Files
DumiFlex-ComfyUI-Wildcard-P…/engine/cascade/fixers.py
T
DumiFlex 4cda98f227 fix(cascade): discover v2 boolean sub-category filter refs
Sub-category rename/delete discovery keyed on the whole filter body, so
it missed any ref using an SP1 boolean filter. `@{mood:warm or intense}`
indexed as `mood:warm or intense` (TS) / matched by `sub == "warm"` exact
equality (Python), so a rename or delete of `warm` neither updated the
SPA badge nor counted the ref in the server-side preview — even though
the rewrite (engine/cascade/fixers.py) already token-matches and DID
rewrite it on apply. Preview undercounted what apply changed.

Both discovery layers now collect the expression's tags via the shared
parser and index/match per-tag (negated tags count; the `!null` marker is
not a tag):
- TS: new collectTags in subcatExprCascade.ts; reverse-dep-index builds
  one toSubcat entry per tag.
- Python: new collect_tags in fixers.py; scan._scan_subcat tests tag
  membership instead of raw-string equality.
2026-06-08 06:41:15 +03:00

731 lines
27 KiB
Python

"""Per-mutation cleanup fixers for cascade-edit operations.
Each fixer handles one (kind, action) combination. All take a live
``sqlite3.Connection`` plus target identifiers, mutate affected entities
via repositories, and return:
(touched, diff)
* ``touched`` — list of entity row dicts captured *before* mutation
(BEFORE-state snapshots). Passed back to the orchestrator so it can
persist them in the undo log.
* ``diff`` — list of lightweight index-patch dicts that describe what
changed, keyed by ``entity_id``.
Contracts:
- Fixers do NOT delete the target entity. The orchestrator handles the
primary mutation.
- Fixers do NOT write to ``cascade_undo``. The orchestrator persists the
undo entry.
- All mutations go through repository methods — never raw SQL.
- Zero ComfyUI imports. Stdlib ``re`` + repositories only.
Caller contract (atomicity)
---------------------------
The caller (orchestrator at engine/cascade/orchestrator.py, Task 5)
MUST wrap each fixer call in an explicit BEGIN…COMMIT/ROLLBACK
transaction. Individual repository methods auto-commit via their
own `with conn:` blocks; the outer transaction the orchestrator
opens is the only rollback boundary if a fixer raises mid-loop.
Without an outer transaction, partial mutations are persisted.
"""
from __future__ import annotations
import copy
import re
import sqlite3
from typing import Any
from engine.db.repositories import BundleRepository, ModuleRepository
from engine.syntax.subcat_filter import ParseError as _ParseError
from engine.syntax.subcat_filter import parse as _parse_subcat
from engine.syntax.subcat_filter import reads_as as _reads_as
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _deepcopy_row(row: dict[str, Any]) -> dict[str, Any]:
"""Return a deep-copied snapshot of a row dict."""
return copy.deepcopy(row)
def _rewrite_var_in_string(s: str, old_name: str, new_name: str) -> str:
"""Replace ``$old_name`` → ``$new_name`` word-boundary-safe.
Uses a negative-lookbehind/lookahead so ``$mood`` matches but
``$moodier`` does not.
"""
pattern = re.compile(
r"\$" + re.escape(old_name) + r"(?![A-Za-z0-9_])"
)
return pattern.sub(f"${new_name}", s)
def _map_tags(ast: dict[str, Any] | None, fn) -> dict[str, Any] | None:
"""Apply *fn* to each tag leaf of a parsed sub-category expression;
drop tags *fn* maps to ``None`` and collapse the now-childless
operator (a one-child and/or becomes that child). Returns ``None``
when the whole expression empties. Mirror of TS `mapTags`
(src/manager/cascade/subcatExprCascade.ts)."""
if ast is None:
return None
if "tag" in ast:
v = fn(ast["tag"])
return None if v is None else {"tag": v}
if ast["op"] == "not":
x = _map_tags(ast["x"], fn)
return {"op": "not", "x": x} if x is not None else None
kids = [k for k in (_map_tags(c, fn) for c in ast["kids"]) if k is not None]
if not kids:
return None
return kids[0] if len(kids) == 1 else {"op": ast["op"], "kids": kids}
def _expr_tags(ast: dict[str, Any] | None) -> set[str]:
out: set[str] = set()
def walk(n: dict[str, Any] | None) -> None:
if n is None:
return
if "tag" in n:
out.add(n["tag"])
return
if n["op"] == "not":
walk(n["x"])
return
for k in n["kids"]:
walk(k)
walk(ast)
return out
def collect_tags(expr: str | None) -> set[str]:
"""Distinct sub-category tags referenced by a filter *expr* string.
Mirror of TS ``collectTags`` (src/manager/cascade/subcatExprCascade.ts).
Empty or malformed expressions yield the empty set — the caller simply
won't link the ref; ``validate_expression`` surfaces real syntax errors
elsewhere. Used by the discovery scan so a rename/delete of any one tag
finds every ref whose boolean filter mentions it (negated tags count).
"""
if not expr or not expr.strip():
return set()
try:
ast = _parse_subcat(expr)
except _ParseError:
return set()
return _expr_tags(ast)
def _ref_expr_pattern(wildcard_id: str) -> re.Pattern[str]:
# 4-segment ref to a specific wildcard: groups = (#name, :expr, !null).
return re.compile(
r"@\{" + re.escape(wildcard_id)
+ r"(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
)
def _rewrite_ref_expr(s: str, wildcard_id: str, target: str, fn) -> str:
"""Rewrite the boolean ``:expr`` of every ``@{wildcard_id...}`` ref
whose expression contains *target*, mapping its tags via *fn*
(rename, or remove + collapse). Refs that don't mention *target* are
left verbatim — no cosmetic re-serialization. ``#name`` + ``!null``
are preserved; an expression that empties drops the ``:expr``
segment, collapsing ``@{uuid:cold}`` to a bare ``@{uuid}``."""
pattern = _ref_expr_pattern(wildcard_id)
def repl(m: re.Match[str]) -> str:
name, expr, null = m.group(1), m.group(2), m.group(3)
if expr is None or not expr.strip():
return m.group(0)
ast = _parse_subcat(expr)
if target not in _expr_tags(ast):
return m.group(0)
new_expr = _reads_as(_map_tags(ast, fn))
out = "@{" + wildcard_id + (f"#{name}" if name else "")
if new_expr:
out += ":" + new_expr
if null == "null":
out += "!null"
return out + "}"
return pattern.sub(repl, s)
def _rewrite_subcat_ref_in_string(
s: str,
wildcard_id: str,
old_subcat: str,
new_subcat: str,
) -> str:
"""Rename ``old_subcat`` -> ``new_subcat`` inside every
``@{wildcard_id...}`` ref's boolean expression (e.g.
``@{u:warm or cold}`` -> ``@{u:warm or chilly}``). Structure,
``#name``, and ``!null`` are preserved."""
return _rewrite_ref_expr(
s, wildcard_id, old_subcat,
lambda t: new_subcat if t == old_subcat else t,
)
def _strip_subcat_ref_in_string(
s: str,
wildcard_id: str,
subcat_name: str,
) -> str:
"""Remove ``subcat_name`` from every ``@{wildcard_id...}`` ref's
expression, collapsing operators (``@{u:warm or cold}`` ->
``@{u:warm}``); a ref whose expression empties collapses to the bare
``@{wildcard_id}`` (the nested insertion survives, only its filter
is dropped)."""
return _rewrite_ref_expr(
s, wildcard_id, subcat_name,
lambda t: None if t == subcat_name else t,
)
def _rewrite_ref_name_in_string(
s: str,
wildcard_id: str,
new_name: str,
) -> str:
"""Rewrite every ``@{wildcard_id...}`` ref in *s* so its ``#name``
segment matches *new_name*. Bare-uuid refs gain the segment;
refs that already carry an old name get it replaced. Subcat
suffix is preserved.
Empty *new_name* drops the segment entirely (canonical bare-uuid
form), which lets the same fixer handle "name was cleared" later
without a separate code path."""
pattern = re.compile(
r"@\{" + re.escape(wildcard_id)
+ r"(?:#[^#:}@{]*)?(:[^}]*)?\}"
)
name_seg = f"#{new_name}" if new_name else ""
def repl(m: re.Match[str]) -> str:
sub_seg = m.group(1) or ""
return f"@{{{wildcard_id}{name_seg}{sub_seg}}}"
return pattern.sub(repl, s)
# ---------------------------------------------------------------------------
# Public fixers
# ---------------------------------------------------------------------------
def fix_wildcard_delete(
conn: sqlite3.Connection,
wildcard_id: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Strip all references to *wildcard_id* from constraints.
* Constraints whose source or target is *wildcard_id* are deleted —
they reference the wildcard by id only, with no fallback shape.
Bundles are intentionally NOT touched. Bundle children are full
frozen snapshots: the wildcard's payload, options, and meta were
deep-copied into ``bundle.children[i]`` at insert time, so the
bundle keeps working even when the source library row is deleted.
Removing the snapshot would destroy data the user explicitly
captured for offline / portable use. The MISSING badge on the
referenced workflow rows still fires (drift-store sees the id is
gone) — that's the honest signal; stripping the snapshot would be
over-eager housekeeping.
The fixer does NOT delete the wildcard row itself; the orchestrator
owns that mutation.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
mod_repo = ModuleRepository(conn)
# --- Constraints --------------------------------------------------------
for m in mod_repo.list():
if m["type"] != "constraint":
continue
p = m.get("payload") or {}
if (p.get("source_wildcard_id") == wildcard_id
or p.get("target_wildcard_id") == wildcard_id):
touched.append(_deepcopy_row(m))
mod_repo.delete(m["id"])
diff.append({"entity_id": m["id"], "removed": True})
return touched, diff
def fix_subcat_delete(
conn: sqlite3.Connection,
wildcard_id: str,
subcat_name: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Strip all references to ``wildcard_id:subcat_name``.
* Source wildcard's ``options[].sub_categories`` — removes the name.
* Constraint matrix keys (source-side) — removes the top-level key.
* Constraint matrix values (target-side) — removes nested keys.
* Other wildcards' option values — removes ``@{wildcard_id:subcat_name}``.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
repo = ModuleRepository(conn)
for m in repo.list():
t = m["type"]
p = m.get("payload") or {}
changed = False
new_payload = copy.deepcopy(p)
if t == "wildcard" and m["id"] == wildcard_id:
# Top-level declared list
declared = new_payload.get("sub_categories") or []
if subcat_name in declared:
new_payload["sub_categories"] = [
s for s in declared if s != subcat_name
]
changed = True
# Per-option membership — drop the deleted subcat from each
# option's sub_categories list (preserves the option).
for opt in new_payload.get("options") or []:
subs = opt.get("sub_categories")
if isinstance(subs, list) and subcat_name in subs:
opt["sub_categories"] = [s for s in subs if s != subcat_name]
changed = True
elif t == "constraint":
matrix = new_payload.get("matrix") or {}
# Source-side: top-level matrix keys are source subcat names
if (new_payload.get("source_wildcard_id") == wildcard_id
and subcat_name in matrix):
del matrix[subcat_name]
new_payload["matrix"] = matrix
changed = True
# Target-side: nested keys within each row value
if new_payload.get("target_wildcard_id") == wildcard_id:
for _row_key, row_val in list(matrix.items()):
if isinstance(row_val, dict) and subcat_name in row_val:
del row_val[subcat_name]
changed = True
elif t == "wildcard" and m["id"] != wildcard_id:
# Strip @{wildcard_id:subcat_name} text refs from option values
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _strip_subcat_ref_in_string(v, wildcard_id, subcat_name)
if new_v != v:
opt["value"] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
repo.update(m["id"], payload=new_payload)
diff.append({
"entity_id": m["id"],
"remove_ref": {"kind": "subcat", "wildcard_id": wildcard_id, "subcat": subcat_name},
})
return touched, diff
def fix_subcat_rename(
conn: sqlite3.Connection,
wildcard_id: str,
old_name: str,
new_name: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Rewrite all references to ``wildcard_id:old_name`` → ``wildcard_id:new_name``.
* Source wildcard's ``options[].sub_categories``.
* Constraint matrix keys (source-side) — renamed top-level key.
* Constraint matrix values (target-side) — renamed nested keys.
* Other wildcards' option values — ``@{uuid:old}`` → ``@{uuid:new}``.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
repo = ModuleRepository(conn)
for m in repo.list():
t = m["type"]
p = m.get("payload") or {}
changed = False
new_payload = copy.deepcopy(p)
if t == "wildcard" and m["id"] == wildcard_id:
# Top-level declared sub_categories list
declared = new_payload.get("sub_categories") or []
if old_name in declared:
new_payload["sub_categories"] = [
new_name if s == old_name else s for s in declared
]
changed = True
# Per-option membership list (`opt.sub_categories`)
for opt in new_payload.get("options") or []:
subs = opt.get("sub_categories")
if isinstance(subs, list) and old_name in subs:
opt["sub_categories"] = [
new_name if s == old_name else s for s in subs
]
changed = True
elif t == "constraint":
matrix = new_payload.get("matrix") or {}
# Source-side: rename top-level matrix key
if (new_payload.get("source_wildcard_id") == wildcard_id
and old_name in matrix):
matrix[new_name] = matrix.pop(old_name)
new_payload["matrix"] = matrix
changed = True
# Target-side: rename nested keys in each row
if new_payload.get("target_wildcard_id") == wildcard_id:
for row_val in matrix.values():
if isinstance(row_val, dict) and old_name in row_val:
row_val[new_name] = row_val.pop(old_name)
changed = True
elif t == "wildcard" and m["id"] != wildcard_id:
# Rewrite @{wildcard_id:old_name} → @{wildcard_id:new_name}
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_subcat_ref_in_string(v, wildcard_id, old_name, new_name)
if new_v != v:
opt["value"] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
repo.update(m["id"], payload=new_payload)
diff.append({
"entity_id": m["id"],
"rename_ref": {
"kind": "subcat",
"wildcard_id": wildcard_id,
"old": old_name,
"new": new_name,
},
})
return touched, diff
def fix_wildcard_rename_name(
conn: sqlite3.Connection,
wildcard_id: str,
new_name: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Rewrite every ``@{wildcard_id...}`` ref so its ``#name`` segment
matches *new_name*. Walks the same surfaces ``fix_wildcard_delete``
+ ``fix_subcat_rename`` cover for ref strings:
* Other wildcards' option values.
* Derivation rules' action string values (the only surface besides
wildcards where ``@{}`` text refs are resolved).
Combine templates, fixed-values, and constraint payloads don't hold
``@{}`` text refs (constraints reference by id only via
``source_wildcard_id`` / ``target_wildcard_id``), so they're
skipped. Bundle children carry frozen module snapshots whose option
values may contain refs; we walk those too so a save propagates
into bundle copies.
Skips the renamed wildcard itself — its own name lives on the row,
not in its own option values.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
mod_repo = ModuleRepository(conn)
bundle_repo = BundleRepository(conn)
for m in mod_repo.list():
if m["id"] == wildcard_id:
continue
t = m["type"]
p = m.get("payload") or {}
new_payload = copy.deepcopy(p)
changed = False
if t == "wildcard":
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
if new_v != v:
opt["value"] = new_v
changed = True
elif t == "derivation":
for rule in new_payload.get("rules") or []:
for branch in rule.get("branches") or []:
for action in branch.get("actions") or []:
if not isinstance(action, dict):
continue
for k, v in list(action.items()):
if isinstance(v, str):
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
if new_v != v:
action[k] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
mod_repo.update(m["id"], payload=new_payload)
diff.append({
"entity_id": m["id"],
"rename_ref": {
"kind": "wildcard_name",
"wildcard_id": wildcard_id,
"new": new_name,
},
})
# Bundle child snapshots — walk frozen module copies and rewrite
# ref strings in their option values. Children may reference the
# renamed wildcard via @{uuid} in their stored snapshots.
for b in bundle_repo.list():
children = b.get("children") or []
new_children = copy.deepcopy(children)
bundle_changed = False
for ch in new_children:
if not isinstance(ch, dict):
continue
if ch.get("type") != "wildcard":
continue
if ch.get("id") == wildcard_id:
# The renamed wildcard's own snapshot in this bundle —
# update the cached `name` field instead of refs.
if ch.get("name") != new_name:
ch["name"] = new_name
bundle_changed = True
continue
payload = ch.get("payload") or {}
for opt in payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
if new_v != v:
opt["value"] = new_v
bundle_changed = True
if bundle_changed:
touched.append(_deepcopy_row(b))
bundle_repo.update(b["id"], children=new_children)
diff.append({
"entity_id": b["id"],
"rename_ref": {
"kind": "wildcard_name",
"wildcard_id": wildcard_id,
"new": new_name,
},
})
return touched, diff
def fix_bundle_delete(
conn: sqlite3.Connection,
bundle_id: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Strip every ``{type: "bundle", id: bundle_id}`` ref from other
bundles' ``children[]`` lists. Mirrors `fix_wildcard_delete` for
bundles — when a bundle is removed, parent bundles holding it as
a tier-2 ref are cleaned so they don't ship `_missing_ref` on
their next GET expansion.
The fixer does NOT delete the bundle row itself; the orchestrator
owns that mutation. Skips self-reference (tier-2 cap forbids it,
defensive).
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
bundle_repo = BundleRepository(conn)
for b in bundle_repo.list():
if b["id"] == bundle_id:
continue
children = b.get("children") or []
new_children = [
ch for ch in children
if not (ch.get("type") == "bundle" and ch.get("id") == bundle_id)
]
if len(new_children) != len(children):
touched.append(_deepcopy_row(b))
bundle_repo.update(b["id"], children=new_children)
diff.append({
"entity_id": b["id"],
"remove_ref": {"kind": "bundle", "id": bundle_id},
})
return touched, diff
def fix_combine_output_var_rename(
conn: sqlite3.Connection,
combine_id: str,
old_name: str,
new_name: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Rename a combine's output variable and all ``$old_name`` references.
* Combine's ``output_var`` field updated to *new_name*.
* Other wildcards: ``$old_name`` → ``$new_name`` in option values.
* Derivations: ``$old_name`` → ``$new_name`` in action string values;
``set_var: "old_name"`` → ``set_var: "new_name"``.
* Other combines: ``$old_name`` → ``$new_name`` in template.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
repo = ModuleRepository(conn)
for m in repo.list():
t = m["type"]
p = m.get("payload") or {}
changed = False
new_payload = copy.deepcopy(p)
if t == "combine" and m["id"] == combine_id:
# Update the combine's own output_var
if new_payload.get("output_var") == old_name:
new_payload["output_var"] = new_name
changed = True
# Also rewrite $old in its own template in case it self-references
tpl = new_payload.get("template", "")
if isinstance(tpl, str):
new_tpl = _rewrite_var_in_string(tpl, old_name, new_name)
if new_tpl != tpl:
new_payload["template"] = new_tpl
changed = True
elif t == "wildcard":
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_var_in_string(v, old_name, new_name)
if new_v != v:
opt["value"] = new_v
changed = True
elif t == "derivation":
for rule in new_payload.get("rules") or []:
for branch in rule.get("branches") or []:
for action in branch.get("actions") or []:
if not isinstance(action, dict):
continue
for k in list(action.keys()):
v = action[k]
if k == "set_var" and v == old_name:
action[k] = new_name
changed = True
elif isinstance(v, str):
new_v = _rewrite_var_in_string(v, old_name, new_name)
if new_v != v:
action[k] = new_v
changed = True
elif t == "combine" and m["id"] != combine_id:
tpl = new_payload.get("template", "")
if isinstance(tpl, str):
new_tpl = _rewrite_var_in_string(tpl, old_name, new_name)
if new_tpl != tpl:
new_payload["template"] = new_tpl
changed = True
if changed:
touched.append(_deepcopy_row(m))
repo.update(m["id"], payload=new_payload)
diff.append({
"entity_id": m["id"],
"rename_ref": {"kind": "var", "old": old_name, "new": new_name},
})
return touched, diff
def fix_option_delete(
conn: sqlite3.Connection,
wildcard_id: str,
option_id: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Remove option from source wildcard + scrub it from constraint exceptions.
Standard fixer contract: returns (touched_snapshots, diff). The
orchestrator's outer ``with conn:`` is the rollback boundary; this
function neither opens nor commits a transaction.
Matching is by stable ``option_id`` so a value-rename (which keeps
the id) does not silently delete the wrong option.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
repo = ModuleRepository(conn)
# --- Source wildcard: remove option entry --------------------------
src = repo.get(wildcard_id)
new_payload = copy.deepcopy(src.get("payload") or {})
original_opts = list(new_payload.get("options") or [])
new_options = [o for o in original_opts if o.get("id") != option_id]
if len(new_options) == len(original_opts):
# Option already missing; nothing to do.
return touched, diff
touched.append(_deepcopy_row(src))
new_payload["options"] = new_options
repo.update(wildcard_id, payload=new_payload)
diff.append({"entity_id": wildcard_id, "remove_option": option_id})
# --- Constraints: drop exceptions referencing option_id ------------
for m in repo.list():
if m["type"] != "constraint":
continue
p = m.get("payload") or {}
exceptions = p.get("exceptions") or []
kept = [
e for e in exceptions
if e.get("source_id") != option_id and e.get("target_id") != option_id
]
if len(kept) == len(exceptions):
continue
touched.append(_deepcopy_row(m))
new_p = copy.deepcopy(p)
new_p["exceptions"] = kept
repo.update(m["id"], payload=new_p)
diff.append({
"entity_id": m["id"],
"remove_ref": {"kind": "option", "option_id": option_id},
})
return touched, diff
def fix_category_delete(
conn: sqlite3.Connection,
category_id: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Null out ``category_id`` on every module referencing *category_id*.
Does NOT delete the category row itself; the orchestrator handles that.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
repo = ModuleRepository(conn)
for m in repo.list():
if m.get("category_id") == category_id:
touched.append(_deepcopy_row(m))
repo.update(m["id"], category_id=None)
diff.append({
"entity_id": m["id"],
"remove_ref": {"kind": "category", "id": category_id},
})
return touched, diff