Compare commits

...
Author SHA1 Message Date
semantic-release-bot 02a046321f chore(release): 2.3.1 [skip ci] 2026-06-18 01:06:25 +00:00
Dumitru Picalau 6a5a8d318e Merge pull request #3 from DumiFlex/fix/instance-modal-scroll
fix(extension): scroll instance edit modals with pinned header + footer
2026-06-18 04:05:45 +03:00
DumiFlex a19e5f4500 Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll
# Conflicts:
#	scripts/check-bundle-size.mjs
2026-06-18 03:44:54 +03:00
DumiFlex c43bff0488 fix(extension): scroll instance edit modals with pinned header + footer
Every per-instance edit modal (wildcard, fixed-values, derivation, combine, constraint, bundle) now scrolls its body when content overflows, with the header pinned to the top and a sticky dock pinned to the bottom (runtime + footer, or target-reach + footer for constraints, which have no runtime) so Save is always reachable. The long inner lists — options, values, rules, exceptions — each scroll independently with sticky column headers. Constraint's target-reach moves to last. Compact + right-padded the exceptions delete button. Bumps the extension bundle budget 430->431 KB for the added modal CSS.
2026-06-18 03:23:24 +03:00
semantic-release-bot a3a3dbe2df chore(release): 2.3.0 [skip ci] 2026-06-17 22:58:00 +00:00
Dumitru Picalau 6e8696fa85 Merge pull request #2 from DumiFlex/feat/manager-bulk-editor
Feat/manager bulk editor
2026-06-18 01:57:18 +03:00
DumiFlex 959cfbe179 feat(manager): add bulk editor for wildcard options and fixed values
Adds an in-editor bulk mode to the wildcard + fixed-value editors. Bulk add pastes options (value [#tag] [*N] with trailing order-free modifiers; new sub-categories auto-create in Ungrouped) or fixed values (name = value, existing names update in place). In bulk mode a checkbox column + selection toolbar apply/remove a sub-category, set the weight, or delete across every checked row at once; constraint-referenced options are kept and reported rather than silently dropped. Pure bulkParse.ts util plus two presentational components (BulkAddPanel, SelectionToolbar) reusing the shared Checkbox/Input. 27 new tests.
2026-06-18 01:34:00 +03:00
DumiFlex 325c90818f feat(widgets): shield clipboard shortcuts on editable widget inputs
ComfyUI's canvas captured Ctrl+A/C/V/X/Z/Y on widget text inputs, so copy/paste pasted whole graph nodes instead of editing the field. clipboard-shield.ts stops those shortcuts (and copy/cut/paste events) from bubbling past an editable target without ever calling preventDefault, so native editing still works. Wired into the widget inner via _shared.ts plus a body-level catch-all in main.ts for body-teleported modals like the Context Injector variable-binding field.
2026-06-18 01:29:40 +03:00
DumiFlex 0f5166e7d1 fix(manager): recover from rich-text editor crash and persist drafts on unload
RichTextInput rebuilt an index-keyed v-for over a hand-mutated contenteditable when the nested-ref picker stole focus, throwing insertBefore/null and freezing the page. Scope a renderEpoch rebuild to the chip-insert path so typing/backspace is untouched. useEditorDraft now flushes the pending debounced draft on unmount and beforeunload, so a refresh after a freeze no longer loses work; ErrorBoundary tells the user their work was auto-saved. Bumps the extension bundle budget 430->431 KB for the added widget code.
2026-06-18 01:25:24 +03:00
semantic-release-bot 9c1bb35a78 chore(release): 2.2.0 [skip ci] 2026-06-16 00:03:54 +00:00
Dumitru Picalau a53d5266de Merge pull request #1 from DumiFlex/feat/community-embed
feat: Wildcard Pipeline Community integration (embed, schema versioning, SP1-SP3, reattachment)
2026-06-16 03:03:13 +03:00
DumiFlex 3065e99c43 test(syntax): move subcat_filter tests into tests/syntax for the coverage gate 2026-06-15 21:53:04 +03:00
DumiFlex 844d16e677 docs(constraint): document target reach selectors and axis reattach 2026-06-15 18:38:49 +03:00
DumiFlex 402fdcc453 docs(derivation): document nested refs in action values 2026-06-15 18:29:23 +03:00
DumiFlex 8b99795dae docs(wildcard): document multi-pick, sub-categories, and nested refs 2026-06-15 18:21:28 +03:00
DumiFlex 7297b2ff7c docs(syntax): document multi-pick, nested-in-inline, $var.K, and the surface gate 2026-06-15 18:10:50 +03:00
DumiFlex feb760e2a0 feat(publish): verify published deps + reclassify stale ones to publish-first 2026-06-15 15:36:35 +03:00
DumiFlex 33316aa494 feat(publish): surface bundle children external refs as dependencies 2026-06-15 13:19:00 +03:00
DumiFlex 7b2d057f34 fix(import-export): refresh also reloads the export tab listing 2026-06-15 12:53:08 +03:00
DumiFlex c66c1f52fa fix(library): validate all-items bundles against loaded catalog not empty store 2026-06-15 12:43:02 +03:00
DumiFlex e458e8d75a fix(bundles): reconcile restored inner-bundle fingerprint to clear false drift 2026-06-15 12:21:07 +03:00
DumiFlex f8edcfedd5 feat(publish): wire inner-bundle dependency publish button to navigate 2026-06-15 12:05:16 +03:00
DumiFlex f58d849500 feat(bundles): manual per-child reattach with community download 2026-06-15 00:02:42 +03:00
DumiFlex b4f2d6bf5a feat(install): auto-reattach inner-bundle references on bundle install 2026-06-14 23:51:04 +03:00
DumiFlex e7e844b7e8 feat(publish): surface inner-bundle dependencies in guided publish 2026-06-14 23:41:10 +03:00
DumiFlex f1ab33c025 feat(bundles): move extract action to children header, used-by beside title 2026-06-14 23:10:58 +03:00
DumiFlex b1fa0ce411 feat(bundles): relink bundle to extracted modules + gate extract on dangling children 2026-06-14 23:01:41 +03:00
DumiFlex 0984a7f450 fix(constraint): origin-stamp downloaded deps so they get the community badge 2026-06-14 22:25:11 +03:00
DumiFlex 02ad0ebcc7 feat(constraint): download missing deps from community + reattach 2026-06-14 21:33:56 +03:00
DumiFlex 7dc9817e71 feat(community): orchestrate transitive dep download for dangling refs 2026-06-14 21:19:43 +03:00
DumiFlex c858bef9c3 feat(community): add public post detail + download fetch helpers 2026-06-14 21:12:53 +03:00
DumiFlex 1ee8fe28ec feat(community): add transitive dependency closure walker 2026-06-14 21:06:16 +03:00
DumiFlex 59af070e7d feat(bundles): extract bundle children to library with ref remap 2026-06-14 20:51:58 +03:00
DumiFlex 223770e18c feat(bundles): add extractBundleChildren fresh-id materialization helper 2026-06-14 20:38:57 +03:00
DumiFlex a9bde14434 feat(install): host bridge forwards post deps + library slug 2026-06-14 20:02:37 +03:00
DumiFlex 0494e3c1a5 feat(install): auto-reattach references via slug-bridge on install 2026-06-14 19:51:07 +03:00
DumiFlex e138868abc feat(install): add reattach type surface (deps, slug, count) 2026-06-14 19:40:34 +03:00
DumiFlex a5f95cf172 refactor(install): applyCollisionDecisions returns rename map 2026-06-14 19:30:30 +03:00
DumiFlex 7845d91bc5 feat(install): add buildReattachRemap slug-bridge helper 2026-06-14 19:21:33 +03:00
DumiFlex 50612dcc39 fix(publish): stamp constraint source/target wildcard names so shares self-describe 2026-06-14 18:13:12 +03:00
DumiFlex 9a3b5fafb4 fix(publish): forward schema_version stamp into the community publish hash 2026-06-14 17:33:35 +03:00
DumiFlex 292a0a547c feat(publish): guided unmet-dependency checklist before publish 2026-06-14 13:53:35 +03:00
DumiFlex 107d5f4432 feat(publish): auto-detect + inject module dependencies into publish hash 2026-06-14 13:04:32 +03:00
DumiFlex e709d538af feat(publish): add listReferencedUuids dependency detector 2026-06-14 12:50:39 +03:00
DumiFlex 9f7ee12218 fix(schema): accept natively-supported future versions on install (max-known, not chain head) 2026-06-14 12:14:34 +03:00
DumiFlex 0cb57064b1 fix(schema): advertise max-known schema version to publish gate, not chain head 2026-06-14 11:55:27 +03:00
DumiFlex 5ae0ff083f feat(constraint): canvas parity for stranded read-only matrix + colored exception modes 2026-06-14 02:01:17 +03:00
DumiFlex 4598f68d28 feat(constraint): lock stranded wildcard fields + color-code exception modes 2026-06-14 01:23:45 +03:00
DumiFlex d188ead525 feat(constraint): backfill cached wildcard name from live catalog on save 2026-06-13 18:43:10 +03:00
DumiFlex fa68a364c7 feat(constraint): show stranded constraint matrix + exceptions read-only in spa editor 2026-06-13 18:31:11 +03:00
DumiFlex 97d3036e6a fix(cascade): stamp deleted wildcard name onto dependent constraints for broken-ref banner 2026-06-13 18:12:56 +03:00
DumiFlex eff7a6a8ed fix(bundles): stamp payload_hash on cascade-restored bundle children so re-insert shows no drift 2026-06-13 17:12:40 +03:00
DumiFlex 37ca8bdd52 fix(bundles): sync restored module payload_hash on cascade so canvas shows no drift 2026-06-13 16:53:05 +03:00
DumiFlex fd254a6aa2 feat(conflicts): surface broken nested @{} refs at module row 2026-06-13 12:46:15 +03:00
DumiFlex c10236108c test(constraint): double-cast findComponent to VueWrapper in cached-names test 2026-06-12 22:02:48 +03:00
DumiFlex 70da7e0faf feat(constraint): cache source/target wildcard names for broken-ref banner
Stamp the source/target wildcard display names onto the constraint payload
(source_wildcard_name / target_wildcard_name) when the source/target is set,
so the broken-reference banner can show "Source wildcard 'Starter subject'
(54693e08)" instead of just the uuid after the wildcard is deleted.

Display-only and additive: the engine resolves by uuid and never reads these
names (diagnostic, like producer_engine_version), so there is no engine
resolution change, no Py=TS parity, and no community schema bump. Legacy
constraints lack the fields and gracefully fall back to uuid-only.

SPA ConstraintEditor stamps on pick + reattach + load and persists when
known; the canvas ConstraintInstanceModal mirrors the reattach stamp and
falls back to the cached name in the banner when the live lookup misses.

Spec: docs/superpowers/specs/2026-06-12-constraint-cached-ref-names-design.md
2026-06-12 21:56:54 +03:00
DumiFlex fa0f2ceca1 fix(bundles): repoint cascade-restored standalone library refs (phase 1 pass 2)
phase 1 minted a fresh library entry per missing module by POSTing its
payload raw, but never rewrote internal refs. a restored constraint (or
any module referencing a sibling restored module) shipped a standalone
library entry with dangling source_wildcard_id / target_wildcard_id /
@{} refs pointing at the old, now-deleted uuids. forward-ref order made
a mid-loop rewrite impossible: the constraint is POSTed before its target
wildcard mints a new id, so moduleMap is incomplete at that moment.

split phase 1 into two passes. pass 1 still POSTs raw to MINT ids. pass 2
runs after the loop (moduleMap complete), walkRemaps each restored
module payload against the full map, and corrects the freshly-created
entry via api.modules.update only when a ref actually moved. ref-free
modules are skipped. this is the same rewrite phase 3 (pushed children)
and phase 4 (workflow rebind) already apply; the standalone library
entries were the third, unfixed output.
2026-06-12 21:36:54 +03:00
DumiFlex 6fe52c065f fix(bundles): repoint workflow module refs on cascade restore, not just ids 2026-06-12 21:08:28 +03:00
DumiFlex a4d19134b7 refactor(bundles): delete dead remapBundleUuids, keep walkRemap 2026-06-12 20:31:04 +03:00
DumiFlex 9071dfba6d fix(engine): bind constraint to its own bundle copy's source pick 2026-06-12 20:23:36 +03:00
DumiFlex 690169bda3 test(engine): lock constraint source-pick fallback semantics 2026-06-12 20:16:13 +03:00
DumiFlex febb9f3e70 feat(engine): capture constraint's own bundle_origin on registered meta 2026-06-12 20:09:40 +03:00
DumiFlex d6094ed393 feat(engine): record per-origin source pick view in __wp_picks__ 2026-06-12 20:03:02 +03:00
DumiFlex 7bcfcac9ad feat(engine): thread bundle_origin into resolve ctx per module 2026-06-12 19:56:18 +03:00
DumiFlex 3884ded916 fix(manager): re-anchor remap popup on resize + cap height to viewport 2026-06-12 18:46:28 +03:00
DumiFlex 9a976137f0 feat(cascade): per-entity wildcard-delete cleanup via cleanup_ids
Wildcard-delete now lets the user opt in per affected entity which dead
@{uuid} references the engine strips, instead of a single all-or-nothing
cascade_refs flag.

- CascadeConfirmDialog: wildcard-delete branch renders per-row controls —
  constraints are informational ("reattach to heal", never cleaned),
  nested-ref wildcards/derivations get a default-unchecked "strip dead
  reference" checkbox. Checked ids form cleanup_ids on confirm. Other
  delete kinds keep the single cascade_refs checkbox unchanged.
- useCascadeApply: add cleanup_ids?: string[] to CascadeApplyRequest;
  apply() forwards it when present (dryRun unchanged).
- wp_api/cascade.py: document cleanup_ids in the body shape (already
  forwarded verbatim to apply_cascade; no per-field plumbing needed).
- Tests: dialog cases for the mixed affected set + cascade_refs
  regression-lock; server test proving cleanup_ids reaches the engine
  end-to-end (strips opted-in ref, leaves omitted ref broken).
2026-06-12 17:52:10 +03:00
DumiFlex e288d1b31d fix(cascade): wildcard-delete leaves constraints, opt-in nested-ref strip
Constraints are now healable via the reattach UI, so the cascade no
longer deletes them on wildcard delete — they are left broken for the
user to re-point later. Remove the constraint-delete loop entirely.

Nested @{wildcard_id...} refs (the deleted wildcard inserted inside
another wildcard option value or a derivation action string) are
stripped only for the entity ids passed in a new cleanup_ids list;
everything else is left untouched. apply_cascade reads
req["cleanup_ids"] and forwards it. Empty/absent cleanup_ids strips
nothing — the new default, which also fixes the old "untick ignored"
bug for wildcard delete.

Add _strip_whole_ref_in_string, reusing the shared _ref_expr_pattern
to remove the whole @{...} token (vs the subcat helper that only drops
the :expr filter), collapsing seam whitespace.
2026-06-12 17:35:56 +03:00
DumiFlex 498a4ba935 fix(manager): anchor remap popup to the chip using its real height 2026-06-12 16:15:42 +03:00
DumiFlex 236c39ac49 feat(import): apply walkremap follow-through over committed entities on rename 2026-06-12 15:53:09 +03:00
DumiFlex 4dd6d6c48e feat(import): walkremap follow-through helper for renamed friend ids 2026-06-12 15:41:42 +03:00
DumiFlex 1bf1e42b49 feat(constraint): spa editor reattach control mirrors the canvas modal 2026-06-12 15:34:07 +03:00
DumiFlex 47af97ee11 fix(constraint): derive real dropped-cell count for reattach preview
The modal never bound :dropped-cell-count, so it stayed 0 and the
spec-mandatory pre-confirm reconcile preview ("N cells dropped") never
rendered — the user reattached blind to how many matrix cells a
different-axes wildcard would discard. Confirm is irreversible (no v1
undo), so this count must show BEFORE confirm.

ConstraintReattachSection now emits its live pre-confirm pick (and a
pickcleared signal on open/cancel/confirm); ConstraintInstanceModal
tracks it and derives a real PER-CELL drop count against the candidate's
sub_categories — a vanished source row drops all its cells, a vanished
target key drops one cell per row that carries it — then binds it to the
section. Reattach emit shape is unchanged.
2026-06-12 15:21:45 +03:00
DumiFlex 77f00ae975 feat(constraint): canvas modal detects dangling source/target and reattaches via walkremap 2026-06-12 15:09:29 +03:00
DumiFlex 07dcf313df fix(constraint): float cached-name match in reattach dropdown (auto-suggest)
The reattach dropdown seeded its candidate search empty and sorted
candidates alphabetically only, discarding the spec's "Auto-suggest
matching" behaviour. The broken ref's cached #name must be the primary
match signal so an import-from-friend case (friend's `colour` -> local
`colour`) surfaces the name match at the top.

Mirror sibling #3 (RemapRefPopup): keep the filter input empty so all
candidates stay visible, but use the open side's cached name as a SORT
KEY that floats an exact (case-insensitive) name match to the top; the
typed query stays a secondary substring filter; remaining candidates
sort alphabetically. Dangling source/target uuids stay excluded.
2026-06-12 15:00:31 +03:00
DumiFlex f7369ff40e feat(constraint): reattach section for dangling source/target with blast-radius warning 2026-06-12 14:49:07 +03:00
DumiFlex 770909dec7 feat(manager): thread module-id so remap clears broken-ref warnings in all #3 hosts 2026-06-12 14:40:37 +03:00
DumiFlex a75c7371f5 feat(manager): wire @remap in richtextinput to heal the open field 2026-06-12 14:32:38 +03:00
DumiFlex e897bd3eba feat(manager): remaprefpopup for healing broken nested chips 2026-06-12 14:20:13 +03:00
DumiFlex 43de217109 feat(refchip): distinct remap emit for unresolved broken chips 2026-06-12 14:08:58 +03:00
DumiFlex fd25a63bf0 feat(cascade): reconcileSubcatExpr token reconciler for remap preview 2026-06-12 14:01:54 +03:00
DumiFlex 1d59df9f73 feat(cascade): rewriteBrokenRef pure rewriter for broken-ref remap 2026-06-12 13:46:36 +03:00
DumiFlex 96fc26a1ae docs: flag pick-bucket collision + deprecate dead remapBundleUuids 2026-06-12 12:59:19 +03:00
DumiFlex 67de90c37b test: lock cascade-restore end-to-end constraint repoint (#2) 2026-06-12 12:49:37 +03:00
DumiFlex e748064969 fix(cascade): keep bundles frozen on wildcard rename (no auto-mutate)
fix_wildcard_rename_name (called from wp_api/modules.py on a wildcard name update) walked every bundle and rewrote its frozen child snapshots — the cached #name on the renamed wildcard plus @{uuid#name} refs in sibling children. So editing/renaming a wildcard silently mutated every bundle holding a snapshot of it, defeating the snapshot contract.

Removed the bundle-walk block so a rename leaves bundle snapshots untouched (mirrors fix_wildcard_delete, which already preserves them). Bundles drift instead; only the explicit refresh-drifted-and-update-bundle flow re-snapshots. Live module @{} ref labels are still rewritten — only frozen bundle copies are left alone.
2026-06-12 11:39:50 +03:00
DumiFlex bb9ec6dfd6 fix(bundle): remap constraint source/target to restored uuids on cascade push
cascade-restore rewriteChildId only rewrote each child entry top-level id, never its payload. So when the cascade push re-created a missing wildcard under a fresh uuid, a sibling constraint kept pointing source_wildcard_id/target_wildcard_id at the dead uuid (and @{} refs likewise) — the pushed bundle shipped broken source/target.

Deep-walk payload + instance via walkRemap (now exported from uuid-remap) with the combined module+inner-bundle restore table, so restored uuids flow into constraint source/target + @{} refs. Refs to non-restored (external) uuids stay untouched.
2026-06-12 11:26:15 +03:00
DumiFlex e69ceaeb9a fix(spa): show rich-text placeholder ghost (zwsp pad defeated :empty)
The placeholder ghost was gated on .wp-rt__host:empty, but the host always carries a ZWSP pad span (padAtoms), so :empty never matched and the placeholder never painted for ANY RichTextInput — including the derivation override fields the user expects to show the library default.

Gate the ghost on an explicit wp-rt__host--empty class that tracks live content (updated on every input + programmatic apply, so it clears on the first keystroke like a native input). The ::before now overlays the text area (host position:relative + absolute ::before with padding:inherit) so the caret stays at the start. Revives placeholders across all RichTextInput hosts (combine, SPA derivation, canvas overrides).
2026-06-12 10:49:44 +03:00
DumiFlex ec3508a0bf fix(canvas): picker z-index + teleport theme + inline ↪#N badge in derivation summary
Three polish fixes for the canvas derivation instance modal's @{} feature:

- Fix 1 (z-index): raise the SubcategoryFilterPicker teleport backdrop/anchor
  from 1000/1001 to 10000/10001 so the step-2 sub-category picker sits ABOVE
  the canvas instance modal (matching the @-autocomplete popover's 9999 tier),
  instead of rendering behind it. CSS-only; SPA modals live well below 9999.

- Fix 2 (theme): propagate the host's resolved wp-theme-light/wp-theme-dark
  class onto the body-teleported autocomplete popover + picker anchor/backdrop
  so their --wp-* tokens resolve to the host palette rather than the base
  :root default. Detected live from the host's nearest themed ancestor, so it
  is correct in both the SPA and the canvas.

- Fix 3 (badge): render the inline ↪#N constraint-pair PairBadge after the
  value chips in the derivation rule summary (IF/ELIF/ELSE rows + collapsed
  rule head), mirroring OptionRow's per-option badge. Threads the existing
  via-option-pairs map (keyed by engine branch key) through ModuleEditModal
  -> DerivationInstanceModal -> RulesSection; reuses PairBadge variant=option.
2026-06-12 10:25:55 +03:00
DumiFlex ab813bbca4 feat(canvas): @{} chips + autocomplete in derivation instance modal
The CANVAS derivation instance modal now reaches full parity with the
SPA derivation editor: the read-only rule summary renders nested
`@{uuid}` refs + `$var` references as chips (was raw
`@{ad00acd5#mood:warm}` text), and the per-branch override fields are
RichTextInputs with `@`/`$` autocomplete + the sub-category filter
pop-up — replacing the plain `<input>`s.

Reuses the existing machinery rather than rebuilding it:
- RulesSection summary chips delegate to a new RuleValueChips
  presentational component that copies the wildcard OptionRow's
  tokenizeRich-based chip markup + palette verbatim.
- The action + ELSE-action override fields mount the lazily-imported
  RichTextInput (`surface=derivation`, `allow-nested-refs`, the six
  buildWildcardRefData maps) — engine resolves `@{}` there as a carrier.
- The condition override field is RichTextInput too but WITHOUT
  allow-nested-refs / ref-data (engine compares condition.value RAW);
  it still gets `$var` suggestions, mirroring the SPA condition field.
- DerivationInstanceModal fetches /wp/api/modules once on mount and
  builds the ref-data via the shared buildWildcardRefData +
  collectLibraryWildcardRefs (self id excluded). The `@{}` source is
  the LIBRARY by identity, not the chain.
- ModuleEditModal forwards upstream/sibling vars (the `$var` source);
  sibling/chain MODULES are intentionally not forwarded.

RichTextInput stays a single shared lazy chunk (Vite dedupes the
import combine's TemplateSection already pulls), so the extension
bundle stays within budget.
2026-06-12 07:44:25 +03:00
DumiFlex 2a1123f549 fix(spa): block native delete eating the ZWSP pad at field boundaries
Real-browser bug (jsdom could not reproduce): pressing Backspace with the caret at the absolute start of a chip field hit a bare early-return BEFORE preventDefault, so Chromium ran its native deleteContentBackward and ate the ZWSP pad char in the leading text span. Losing the pad stranded the caret on the host root; the contenteditable DOM then permanently diverged from the atoms model, producing the stuck "cant delete / $#" state and a downstream Vue insertBefore-null render crash.

Fix: preventDefault before returning at both empty boundaries (Backspace at range.start===0, forward-Delete at range.start>=totalLen) so the browser never natively deletes the pad. Diagnosed via a live in-browser console trace (jsdom cannot perform native contenteditable deletion); regression test asserts the guard fires.
2026-06-12 07:13:30 +03:00
DumiFlex 3564938b72 feat(spa): author @{} nested refs in derivation action values
The engine resolves @{} nested-wildcard refs on the derivation surface (Layer A), but the SPA derivation editor never got the matching authoring support — no @ autocomplete, refs stayed literal text. Reuse the wildcard editor machinery rather than build new.

Extract buildWildcardRefData(catalog) into utils/library-suggestions (one pass → the six per-wildcard maps RichTextInput consumes); WildcardEditor refactors onto it (no behaviour change). Add an allowNestedRefs prop to RichTextInput that opens the existing @-autocomplete gate + ref chipify on a non-wildcard surface. DerivationRuleCard forwards the maps + allow-nested-refs to ACTION + ELSE value inputs ONLY — condition values are compared raw by the engine, so they stay literal. Hint updated.
2026-06-11 22:46:39 +03:00
DumiFlex 0e9b5cfbbf refactor(constraint): centralise derivation branch-key format + py/ts corpus parity
Extract branch_carrier_key() (engine) and branchKey() (TS) so the derivation branch-key format (rule_id:bi / rule_id:else) has a single source per language; route all call sites through them.

Add branch_key_cases to tests/fixtures/constraint-corpus.json; the Py runner (test_constraint_corpus.py) and TS runner (constraint-pairs.test.ts) each assert their production helper against the shared fixture key, so the two formats cannot drift independently.

Resolved-output parity is engine-only (TS never resolves derivation refs); A1 already locks it.
2026-06-11 18:03:29 +03:00
DumiFlex 098c069d53 feat(constraint): list derivation-nested occurrences in reach pick-list + orphan scan
B4: add "derivation" to TargetReachSection local CARRIER_TYPES so
carrierOptionIdsFor finds derivation branch-key option ids in the
pick checklist; extend lookup builder to collect derivation
action.value strings for transitive @{uuid} reach.

B5: extend collectDownstreamNestedReachUuids to scan derivation
payload.rules[].branches[].action.value and else.action.value
with matchAll (same REF_RE as wildcard options), so orphan-scan
counts derivation-hosted @{uuid} refs as valid reach.
2026-06-11 17:45:18 +03:00
DumiFlex b27b9f8d7c fix(constraint): keep @{} carrier regex strict-hex (engine/dep-graph parity)
B2 relaxed REF_REGEX_VIA to [0-9a-z] to fit a non-hex test stub, diverging
constraint-pairs.ts from the canonical [0-9a-f]{8} uuid class used by the
engine tokenizer (tokenize.py / cascade/scan.py / pipeline.py) and the TS
dep-graph.ts twin. Revert to [0-9a-f] and switch the derivation tests to a
real hex stub (aabbccdd) instead. Carrier ref detection now matches the
engine exactly again.
2026-06-11 17:36:47 +03:00
DumiFlex 8247d739ce feat(constraint): recognise derivations as @{} carriers (branch-key option ids)
B2: add refBearingEntries helper + expand CARRIER_TYPES to include derivation;
rewrite carrierOptionIdsFor to iterate refBearingEntries so derivation rules
contribute keyed entries (rule_id:bi / rule_id:else) matching the engine's
derivation_handler branch-key contract. B3: refValuesByUuid build loop in
computePairingsFull now calls refBearingEntries so derivation action values
participate in transitive @{uuid} reach. Also relaxes REF_REGEX_VIA uuid
character class from [0-9a-f] to [0-9a-z] so test stubs with non-hex letters
round-trip correctly (production uuids remain lowercase-hex, a strict subset).
2026-06-11 17:28:51 +03:00
DumiFlex 599c3b17f6 feat(engine): stamp derivation carrier (uid, branch_key) for nested reach
_apply_action now accepts carrier_key and stamps (module_uid, branch_key)
on the resolve context before calling resolve_text, enabling SP3 pick-reach
constraints to target wildcards nested inside a specific derivation branch.
Branch call site passes f"{rule_id}:{bi}"; else call site passes
f"{rule_id}:else".
2026-06-11 17:19:28 +03:00
DumiFlex 059517f740 feat(engine): resolve @{} refs on the derivation surface
open the surface gate in _resolve_ref for surface="derivation" so
action.value strings can embed @{uuid} wildcard refs. conditions
stay raw (they compare literal text, never roll). constraint bucket
threads through build_resolve_ctx unchanged, so chain constraints
re-weight nested targets for free.

update parametrized ref_out_of_surface tests to remove derivation
from the gated surface list — it now resolves like wildcard.
2026-06-11 17:09:46 +03:00
DumiFlex 73a00a3760 fix(context): constraint-reach QA, node codenames, bundle-aware toolbar
QA + polish on the unmerged SP3 constraint-reach work, plus the node-id
redesign and bundle-aware bulk toolbar.

- reach badge reactivity: pairingBadgeEqual compares reach + pairingMapEqual
  compares contributors (context.ts) so a target_select edit re-renders.
- TargetReachSection: neutral section highlight + custom up/down count stepper.
- nested carrier renders ONE badge (constraint-pairs.ts: nested coverage goes
  to viaInbound only, not also contributors).
- instance target_select override reaches the badge: cross-node-pairings.ts
  folds the instance value over payload; the modal collapses to null only when
  it equals the library default (preserves an explicit "all"). Engine
  precedence (instance over payload) locked by tests.
- graph.ts writeBindings honors instance.variable_binding, fixing the duplicate
  shadow ("override") badge + missing-var scan + preview for renamed bindings.
- node identity: fixed, unique, POV-independent adjective-noun codename from the
  litegraph node id (node-codename.ts); read-only neutral chip; dropped the
  editable node_label + A/B/C letters.
- nested-pick label reads "@<target> in <carrier>" (target by display name).
- bulk toolbar: toggle-all flips bundle masters + top-level modules (not bundle
  children); collapse/expand-all flips bundle frames; buttons resized to the
  row-icon size.
2026-06-11 16:38:56 +03:00
DumiFlex d826c6ba61 fix(ts): pick reach stores bare _uid; partial_reach severity info + stale-comment cleanup 2026-06-11 12:49:23 +03:00
DumiFlex 83149630ab docs: update constraint conventions to the SP3 reach model 2026-06-11 10:32:10 +03:00
DumiFlex 826d86f70d feat(ts): stamp schema_version 4 when constraint target_select is non-default 2026-06-11 10:22:32 +03:00
DumiFlex 764c82ee73 feat(ts): debug rows for constraint never_applied + partial_reach 2026-06-11 10:07:23 +03:00
DumiFlex a0f9651c6b feat(ts): library-default target_select in the manager constraint editor 2026-06-11 10:02:22 +03:00
DumiFlex c80605ac4e feat(ts): constraint reach selector — segmented modes + cross-node pick checklist 2026-06-11 09:45:38 +03:00
DumiFlex 3f14a6ff35 feat(ts): per-selector constraint orphan scan (drop one-shot claim-order) 2026-06-11 09:19:46 +03:00
DumiFlex 02ed686129 feat(ts): badge reframe — reach suffix, contributor cluster + solid collapse chip, before name 2026-06-11 09:01:23 +03:00
DumiFlex 39022106c3 feat(ts): pairings mark-all reach + contributors (drop exclusive claim) 2026-06-11 08:37:30 +03:00
DumiFlex a94cd543d0 feat(ts): per-WP_Context node_label, tagged through the cross-node walker + editable header chip 2026-06-11 08:10:20 +03:00
DumiFlex 71e05b1956 fix(ts): thread cross-node chain to the constraint modal (chain-modules prop) 2026-06-11 07:49:06 +03:00
DumiFlex 1ddbe4548f feat(ts): transitive nested-target carrier walk (cap + cycle) with route chain 2026-06-11 07:25:27 +03:00
DumiFlex b20faa0ca8 feat(ts): preview resolver applies combine + reach (engine parity) 2026-06-11 07:06:14 +03:00
DumiFlex 2eb0e365b7 feat(ts): combineConstraintFactor mirror + shared corpus parity
Add the TypeScript mirror of engine/modules/_constraint_math.py with
identical semantics, validated against the SAME shared corpus
(tests/fixtures/constraint-corpus.json) so Py === TS parity holds. Vitest
covers 2 unit cases (multi-tag multiply, exclude-absorbing) plus the 4
corpus cases (multi-tag->1, multi-pick->6, exclude->EXCLUDE, exception->0.25).

Also bump bundle TOTAL_LIMIT 405 -> 430 KB for the upcoming SP3 constraint
reach UI; entry/per-file limits unchanged.
2026-06-11 06:49:59 +03:00
DumiFlex 4165b2e9f4 feat(engine): thread carrier uid + option id for nested-pick constraint reach 2026-06-11 06:42:16 +03:00
DumiFlex be1f765243 feat(engine): constraint reach selector (first/next/all) + multi-constraint combine; retire one-shot
Replace the constraint one-shot/consumed-set model with a reach selector:
each constraint's target_select (default {mode:"all"}) decides which firing
target instances it covers, and every covering constraint is applied +
combined sequentially. A per-constraint hit counter (__wp_constraint_hits__)
replaces the consumed-set; reach is downstream-relative (a constraint is only
in ctx after its module ran).

- apply_constraints_for_target rewritten: hits/firing_uid/carrier_ctx params,
  _occurrence_matches matcher (carrier_ctx None from callers — nested-pick
  threading is a later task). consumed= kept accepted+ignored for back-compat.
- claim_carrier_constraints deleted (the positional spill failsafe is obsolete
  without consumption); its import + both wildcard_handler call sites + the
  _claim_carried helper removed.
- resolve.py nested path shares the ctx hit counter (get_constraint_hits) so
  first/next coverage spans direct + nested encounters; carrier_ctx=None.
- pipeline.py finalisation now hits-based: constraint_never_applied when a
  selector covered zero, new constraint_partial_reach when next N / pick
  matched fewer than asked.
- cross-node: __wp_constraint_hits__ replaces __wp_consumed_constraints__ in
  _CROSS_NODE_INTERNAL_KEYS; build_payload dedup also collapses partial_reach.

Existing one-shot tests adapted to the new semantics:
- test_constraint_first_instance: the implicit "fires once" tests re-expressed
  via explicit target_select={mode:"first"}; added an all-mode-default test and
  a partial_reach finalisation test. The pre-seeded-consumed no-op test became a
  first-mode "counter already past 1" test (consumed-set concept gone).
- test_user_chain_simulation: consumed assertions → hit-count assertions; the
  carrier-claim tests (no-spill, null-pick claim, source-not-picked, sibling
  one-shots) rewritten to the new model — a carrier that doesn't resolve the ref
  no longer "claims" anything, so the constraint applies on the next actual
  resolution (or legitimately never fires → never_applied).
- test_module_interactions: "only first fires" composition test replaced with a
  two-all-constraints-compose-on-one-target test (both excludes zero the pool →
  excludes-all fallback), plus a single-all-constraint survivor test.
2026-06-11 06:21:57 +03:00
DumiFlex 8c3c9aedc2 feat(engine): validate + register constraint target_select; instance override field 2026-06-11 05:53:44 +03:00
DumiFlex f68d2ece81 feat(engine): per-pick recording + apply constraints via combine_constraint_factor 2026-06-11 05:41:40 +03:00
DumiFlex dd785c5ca7 feat(engine): pure combine_constraint_factor + shared constraint corpus 2026-06-11 05:29:40 +03:00
DumiFlex 8ad2d0cec6 fix(ui): stack the pool allow-repeats + exclude-null toggles
Both were `display: inline-flex`, so when multi-pick + a null option are both
present they flowed onto one line and aligned by baseline — the two boxes read
as vertically misaligned (only visible now that the boxes actually render).
Make each a block-level `flex` row with `width: fit-content` so they stack with
their boxes left-aligned in a column.
2026-06-10 02:13:48 +03:00
DumiFlex 8000e1b75c fix(ui): render embed/canvas checkboxes via a scoped WpCheck component
The inline `<span class="wp-check">` controls relied on the global `.wp-check`
rule from manager/styles/tokens.css, which is loaded in the SPA but NOT on the
ComfyUI canvas (settings.ts pulls a hand-picked CSS set; tokens.css is not in
it). So in canvas-mounted instance modals the converted checkboxes rendered as
unstyled, zero-size spans — invisible. (`.opt__check` survived because it is
component-scoped CSS, which ships into every bundle the component renders in.)

Fix: extract the wildcard-option box into `WpCheck.vue` — a shared component
whose `<style scoped>` mirrors `.opt__check` verbatim. Scoped CSS is compiled
into the component chunk, so it travels into the SPA *and* the canvas bundle
with no dependency on any global stylesheet. Route PoolSection, DisabledRules-
Section, SubcategoryFilterPicker, Push/PushBundleToLibraryModal, and
DisplayPlaygroundModal through it.

The SPA keeps its global `.wp-check` (ui/Checkbox.vue consumers) untouched.
Reverts the 405 -> 407 KB budget bump: one shared component is leaner than the
13 inline copies, so the total lands back under 405 KB.
2026-06-10 01:59:33 +03:00
DumiFlex c6baeb8136 refactor(ui): convert remaining native checkboxes to the shared wp-check look
Sweep the leftover browser-native `<input type="checkbox">` controls onto the
inline `wp-check` span so every checkmark box reads as the wildcard-option
style. Bumps the bundle budget 405 -> 407 KB for the added markup (~0.8 KB
gzip); the embed entry stays slim.

Converted (true checkboxes):
- DisabledRulesSection: per-rule disable toggles (onToggle now takes a bool).
- SubcategoryFilterPicker: exclude-null (manager twin of the pool toggle).
- PushToLibraryModal: "propagate to bundles".
- PushBundleToLibraryModal: cascade "restore missing refs".
- DisplayPlaygroundModal: 7 display-pref switches (drop the dead wp-pg__check
  rule; rows become <div> so no orphaned <label> remains).

Intentionally left as-is — these are toggle SWITCHES / custom toggle buttons,
not checkmark boxes: the row-enable sliders (ModuleRow, InjectorRow,
MockModuleCard, BundleHeader), the internal-flag toggle, and seed-lock.

Tests updated to the aria-checked / click contract.
2026-06-10 01:34:05 +03:00
DumiFlex 62d3768428 refactor(ui): make .wp-check the one checkbox style + restyle to wildcard-option look
The wildcard pool's per-option box (`.opt__check`) is the look we want for
every checkbox. Restyle the global `.wp-check` to match it and make the rule
markup-agnostic so a bare `<span role="checkbox">` gets the same style as the
ui/Checkbox.vue `<button>` — no component import needed in the size-sensitive
embed tree.

- tokens.css: `.wp-check` geometry now matches `.opt__check` (14px, 1.5px soft
  border, 3px radius, sunk into --wp-bg, 9px tick). On/disabled selectors gain
  `[aria-checked="true"]` / `[aria-disabled="true"]` alongside the existing
  `[data-checked]` / `:disabled` so spans and buttons share one definition.
  On-state fill stays accent-600 for the white-tick contrast audit.
- PoolSection: drop the duplicate shared/Checkbox.vue I had added; the
  exclude-null + allow-repeats toggles are now inline `wp-check` spans,
  matching the OptionRow / ValueRow / ExceptionsSection siblings and keeping
  the embed bundle within budget.
- tests: assert the aria-checked / aria-disabled span contract.

Custom toggle BUTTONS (internal flag, seed-lock) are intentionally untouched.
2026-06-10 00:55:37 +03:00
DumiFlex e78592839f feat(spa): "Allow repeats" toggle for the wildcard pool multi-pick
Exposes the pick_independent instance field (engine resolution landed in 33b3600): instance schema + snapshot round-trip, TS instance type + _shell INSTANCE_FIELDS_PER_KIND registry (so reset-overrides + modified-state + the TS/Py parity gate all include it) + preview cache-key, and the canvas PoolSection 'Allow repeats' toggle (shown only while multi-pick is active; off = unique). Instance-only (per-node, canvas runtime), additive + tolerant — no community catalog change.
2026-06-10 00:09:10 +03:00
DumiFlex 33b3600bf8 feat(engine): independent (allow-repeats) multi-pick for the wildcard pool
The pool multi-pick (PICK min-max) now honors instance.pick_independent: when true it draws WITH replacement (repeats allowed) and the count is NOT clamped to the pool size, mirroring the inline ~ flag. Default (unique, pick_k_unique) path is byte-unchanged, so existing locked seeds reproduce. Foundation for the 'Allow repeats' pool toggle; schema (instance + library default) + TS parity + UI follow.
2026-06-09 23:44:25 +03:00
DumiFlex ba455fb098 fix(spa): colour rich-text syntax on the ComfyUI canvas
A RichTextInput/RichTextPreview mounted inside a Context node (combine template field, constraint exception preview) rendered brace-block scaffolding + inline tokens uncoloured: the colour rules live in manager/styles/rich-text.css, loaded only by the SPA (manager/main.ts), never on the canvas (which gets theme.css = :root vars only). Ship a tiny rich-text-canvas.css with just the .wp-rt-* colour rules and import it once at the extension entry. Values mirror rich-text.css (colour only, no background).
2026-06-09 23:27:34 +03:00
DumiFlex 55d9ef19e0 fix(spa): show the !null ban mark on canvas inner-block ref chips
An exclude-null-only ref @{uuid#name!null} has no ':expr', so the lexer's name capture swallows the '!null'; OptionRow.refFilter only inspected sub_categories and missed it, so a decomposed inner ref (e.g. {3$$,$$@{uuid#mood!null}}) showed no filter/ban indicator. refFilter now peels a trailing '!null' off the name (mirrors the SPA RefChip 4-segment lift) and refDisplay strips it from the label so it reads @mood, not @mood!null.
2026-06-09 23:20:38 +03:00
DumiFlex 0962aad413 feat(spa): canvas wildcard editor decomposes blocks into inner chips (sp2b)
OptionRow rendered a whole {a|b}/{N$$sep$$…} block as raw text, so a nested @{uuid} showed the literal uuid instead of a chip. It now decomposes blocks the way the SPA editor does (mirrors atomicEditorModel.parse): scaffolding stays block-coloured, inner @{uuid}/$var arms become the same chips top-level ones use (with resolved names — inner ref uuids are now fed to ensure()). Also: preview-tokens DP_MULTI_RE accepts the SP2b range/~ prefix so the combine/fixed-values preview panes colour {2-4~$$…} too.
2026-06-09 22:00:13 +03:00
DumiFlex 62f2e4aafb feat(spa): resolve inline blocks in the combine preview (deterministic)
The combine RESOLVED preview left {a|b}/{N$$sep$$…} literal. It now expands them deterministically (seedless preview): an alternation -> its first branch, a multi-pick -> its first N branches joined by the separator, with nested $vars resolved via the upstream map. Runtime still rolls the real RNG; this is the static preview's concrete example. The preview pane now also shows when a template has only inline blocks (no vars).
2026-06-09 21:49:40 +03:00
DumiFlex 51a526eb06 fix(spa): chip whitespace-padded block arms + recolour blocks on edit
#4b: decompose each brace-block arm token-by-token instead of only when the arm is a SINGLE token — a leading/trailing space (e.g. {2$$, $$ $variable1|$variable2}) made the arm two tokens so the var never chipped. Now ref/var tokens chip and surrounding whitespace/literals stay scaffolding.

#3 correction: replace the blanket blockColor-preservation in readHostAsAtoms/deleteRawRange with recolorBlocks — re-derive scaffolding colour from the live serialised text after an atom-direct edit (split text atoms at block-token boundaries, never chipify). A still-valid block keeps its colour; breaking a block by deleting its {/}/| or $$ delimiter now correctly de-colours the scaffolding (previously it stayed coloured).
2026-06-09 21:18:02 +03:00
DumiFlex 619cd5ab1e fix(spa): keep brace-block scaffolding colour through atom-direct edits
The Wave-5 atom-direct edit path (backspace/delete) rebuilt text atoms via readHostAsAtoms WITHOUT blockColor, and deleteRawRange dropped it on slice/merge — so editing inside a {a|b}/{N$$…} block stripped the amber/green scaffolding colour until the next full settle re-parse (#3, confirmed via live repro: deleting } dropped scaf-multi spans 2->0). Both now read/carry blockColor (readHostAsAtoms maps the .wp-rt-block-scaf--* class back to the atom; deleteRawRange preserves it through slice + merge).
2026-06-09 20:23:20 +03:00
DumiFlex 3a9a09aeb9 fix(spa): $var autocomplete fires after the $$ multi-pick delimiter
probeAutocomplete bailed whenever the char before a $ trigger was also $, treating the $$sep$$ multi-pick delimiter (e.g. {3$$,$$$style}) as a $$ escape and suppressing the var popover. Extracted the pure probe to utils/autocompleteProbe.ts and replaced the naive check with $-run parity: an even run is an escape/delimiter pair, an odd run's unpaired tail is a real $name/@{ token start. Unit-tested.
2026-06-09 20:08:33 +03:00
DumiFlex 6097590854 fix(spa): detect @{uuid} refs nested inside brace/multi blocks
refsInValue only scanned top-level tokens, so a @{uuid} living inside a {a|b} / {N$$sep$$…} block (which tokenises to ONE dp-brace/dp-multi token carrying its arms as raw strings in meta.branches) was never seen. The Wildcards list 'Uses nested refs' + 'Referenced by others' facets and the dependency graph all read 0 for such refs. Now recurse into each block's branches so a nested ref counts and produces a graph edge.
2026-06-09 19:50:46 +03:00
DumiFlex b9ae37a5f5 fix(spa): drop leftover background box behind multi-pick blocks
Two duplicate token-style sources still painted --wp-rt-token-good-bg behind {N$$…} multi-pick blocks after the SP2b V2 redesign: the canvas wildcard editor's scoped .opt__tok--multi (the visible regression) and tokens.css's unscoped .wp-rt-dp-multi (now dead — RichTextInput/Preview decompose before tokenising, so nothing emits that span — but kept in sync to prevent a future mirror/embed regression). Both are now colour-only, matching the SPA editor/preview block-scaffolding and the .opt__tok--brace sibling. Continuous block-coloured text, no box.
2026-06-09 19:33:29 +03:00
DumiFlex 36942a27ec feat(spa): stamp sp2b schema_version on publish when range/independent used
buildPublishBody now picks the community catalog schema_version per-payload via schemaVersionForPayload: SP2B_SCHEMA_VERSION (3) when the payload text uses a range count or the ~ independent flag, else CURRENT_SCHEMA_VERSION (2). Mirrors the community publish-side marker scan so both ends agree on which shipped text needs the higher catalog version. SP2b adds no structural migrator (a v2 payload is shape-identical), so CURRENT_SCHEMA_VERSION stays 2 — the stamp-version and the migrate-to-version are decoupled. The boot catalog probe already tolerates a head greater than CURRENT: decideInstallPath is per-payload and refuses only a breaking-in-interval shape.
2026-06-09 18:53:54 +03:00
DumiFlex ef6fadcf24 feat(spa): decompose brace blocks into scaffolding + inner chips, render v2
Decompose every {…} brace-block token in the SPA atom parser into a flat run of block-coloured scaffolding-text atoms + inner @{uuid}/$var chip atoms (token shape + engine untouched, so cross-language parity holds). The editor and read-only preview render scaffolding as continuous block-coloured text (amber {a|b}, green {N$$…}) with no background or box; inner refs/vars stay RefChips, the only bordered elements. parseForSurface preserves blockColor when folding surface-literal arms so a collapsed arm keeps the block's colour, and only merges text atoms that share a blockColor. Wave-5 atom-direct delete already treats a decomposed inner block chip as one cursor stop.
2026-06-09 18:43:48 +03:00
DumiFlex 63b824e4a1 feat(engine): range count + independent mode + lone-ref pool multi-pick
Resolve the SP2b multi-pick semantics:
- range count drawn in [min,max] (a fixed {N$$} keeps min==max and does NOT
  draw, so its seed sequence is byte-for-byte unchanged);
- independent (~) = N draws WITH replacement, each branch resolved
  independently (a lone ref re-rolls every time);
- unique lone-ref (count>=2, wildcard surface) = N DISTINCT options from the
  ref filtered pool — a per-site SP2a multi-select. count==1 stays on the
  legacy single-pick branch path (seed safe).

Extracts ref_option_pool (catalog lookup + :expr/!null filter + chain
constraints, incl. the strict-unknown raise + warnings) from _resolve_ref so
both the single pick and the lone-ref multi-pick share it. Also closes the
coverage gap: a $var branch inside a multi-block on the combine surface.
2026-06-09 18:08:52 +03:00
DumiFlex 0b36ce892b feat(engine): multi-pick prefix gains N-M range + ~ independent flag
The {N$$sep$$} brace prefix now also accepts a count range ({N-M$$}) and a
~ independent flag ({N~$$} / {N-M~$$}, repeats allowed; absent = unique).
Both tokenizers (Python engine + TS richTokenize) parse it identically
into dp-multi meta {min, max, independent}; legacy `count` is kept (== max)
so existing readers + the resolver behave unchanged for a fixed {N$$}.
Shared syntax-corpus.json locks Py≡TS parity (existing multi_pick entries
gain the new keys; new range/lone-ref cases added).

Resolution of the range/independent semantics lands next (engine resolver);
this commit is tokenizer + parity only — a fixed {N$$} is byte-for-byte
unchanged.
2026-06-09 17:54:48 +03:00
DumiFlex 94ccd7f192 test(wp_api): lock comma rejection on variable names server-side
The engine handlers already enforce _IDENT_RE on output_var / var_binding
/ fixed values[].name via validate_payload, wired into every
create/update/import through _validate_payload_for_type (returns 400 on
ValueError). Existing tests cover leading-digit / dunder / too-long /
empty cases; add the explicit comma scenario the user reported so the
exact "no settle-delimiter in a var name" contract is regression-proof
for all three module types. No production change — server gate already
existed; this characterizes + locks it.
2026-06-09 15:39:16 +03:00
DumiFlex c661550091 feat(manager): enforce identifier grammar on variable names
A produced-var name becomes a $var, so it must be a clean identifier
([A-Za-z_][A-Za-z0-9_]*). Anything else can never be referenced: the
tokenizer wont match it and the rich-text editors settle delimiters
(comma/space/;:/(){}[]!?) split it. This allowlist forbids the comma the
user hit AND every other junk char + leading digit.

Two TS layers:
- validateModule: flags an invalid output_var / var_binding / fixed
  values[].name as an error in the VALID column, catching bad names from
  ANY path (import, embed override, legacy data), not just typed input.
- embed/context instance-override inputs (wildcard + combine binding,
  fixed-values name) now strip non-identifier chars on keystroke and
  force-sync the controlled input, matching the main library editors. The
  fixed-values VALUE field stays free-form.

slug.ts gains stripNonIdentifierChars as the shared keystroke sanitiser.
Mirrors the engine FixedValuesHandler._IDENT_RE (Python = TS).
2026-06-09 15:32:06 +03:00
DumiFlex bf189b33d5 fix(manager): render unsettled $var/@ref as plain text in editor
textAtomHtml now collapses BOTH var and ref on every surface, so an
inline (unsettled, not-yet-a-chip) $name / @{uuid} renders as plain text
instead of the violet .wp-rt-var / magenta .wp-rt-ref highlight. The
absence of a chip already signals "not committed yet" — a second colour
just competed with the settled-chip (RefChip) palette and read as a
different kind of token (user feedback).

Surfaced by the Wave-5 atom-direct delete: backspacing re-renders the
remaining raw token through textAtomHtml, which previously painted it
violet. Settled chips keep their colour; the read-only RichTextPreview
is a separate render path and is unchanged; inline {a|b} brace / multi /
weight / escape colouring is untouched (those are not chippable tokens,
so their highlight is the only signal).
2026-06-09 01:02:06 +03:00
DumiFlex af90203f39 fix(manager): never chipify on backspace/delete in rich-text editor
Backspace/Delete now delete directly off the live atom structure
(readHostAsAtoms + deleteRawRange) instead of re-tokenizing the
remaining raw text via parseForSurface. Chip formation stays ONLY on
the commit paths (settle-delimiter / blur / autocomplete).

Fixes user-reported edit corruption: raw "$mood.1" + Backspace deleted
the "1" then chipified the remaining "$mood" into a chip plus a stray
"." text; any mid-edit "$name" token was eaten/chipified the instant a
char was removed. A committed chip still deletes whole (the deletion
span widens to chip boundaries); raw text that merely looks like a chip
loses exactly one char and stays raw until a real commit point.

Removes the surface-gated chip-regex + ZWSP charCodeAt guards + escape
guards from the delete path — all dead under the atom-direct model.
2026-06-09 00:21:08 +03:00
DumiFlex 435cdb178b fix(resolver): carry structured list-vars through static resolve so $mood.0 indexes 2026-06-08 23:39:16 +03:00
DumiFlex 743f8b08c8 fix(spa): single-char backspace/delete on raw-typed $var (atomic-delete only committed chips) 2026-06-08 23:24:38 +03:00
DumiFlex d9235c39b1 fix(spa): keep autocomplete open across a $var.K accessor (no Enter newline split) 2026-06-08 22:57:20 +03:00
DumiFlex b9df102a80 fix(combine): filter multi-pick representative to engine pool + honor .K in template preview 2026-06-08 22:52:07 +03:00
DumiFlex 2a24f9ff3a fix(pool): custom pick steppers + min<=max clamp + null-aware enabled/probability 2026-06-08 22:41:53 +03:00
DumiFlex d3881c84a1 fix(snapshot): include SP2a pick fields in instance baseline + snapshot type 2026-06-08 22:11:11 +03:00
DumiFlex 31fe070a45 fix(pool): surface multi-pick count in pool summary + instance subtitle 2026-06-08 21:56:41 +03:00
DumiFlex 7d267d0be6 fix(pool): strike + uncheck null row in multi-pick; align pick stepper styling 2026-06-08 21:49:33 +03:00
DumiFlex f922dca356 fix(graph): honor .K accessor + multi-pick representative in static preview 2026-06-08 21:42:14 +03:00
DumiFlex dc05a5d176 fix(assembler): render list-valued vars + .K accessor in node preview 2026-06-08 21:34:41 +03:00
DumiFlex 12082228e7 fix(spa): stable $var.K chip round-trip in rich-text editor 2026-06-08 21:24:54 +03:00
DumiFlex bc7ea336e7 fix(spa): resolve \.K to base name in derivation validation + conflict scan 2026-06-08 21:18:04 +03:00
DumiFlex 6703583db8 fix(preview): serialize list-valued vars in resolve endpoint 2026-06-08 21:11:59 +03:00
DumiFlex ace0486f9d fix(engine): guard junk pick counts + warn on multi-pick constraint source 2026-06-08 21:06:35 +03:00
DumiFlex d41be86fae fix(engine): fold list-valued vars in derivation + converter reads 2026-06-08 20:58:59 +03:00
DumiFlex 8d7bda2234 test(spa): lock accessor-aware var autocomplete match 2026-06-08 20:11:28 +03:00
DumiFlex d1ee97b3ec test(sp2a): lock multi-pick debug render + var.K cascade/validation 2026-06-08 15:08:22 +03:00
DumiFlex d167a1f564 feat(pool): multi-select pick-count control + null disable in multi 2026-06-08 14:59:10 +03:00
DumiFlex 64d1cb157e feat(spa): render var.K as a single chip + accessor-aware autocomplete 2026-06-08 14:50:26 +03:00
DumiFlex 17a2149fb0 feat: pick_min/pick_max/pick_separator wildcard instance fields 2026-06-08 14:40:40 +03:00
DumiFlex 443db49546 feat(engine): multi-select wildcard binds a list variable 2026-06-08 14:31:26 +03:00
DumiFlex 8eeb0a8032 feat(engine): list-backed variables + var.K accessor 2026-06-08 14:21:40 +03:00
DumiFlex 3ac0afa979 feat(engine): tokenize var.K list accessor (py+ts parity) 2026-06-08 14:12:42 +03:00
DumiFlex e5f54c6ba8 refactor(engine): extract pick_k_unique from multi-pick resolver 2026-06-08 10:13:40 +03:00
DumiFlex c5720e79bd fix(ui): peel !null in nested-ref filter chips on canvas + preview
The widget lexer comma-splits a nested ref's `:body` without peeling the
`!null` marker, so two chip renderers showed the glued raw filter:
- the canvas pool OptionRow printed `· warm or intense!null` inline;
- RichTextPreview fed the glued body to RefChip's legacy `subCategories`
  prop, whose reconstruction only stripped a standalone "null" element —
  so the hover title read `warm or intense!null` and the exclude-null ban
  never showed. That path drives the debug-node warnings, TestRunner, and
  constraint-exception previews.

Add a single-source `splitRefFilter` peel in richTokenize and route every
filter chip through it:
- OptionRow now renders the SPA RefChip grammar — funnel (expression) +
  ban (null excluded), expression normalized into the hover title, nothing
  inline.
- RefChip's legacy subCategories path peels a glued `!null` (fixes the
  whole RichTextPreview tree at once).
- RichTextInput's splitColonBody delegates to splitRefFilter so the editor,
  RefChip, and canvas can never drift on `!null` handling again.
2026-06-08 07:10:50 +03:00
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
DumiFlex 5a0e05e6bf fix(validation): parse v2 boolean sub-category ref filters
The rich-text lexer still comma-split the `@{uuid:...}` filter body using
the v1 grammar and never peeled the `!null` marker, so validateModule saw
the whole SP1 expression as one literal sub-category name. A valid ref
like `@{mood:warm or intense!null}` was flagged "has no subcategory
\"warm or intense!null\"".

extractRefs now rejoins the body and carries the raw expression; the
wildcard validator peels the trailing `!null`, then validates each tag
via the shared subcatFilter.validateExpression parser — the same check
the picker runs inline, so list view and editor agree by construction.
Also refresh the now-misleading v1 lexer comment.
2026-06-08 06:22:52 +03:00
DumiFlex c77781062a fix(ui): shrink ref-filter action buttons by overriding the fixed .wp-btn height
The earlier padding/font tweaks never shortened Delete/Skip/Apply because
.wp-btn sets a fixed height: var(--wp-btn-h) (~34px) — padding cannot shrink
a fixed-height flex button. Override height to 24px in the picker actions so
they match the popover density (verified 24px live).
2026-06-08 04:01:25 +03:00
DumiFlex ff0f9dc0cd fix(ui): anchor ref-filter popover snug to its chip + move delete button far-left
- Popover floated too far from the pill. clampPickerIntoView() re-clamped
  using the over-estimated PICKER_APPROX_H, parking a flipped popover ~140px
  above the chip. Now it captures the trigger rect at open and, after the
  popover paints, re-anchors against it with the REAL measured height (6px
  gap) before clamping into the viewport — so it sits right on the chip.
- Delete moved to the far left of the picker actions (margin-right:auto),
  separated from Skip/Apply on the right — destructive-action-left so it is
  not adjacent to Apply.
2026-06-08 03:54:04 +03:00
DumiFlex b23e29e625 fix(ui): clamp ref-filter popover on-screen, show match count, link null toggle to exclude_null
Follow-up to the round-2 polish.

- Ref-filter popover ran off-screen. The enlarged popover outgrew the stale
  PICKER_W/PICKER_APPROX_H anchor estimates, so a chip near the page edge
  opened the picker partly off-screen. Bumped the estimates and added
  clampPickerIntoView(): after the popover paints, measure its real box and
  nudge it fully into the viewport (handles the variable palette height).
- Match count was hidden. WildcardEditor only forwarded uuid-to-sub-categories
  to RichTextInput, so the picker got an empty option-tag-set denominator and
  suppressed the "N of M options match" line — letting users build an
  always-empty filter blind. Now also forwards uuid-to-option-tag-sets (match
  count) and uuid-to-tag-groups (grouped insert palette).
- Null option row and "Exclude null" were independent. The graph pool's null
  row checkbox wrote enabled_options while the Exclude toggle wrote
  exclude_null, so the two desynced. isEnabled now governs the null slot
  solely by exclude_null (checked before the enabled_options gate), and
  toggling the null row routes to exclude_null — the two controls are one.

Tests: probability null-vs-enabled_options; PoolSection null-row drives
exclude_null.
2026-06-08 03:43:57 +03:00
DumiFlex 54054fb172 test(cascade): edge-case qa for subcat rename/delete (collapse, null, multi-surface)
QA pass on the SP1 Chunk E cascade. Adds eight regression cases beyond the
existing basic suite, all green: operator collapse with `not` and nested
parens (`warm and not cold` -> `warm`), middle-of-three OR delete, `!null`
marker preserved through a collapse, delete-only-tag leaving a bare
`@{uuid!null}`, a same-named subcat in a *different* wildcard left untouched
on rename, and a single rename/delete that has to land across every surface
at once — registry, option membership, constraint matrix key (source + target
side), and a nested `@{}` ref expression. No engine bugs found.
2026-06-08 03:27:18 +03:00
DumiFlex c2d0ef5379 fix(ui): subcategory polish across spa + graph (colours, exclude-null, palette, popover)
Round-2 feedback on the SP1 sub-category UI.

SPA (manager):
- #1/#2 SubcategoryFilterPicker — enlarge the ref-filter popover (340->400px
  min, taller 44px / 14px expression input) and shrink the Delete/Skip/Apply
  buttons (the default .wp-btn size read as oversized in the popover).
- #6 per-option tag picker — selected rows now tint + bold + fill the check
  box (hue-independent) instead of a near-invisible box-only tint.
- #8 group boxes — coloured left accent + coloured header keyed to the axis
  hue, plus a stronger pill tint, so each axis reads as a colour cluster.

Graph (canvas instance modal):
- #4 exclude-null never persisted — exclude_null was missing from
  RELEVANT_INSTANCE_KEYS (stripped from the preview body + chain hash), from
  INSTANCE_FIELDS_PER_KIND / RESET_FIELDS, and from the engine INSTANCE_SCHEMAS
  (the SP1 chunk documented it but never added the line); added everywhere.
  Toggle now uses native @change on the input instead of a label @click.prevent
  that fought the checkbox own toggle, so the box reflects the new state.
- #5 exclude-null control now hidden entirely when the wildcard has no null
  option (was shown-but-dimmed).
- #7 advanced expression editor gains an insert-at-cursor palette (grouped
  sub-categories + operators), mirroring the SPA ref-filter.
- #9/#6 pool quick-pills — faint axis-hue border so a group reads as a
  cluster even unselected; selected pills get a solid hue fill + bold +
  leading check so on is visible for any hue (incl. the grey ungrouped).

Tests: PoolSection exclude-null (emit/reflect/hidden) + advanced palette
insert; _shell + engine instance-schema parity updated for exclude_null.
2026-06-08 03:18:57 +03:00
DumiFlex 723adde0bb fix(manager): dismiss subcat menus on outside click + flip near edge + drag-to-regroup
Five live SPA bugs in the wildcard editor sub-category UI:

- kebab menu + per-option tag picker clipped off the page bottom on the last
  rows — measure the trigger on open and flip the dropdown upward when it would
  overflow the viewport (subcat-menu--up / opt-tags__picker--up).
- kebab / new-tag input / option picker stayed open when clicking elsewhere —
  add a document-level click-outside + Escape handler (closeAllMenus).
- dragging a pill to another group box did nothing — wire HTML5 drag/drop on the
  pills + group boxes, reusing moveTagToGroup (drop on ungrouped box = ungroup).
- SubcategoryFilterPicker (nested-ref filter popover) was cramped — widen the
  popover + expression input, shrink the insert chips + action buttons.

Regression tests cover outside-click-close + drag-to-regroup; flip-up is
geometry-driven and was verified live.
2026-06-08 02:27:40 +03:00
DumiFlex 3bcafc986c fix(ui): style wildcard editor sub-category groups + option-tag picker
WildcardEditor.vue shipped the Chunk H markup (group boxes, hued pills with kebab, per-option multi-select picker) but its <style> still held only the OLD sub-category classes — every new class was unstyled, so the section rendered as glued plain text ('calm2', 'ungroupedcalmintense'). Added the missing CSS using manager tokens: .subcat-group* boxes, .subcat-pill* (axis-hued, separated count badge), .subcat-menu* kebab dropdown, .subcat-addtag*/.subcat-add-group, and the H2 .opt-tags* chips + chevron + grouped checkbox .opt-tags__picker. Kebab opens rightward (left:0) so it does not clip on left-edge pills. Verified live in the SPA (build:manager) across all three popovers.
2026-06-08 01:46:21 +03:00
DumiFlex a62851256b fix(engine): slugify nested-ref tags in v1->v2 migrate + sp1 e2e smoke
The migrator now slugifies each tag inside a nested @{} ref's filter list (cross-wildcard refs track the target's renamed registry entry — deterministic, same _slug the target applies), not just comma->or + null->!null. Idempotency-safe: the rewrite branch only fires on v1-shaped (comma/null) refs; v2 refs are left verbatim. Mirrored in src/manager/import-export/migrations.ts.

tests/test_sp1_smoke.py: end-to-end SP1 acceptance — a v1 library payload migrates to v2 (registry + option tags slugified, nested ref comma->or + slug + !null, instance category_filter list->expr + exclude_null), the migrated payload validates, and the boolean pool matcher resolves over the new multi-tag option shape.
2026-06-07 05:01:05 +03:00
DumiFlex c374863c87 feat(ui): wildcard editor sub-category groups + per-option multi-tag
api/types.ts: WildcardOption.sub_category -> sub_categories[] (default []); WildcardPayload.tag_groups. Cascaded the type flip across the manager: resolver.ts constraint matrix keys on the primary tag (sub_categories[0], SP1 stopgap mirroring the engine; SP3 multiplies), ConstraintEditor local type, DebugViewer pick display, starter-recipe seeds, + field-shape migration in the affected tests (behavioral assertions intact).

Chunk H1: WildcardEditor renders the sub-category registry as group boxes (one per tag_groups axis + an ungrouped box) with inline +tag / +Group and a per-pill kebab (rename/move/delete via the expression-aware cascade) — drops the old global add bar + external edit button. H2: per-option sub-category cell is now a grouped multi-select (removable chips + a grouped checkbox picker), toggling option.sub_categories. Full vue-tsc + vitest green.
2026-06-07 04:55:06 +03:00
DumiFlex 33e6bd0d34 feat(ui): nested-ref filter popover + chip + in-graph pool editor
Chunk F (src/manager/components): SubcategoryFilterPicker reworked into a text-first boolean-expression editor (insert-at-cursor chips/operators, live reads-as + 'N of M match' count + validateExpression, exclude-null toggle; emits {expr, excludeNull}). RefChip shows a compact funnel indicator + hover tooltip instead of an inline subcat suffix. RichTextInput serializes ref atoms to @{uuid#name:expr!null}.

Chunk G (src/components/context/editors/wildcard): probability.ts isEnabled matches the boolean category_filter expression (parse+matches) + exclude_null. OptionRow renders per-tag CATEGORY chips colored by tag_groups axis. PoolSection: grouped quick pills (or within a group, and across) + an advanced boolean editor + exclude-null toggle.

Foundation/cascade: _shared.ts Instance.category_filter string[]->string + exclude_null; prune.ts drops stale tokens from the expression via subcatExprCascade; ContextWidget tooltip renders the expression string. Bundle-size budget 395->405 KB for the graph editor + shared parser (documented). Full vue-tsc + vitest green.
2026-06-07 03:41:01 +03:00
DumiFlex 11ad8ca4bc feat(cascade): expression-aware subcat rename/delete (engine + ts)
engine/cascade/fixers.py: _rewrite_subcat_ref_in_string + _strip_subcat_ref_in_string now parse a nested ref's :expr segment via subcat_filter, map the tag token (rename, or remove + collapse childless operators), and re-serialize via reads_as — preserving #name + !null. Bare-subcat refs (@{u:cold}) collapse to a bare @{u} on delete; boolean refs (@{u:warm or cold}) collapse to @{u:warm}. Only refs that actually contain the target tag are rewritten (no cosmetic churn).

src/manager/cascade/subcatExprCascade.ts: TS mirror (renameSubcatInExpr / removeSubcatFromExpr via parse + readsAs + mapTags) for the client-side cascade.
2026-06-07 02:33:05 +03:00
DumiFlex 221aa9097b feat(manager): mirror v1->v2 migration + bump client schema version
src/manager/import-export/migrations.ts: CURRENT_SCHEMA_VERSION 1->2; migrateV1ToV2 envelope step (mirror of engine/migrations/v1_to_v2.py) registered in MIGRATION_CHAIN — option sub_category->sub_categories[], sub-category name slugify+cascade, instance category_filter list->boolean-expr + exclude_null, nested @{uuid:a,b,null}->@{uuid:a or b!null}. Idempotent.

v2 Zod validators (src/validators/module/v2.ts): wildcardV2 (sub_categories[] + optional tag_groups); other module subtypes + bundle root unchanged, re-registered under the :2 registry keys. Export moduleRowBase from v1. Regenerate the engine-parity wildcard fixture to v2 shape.

The WildcardOption TS interface + its UI consumers stay on the v1 shape (deferred to the UI chunk) — this commit is runtime parity only (migrator + Zod + stamping), so vue-tsc stays green. Update version/entity-count/MIGRATED-label assertions in the migration, import-tab, import-picker, and parse tests.
2026-06-07 02:07:43 +03:00
DumiFlex 39529a669b feat(engine): migrate library v1->v2 (multi-tag) + bump schema version
engine/migrations/v1_to_v2.py: option sub_category->sub_categories[]; sub-category name slugify (whitespace/grammar chars->_, reserved suffixed, de-duped) cascaded to options + tag_groups; instance category_filter list->boolean-expr string + exclude_null flag; nested @{uuid:a,b,null} refs->@{uuid:a or b!null}. Idempotent. Two entry points share one core: envelope migrate_v1_to_v2 (import/export, registered in _CHAIN) + per-row migrate_module/bundle_v1_to_v2 (lazy/bulk DB path).

Register _CHAIN[1] + bump engine CURRENT_SCHEMA_VERSION 1->2. New SQL migration 016 marches stale DB rows 1->2 at boot via run_bulk_lazy + the per-row migrators (014's documented bump pattern). TS mirror (migrations.ts) + client CURRENT bump follow next (no cross-language version assertion couples them). Update version/entity-count assertions in payload-migration, migration-runner, and export-build tests.
2026-06-07 01:40:53 +03:00
DumiFlex 13de622c6b feat(engine): multi-tag options + boolean sub-category filters (v2)
Option sub_category -> sub_categories[] (default []). Both pool filters (instance category_filter, nested @{} ref) become boolean expressions (and/or/not/parens; comma=or) matched against an option's tag set via the shared engine/syntax/subcat_filter matcher, applied BEFORE constraints. Exclude-null is a separate flag: instance exclude_null + the trailing !null ref marker, replacing the old 'null' keyword.

4-segment ref grammar @{uuid#name:expr!null} single-sourced across tokenize.py/scan.py/pipeline.py/_constraints.py (! excluded from name/expr captures); tokenizer REF meta now carries filter_expr + exclude_null instead of a comma-split list. scan.py findall unpack sites updated for the 4th group.

validate_payload: per-option sub_categories[] must be in the registry; optional tag_groups (UI-only axes, members in-registry, <=1 group); registry names validated via subcat_filter.validate_subcat_name (rejects whitespace, boolean/ref chars, reserved words incl. null). wp_api/_validators delegates to the same function.

Constraints stay alive via a primary-tag stopgap: _apply_constraint_to_options + _record_pick use sub_categories[0] as the matrix key; true multi-tag matrix matching is SP3. Cascade fix_subcat_delete/rename + orchestrator scrub the per-option list; instance_schemas category_filter -> string. Existing engine tests migrated to the v2 shape; added tests for the ref regex, payload validation, and multi-tag pool filtering.
2026-06-07 00:43:55 +03:00
DumiFlex 0e1803ec12 feat(syntax): boolean sub-category filter parser, matcher + ts parity
engine/syntax/subcat_filter.py: tokenize->AST parse, matches(), reads_as() normalizer, validate_subcat_name + validate_expression (grammar: not>and>or, comma=OR, n-ary flatten). src/manager/parsing/subcatFilter.ts: behavior-identical TS mirror. Shared fixture routed through validate_expression in both languages so syntax + reserved/unknown errors stay in parity.
2026-06-06 23:22:44 +03:00
DumiFlex 04841aaab9 fix(community): sync embed theme with the manager toggle live
Community.vue mounted the embed with a hardcoded theme: "auto" and never
watched the manager theme, so flipping light/dark left the embed stuck
(it tracked the OS, not our toggle). Forward uiStore.themeMode at mount
and watch it through the new handle.setTheme so the embed re-skins live.

setTheme is optional + guarded in the local EmbedHandle mirror: the
bundle loads at runtime from the community host, which under
community-first deploy ordering may be an older build without it.
2026-06-06 18:25:36 +03:00
DumiFlex 4fabb7685e feat(import): surface cross-kind id clashes as CLASH; never replace
Import-from-file picker now threads module type into its library map, so detectCollisions classifies a same-id different-kind row as type-conflict. ConflictModal renders it as a violet CLASH badge (vs the misleading MODIFIED/EXISTING), and partitionSelection coerces any replace/accept on a clash into install-as-new so the manual import path can't clobber a different-kind live row either. Completes the shared classifyOne unification across all three surfaces.
2026-06-06 17:01:28 +03:00
DumiFlex ca54a3a323 feat(install): flag + backstop cross-kind id clashes (never replace)
detectInstallCollisions now flags a typeConflict when the live row at an incoming id is a DIFFERENT module kind (shared 8-hex id-space). enforceClashSafety coerces any 'replace' on a clash into install-as-new, regardless of what the resolveCollisions UI returned — a UI-agnostic data-layer backstop that prevents clobbering an unrelated item. Both snapshotLibrary copies (main.ts + CommunityUpdateDialog) now carry module type so the check can run. Community-embed install modal honoring the flag (hide Replace, show clash) is a follow-up in the community repo.
2026-06-06 16:49:16 +03:00
DumiFlex c13351d21c feat(context): type-aware drift/missing + id-clash state via classifyOne
Workflow drift/missing now runs through the shared classifyOne verdict: a same-id, DIFFERENT-kind library row surfaces as a distinct 'clash' state (violet badge) instead of a misleading drift/missing dot, and Refresh is suppressed + guarded in mergeRefresh so it can never clobber an unrelated item. Hybrid content-key: the workflow path compares payload_hash (drift semantics unchanged) while the import picker keeps fingerprint; classifyOne is content-key agnostic. /wp/api/modules/hashes now returns {type, payload_hash} per id; drift-store map + AppLayout fingerprint + setLibraryHash updated to the object shape.
2026-06-06 16:39:49 +03:00
DumiFlex 885f7fb98a feat(api): modules/hashes returns type + fingerprint per id 2026-06-06 16:10:33 +03:00
DumiFlex 9e34d23129 feat(collision): add type-conflict state + classifyOne core 2026-06-06 16:05:09 +03:00
DumiFlex 4b491b8fa5 feat(manager): nsfw content filter on every list view + all items
Adds a tri-state Content filter (All / SFW / 18+) to the shared list
filter panel in ModuleListView, so it shows on all per-kind module pages
AND the All-items aggregate. Client-side over the row's content_rating
(the same field driving the 18+ pill), so no fetch. Persisted via the
shared BASE_LIST_SCHEMA (`?nsfw=`) so it survives reload + is bookmarkable
across every list view at once. Clear-filters resets it.

Updates the useListUrlState schema test for the new 8th base field.
2026-06-06 14:08:14 +03:00
DumiFlex 0da118e10b fix(manager): bundle-editor constraint names + list keyboard nav reachability
Two issues surfaced after the prior batch was actually built + run:

- bundle editor (#2): a constraint child's matrix axes showed "Source ·
  source" / "Target · target" because BundleChildPane never passed
  source-name/target-name to ConstraintMatrixSection (unlike the Context
  ConstraintInstanceModal). Now resolves source_wildcard_id /
  target_wildcard_id against the bundle's other children (passed down as
  sibling-modules) → "Source · <wildcard name>". All siblings are
  in-memory, so no async resolver needed.

- list arrows (#3): the @keydown lived on the <tbody>, but clicking a row
  focuses one of its cell buttons (select / expand / tag / pill), never
  the tbody, so the handler never fired. Moved tabindex + @keydown to the
  scroll wrapper — a common ancestor that receives bubbled keydown from
  any focused descendant and is itself focusable for Tab / gutter clicks.
2026-06-06 13:35:09 +03:00
DumiFlex 915e0cce0d fix(manager): row actions/focus follow hover+keyboard, not a sticky state
Follow-up to the list-keyboard work: the prior fix seeded focusedRowId
whenever the <tbody> gained focus (incl. a click landing in the list),
and row actions/stripe were driven off the data-focused state — so a
clicked row kept showing its action buttons + accent stripe after the
pointer left ("rows stay selected / show actions when not hovered").

- Drop the tbody @focus seeding; the keydown handler already seeds row 0
  on the first ArrowDown/Up (moveFocus i<0 → 0), so arrows still work
  once the list is focused without leaving a sticky row on mere click.
- Row actions now reveal on :hover (mouse) or :focus-visible (keyboard)
  only — not :focus-within / data-focused — so a mouse click never
  leaves them stuck on.
- Focus stripe likewise moves to :focus-visible, so the accent marker
  tracks the keyboard cursor and never lingers after a click.
2026-06-06 13:19:37 +03:00
DumiFlex 24e83ea9b1 fix(manager): community pill lifecycle, list keyboard nav, dashboard intro
Batch of community-integration UX fixes:

- update pill (#4/#6): re-run the community update check on every
  library drift delta (AppLayout watches), so the per-row pill tracks
  reality without a manual refresh — clears after an in-place update,
  reappears after deleting an install-as-new copy.
- update pill (#5): communityUpdateStore now suppresses the pill on any
  row whose post already has a sibling installed at latest, so
  "install as new" clears the older row's pill (and it returns if that
  copy is later deleted).
- list selection (#7): arrow-key nav now pushes real DOM focus onto the
  active row, so :focus-visible tracks the roving-tabindex state instead
  of lingering on the originally-clicked row — single highlight.
- list keyboard (#8): <tbody> is focusable and seeds the first row on
  focus, so arrows work from a cold start without clicking a row first.
- dashboard (#9): the "New here?" intro card now sits ABOVE an
  always-rendered recents list (was mutually exclusive) and has a
  persisted dismiss X.
- all items (#10): forward community_post_slug / version / content_rating
  through the row mappers so the globe + 18+ pills render in the
  aggregate view like they do per-kind.
2026-06-06 11:38:21 +03:00
DumiFlex 51f6793177 test(validators): engine-parity harness to stop validator drift
The community TS validators re-implement the engine's payload shapes by
hand, and drifted repeatedly (fixed_values entries-vs-values, wildcard
is_null omitted) -- each drift surfaced as a publish failure for a user.
This makes the engine the single source of truth.

- scripts/dump_engine_shapes.py: builds one sample payload per module
  subtype, validates each against that subtype's own engine
  Handler.validate_payload (raises if not engine-valid), and writes the
  engine-row shape to src/validators/fixtures/engine-parity/*.json. Run
  it whenever an engine module shape changes; commit the regenerated
  fixtures.
- src/validators/__tests__/engine-parity.test.ts: asserts the STRICT
  community validator accepts every engine-dumped fixture. 5/5 pass.

Now drift fails CI on either side: a sample that no longer matches its
handler fails the generator; a validator that rejects the real shape
fails the parity test. CLAUDE.md schema section documents the loop.
2026-06-06 10:44:01 +03:00
DumiFlex 8a9ee8c85e fix(validators): match real engine shapes + widget-native bundle children
Engine↔community shape audit. The TS validators were hand-authored
from a guess and drifted from the real engine output, so valid modules
failed strict publish validation.

Standalone module validators (module/v1.ts), aligned to the engine
handlers:
- wildcard: var_binding + sub_categories are engine-optional; options
  gain the real `is_null` field (the null-option contract) -- strict
  was rejecting null options, and strip would have dropped is_null,
  producing an empty-value option the consumer rejects on install.
- combine: input_vars optional. constraint: source/target optional,
  matrix/exceptions optional. (Engine reads all via .get with defaults.)

Bundle children (bundle/v1.ts): a child is the engine's frozen WIDGET
snapshot ({id, type, meta:{name}, payload, instance, enabled,
collapsed}) or a nested-bundle ref -- NOT a flat module row. Modeling
it as a module row rejected the real shape AND lost the name (it lives
under meta), so installed bundles round-tripped to "(unnamed)".
bundleV1Child now validates the snapshot loosely (require id+type,
enumerate known fields so tolerant strip keeps them, payload opaque).

single-row-publish: buildBundlePublishable ships children verbatim as
widget snapshots (history stripped per child) -- lossless round-trip,
no resolver/flatten. Dropped ModuleResolver + the catalog-fetch in
CommunityRowActions + the resolver arg in ExportTab.

Tests rewritten: children ship verbatim with meta.name preserved +
history stripped; bundle passes strict validation. 24 validator +
import-export tests green.
2026-06-06 09:52:02 +03:00
DumiFlex 191f880fa2 fix(publish): correct fixed_values shape + strip history sidecar
Publishing any fixed_values module (standalone or as a bundle child)
failed strict validation: "payload.entries Required".

Two root causes:

1. The v1 fixedValuesV1 validator + its fixture were authored in Task
   11 from a guess -- `payload.entries: [{variable_name, value}]` --
   and never exercised against a real row. The actual engine shape
   (engine/modules/fixed_values_handler.py) is
   `payload.values: [{id, name, value}]`. Corrected the validator +
   the module-fixed_values.json fixture to match reality.

2. Edited modules carry a local `history` sidecar in their payload
   (utils/history.ts, max 3 entries). The strict validators reject it
   as an unrecognized key, and it shouldn't ship to the community
   anyway (leaks prior values + bloats the payload). stripHistory() in
   buildModulePublishable drops it for BOTH publish and copy-to-
   clipboard; the bundle-child flatten fallback strips it too. The
   resolve + passthrough child paths already route through
   buildModulePublishable.

Tests: buildModulePublishable strips history; a real edited
fixed_values row (values shape + history) passes strict validation.
26 validator + import-export tests green.
2026-06-06 09:25:20 +03:00
DumiFlex f8efcc92d7 fix(community): preserve bundles/library/deleted views in host nav
The embed -> host -> URL -> parseRoute round-trip dropped every view
except detail/profile/publish. Community.vue's parseRoute fell through
to browse for bundles/library/deleted, and onNavigate's else branch
router.replace'd to bare /community (browse). So:

- Clicking Bundles / My library went to those views for one frame,
  then the host round-trip clobbered them back to Browse.
- The in-embed Back button (targets the previous LIST view, e.g.
  library/bundles) collapsed to Browse too -- "back button does not
  work at all".
- Browse stayed highlighted because the resolved view really was
  browse after the round-trip.

Fix: map each view to its own URL segment in BOTH directions --
/community/bundles, /community/library, /community/deleted. parseRoute
reads them back; onNavigate writes them. publish stays unmirrored on
purpose (host-initiated with a payload hash router.replace would
strip). EmbedNavigateTarget/MountOptions types gain "deleted".
2026-06-06 09:02:41 +03:00
DumiFlex 47b153503f fix(publish): normalize bundle children to module-row shape
Publishing a bundle failed strict validation with dozens of
"children[N].name Required" + "Unrecognized key(s): enabled, collapsed,
meta, payload_hash, instance, entries" errors.

Root cause: bundles created in-app store their children in the
WP_Context widget shape -- name nested under `meta`, payload in widget
form (fixed_values uses a top-level `entries` / `values` rather than the
library `payload.entries`), plus instance/enabled/collapsed/payload_hash
extras. buildBundlePublishable shipped that verbatim, but the community
bundle validator wants each child as a canonical module row
(`{id, type, name, description, category_id, tags, is_favorite, payload}`).
The Export-tab file path never validated, so this went unnoticed until
the publish path (which validates) exercised it.

Fix: normalizeBundleChild() converts each stored child, most-correct
first:
1. Already module-row shaped -> re-stamp via buildModulePublishable.
2. Resolve the live library module by id -> guaranteed validator-correct
   payload (same row a standalone module publishes). This is the primary
   path and why the fixed_values payload now carries `entries`.
3. Fallback: flatten the snapshot's own fields + console.warn (only when
   the source module isn't in the local library).
Nested-bundle children throw a clear "flatten it first" error (the
community child schema is module-only).

buildBundlePublishable now takes a ModuleResolver. CommunityRowActions
fetches the module catalog (if empty) + builds the resolver from it;
ExportTab resolves against its loaded module list. Publish errors now
surface as a toast instead of an unhandled throw.

Tests: normalize resolves widget->row, flattens on miss (warns), passes
through canonical, throws on nested bundle; buildBundlePublishable output
survives strict buildPublishBody validation (the regression).
2026-06-06 08:32:04 +03:00
DumiFlex bbd1a37bd8 feat(publish): writeback community origin to local row after publish
Publishing a local row to community never linked the row back to the
resulting post, so: no community pill, no update-available tracking,
and the re-publish version-bump flow was unreachable (origin slug was
always null -> publishMode could only ever be 'create').

Adds the writeback path:
- ModuleRepository / BundleRepository.set_community_origin(id, slug,
  version): stamps community_post_slug + community_version_number
  WITHOUT a version bump or fingerprint change -- origin metadata is
  not runtime-affecting, so a published row stays byte-identical to
  what consumers installed.
- wp_api: POST /wp/api/modules/{id}/community-origin + the bundles
  twin. Validates {post_slug: str, version_number: int}.
- api client: modules.setCommunityOrigin + bundles.setCommunityOrigin.
- main.ts host bridge: new optional `markPublished(localId, kind,
  slug, version)` method. Calls the right setCommunityOrigin, then
  refreshes both catalogs so the pill appears without a reload.
  Optional + try/catch-returns-false so a writeback miss never breaks
  the embed's publish-success path. No ABI bump -- adding a method is
  non-breaking; the embed guards on its presence.

Tests: set_community_origin stamps slug+version, does NOT bump
version/fingerprint, raises on missing row.

Pairs with the community embed commit that calls markPublished after
createPost / createVersion.
2026-06-06 06:48:43 +03:00
DumiFlex 63a7cd9ae4 fix(publish): bypass vue-router hash encoder
vue-router 4's `router.push({hash})` percent-encodes the hash value
on its way through. When the hash is built via URLSearchParams.toString()
(which already encodes `+`, `,`, `=`, etc. to %2B, %2C, %3D), the
router double-encodes the `%` itself -- `tags=tag1,tag2` arrived at
the embed as literal `tag1%2Ctag2` in the input field, and the
base64 payload's `+` chars got mangled past the point where atob
could decode them. Payload textarea stayed empty.

Fix: navigate to the path via the router as before, but assign the
hash via `window.location.hash` AFTER the route settles. Direct
assignment bypasses the encoder; the browser fires `hashchange`
which the embed picks up.

Pairs with embed listener added in the matching community web commit.
2026-06-06 06:30:26 +03:00
DumiFlex 39c06371e3 fix(publish): forward content_rating + tags + origin slug to embed
Sister-side publish flow only sent name + description + payload via
the URL hash. content_rating + tags fields stayed at their defaults
in the embed publish form ('sfw' + empty), forcing the user to
re-enter them by hand every time -- and on NSFW modules that's a
foot-gun: easy to forget to flip the radio before clicking Publish.

PublishablePayload now carries:
- content_rating: engine-side ('safe'|'nsfw'); mapped to community's
  'sfw'/'nsfw' at the URL hash boundary.
- tags: prefills the comma-separated tags input.
- community_post_slug: routed through as `origin_slug` in the hash so
  the embed publish form can detect re-publish scenarios (own post =
  version bump; foreign post = fork prompt -- consumer lands next).
2026-06-06 06:09:21 +03:00
DumiFlex ede4f4e0f2 docs: document schema_version machine + column-add procedure
New section covering:
- The three axes (schema_version, producer_engine_version,
  version_number) and what each means.
- Engine row columns added in migration 014 (schema_version,
  original_payload_json, tolerant_drift_status, schema_migrated_at).
- The lazy_migrate_row machine: validator registry,
  parseTolerantAsCurrentShape, migratePayload, install decision tree.
- The proper way to bump schema_version when a payload shape change
  lands (new validator + migrator step + fixtures + constant bump +
  test + community catalog coordination + server-first deploy).
- Row-level column changes (e.g. content_rating in migration 015)
  as a distinct procedure -- no schema bump needed since they live
  outside the payload JSON.

References the cross-repo spec at the community web repo.
2026-06-06 05:54:36 +03:00
DumiFlex 99374cf76f feat(manager): nsfw toggle in editors + pill on library rows
Wires the content_rating axis end-to-end on the SPA + HTTP layer:

- TS types: ModuleRow / BundleRow / *CreateInput / *UpdateInput +
  InstallOrigin all gain a `content_rating?: 'safe' | 'nsfw'` field.
- install.ts: when `opts.origin.content_rating` is set, every entity
  going in has its content_rating stamped -- the embed's community
  install passes 'nsfw' through to the engine without needing a
  follow-up fetch.
- IdentityCard: adds a Toggle in a new "Content rating" field. Same
  component is the source for all 6 module editors + BundleEditor,
  so the toggle lives in one place. A `showContentRating` opt-out
  lets TemplateEditor hide the field (templates table doesn't carry
  the column).
- All 6 module editors (Fixed, Wildcard, Combine, Constraint,
  Derivation, Bundle) plumb a `contentRating` ref through load,
  save, and IdentityCard binding.
- wp_api modules + bundles: POST/PUT endpoints accept content_rating
  in the body and forward to the repository.
- ModuleListView: renders a new `wp-row-community-pill--nsfw` pill
  on rows where row.content_rating === 'nsfw'. Uses the harmonized
  primitive from b5ce9e4 -- the comment block there explicitly
  seeded this variant.
2026-06-06 05:43:40 +03:00
DumiFlex 395af63b1a feat(engine): content_rating column on library rows (migration 015)
Adds `content_rating TEXT NOT NULL DEFAULT 'safe'` to modules + bundles.
Defaults all pre-015 rows to 'safe'. Repository create/update accept a
new content_rating kwarg with validation against {'safe', 'nsfw'}.
Importer _insert_module / _insert_bundle pass entity.content_rating
through; _update_module / _update_bundle use COALESCE(?, content_rating)
so an install path that omits the key preserves the existing flag.

Why: user wants two NSFW behaviors:
1. Edit page toggle to mark a module/bundle as NSFW locally.
2. Community posts with content_rating='nsfw' auto-stamp NSFW on
   the local row at install time.

This is the engine half of that work -- the schema, the data
plumbing, and validation. The SPA half (IdentityCard toggle + pill
render on ModuleListView + install.ts plumbing) lands next.

Tests cover: migration head bump to 15, column existence on both
tables, safe default backfill, accepts nsfw, rejects invalid, update
toggle round-trip, update without content_rating preserves prior.
2026-06-06 05:22:02 +03:00
DumiFlex 87b8c6d837 refactor(manager): harmonize module-list row pills
Restyle .wp-row-community-pill + --update variant to the unified
metadata-pill shape: 18px tall, 4px-rect radius, 10px label,
bordered. Drop redundant "community" text from the origin pill --
the globe glyph carries the meaning, and harmonized sizing means
the icon-only chip stays legible.

Why: pre-existing pills used a 999px pill radius with mixed sizes
and the accent purple bled into surrounding subtype chips. New
shape gives the row a quieter inspector-ribbon feel while
preserving the click affordance on update-available.

The same primitive will house future variants (e.g. nsfw, once
the content_rating column lands on the engine row in migration
015).
2026-06-06 05:01:51 +03:00
DumiFlex 73ca8a3f9a feat(engine): bulk lazy runner — eager iteration of lazy_migrate_row
Implements spec §4 Flow 4: bulk-at-boot is NOT a parallel forward-chain
implementation; it's eager iteration of lazy_migrate_row over every
stale row. Same original-first logic, same re-projection, zero
divergence. Eliminates the transient-wrong window where bulk could
write fabricated defaults over recoverable data in original_payload_json.

run_bulk_lazy returns the failure count; caller surfaces a dashboard
banner when > 0. STALE_PREDICATE catches both 'sv < current' AND
'tolerantly-installed row whose original now outranks the projection'.

Migration 014 invokes run_bulk_lazy at the end of up() — no-op pass
at v1, but validates the wiring is correct. Future bump migrations
(015 = v2, etc) will reuse the same helper.
2026-06-05 14:21:31 +03:00
DumiFlex ad0f8c2cd8 feat(import-export): publish stamps schema_version + producer_engine_version
buildPublishBody runs the producer's strict v1 validator against the
payload before returning. Stamps schema_version = CURRENT_SCHEMA_VERSION
and producer_engine_version from package.json. Same validator code is
re-used by consumers at install-time strict validation (spec §5f) — a
bug has to exist in both producer + consumer at the same version to
slip past, which means it's a generic validator bug, not a
vendor-drift bug.

publishToCommunity now calls buildPublishBody before navigation, so
structural validation fires at publish-click time. The actual POST
still happens in community web's EmbedPublish after the user fills
the form — sister doesn't make the request directly.
2026-06-05 14:07:14 +03:00
DumiFlex 19836dd2d3 feat(import-export): install decision tree per spec §4 Flow 3
decideInstallPath returns one of:
- install_direct (payload at CURRENT)
- install_with_chain (payload below CURRENT, forward migrate)
- install_tolerant (payload above CURRENT, AND-fold over catalog
  interval is all-additive; strip + preserve original verbatim)
- refuse (payload above CURRENT, any breaking step in interval)

The AND-fold check is the spec §3 #1 fix — fold over the WHOLE
interval (CURRENT, payload_v], not just the payload's own catalog
row. A breaking step earlier in the interval still poisons later
additive steps.

This is the decision function only — wiring it into the full
installEnvelope flow (path 3a strict-validate, path 3b tolerant
strip + verbatim local persist) lands in a follow-up task.
2026-06-05 13:56:09 +03:00
DumiFlex 07fe3ab064 feat(engine): lazy_migrate_row — the ONE migration runtime
Implements spec §4 Flow 5 in full:
- No-op when row is at CURRENT with no future original
- NULL-drop optimization when row.sv == original.sv == CURRENT
- Recovery exact catch-up: promote original into payload (the §4 bug fix —
  write-back gate is 'base_v < CURRENT OR base IS original', not just
  'base_v < CURRENT'; otherwise exact catch-up silently no-ops)
- Still-drifted re-project: tolerantly strip original down to CURRENT
- Deferred strict-validate failure → tolerant_drift_status='broken'
- Standard forward chain otherwise

original_payload_json is inert storage: two writers only (initial
tolerant install + this NULL-drop). No other path rewrites it.
schema_migrated_at is separate from updated_at (structural migration
is not a user edit).
2026-06-05 13:46:49 +03:00
DumiFlex 320315c0ae refactor(import-export): per-row migratePayload contract per spec §2
Two distinct migration domains now coexist in migrations.ts:

- migrateImportEnvelope (renamed from migratePayload) — operates on
  the full multi-row import envelope (RawPayload with bundles[],
  wildcards[], etc). Used by parsePayload + the manual JSON import.
- migratePayload (new) — operates on a SINGLE ROW (module or bundle
  dict). Per spec §2: forward-only chain with registry-keyed dispatch
  by (kind, fromVersion); context is opt-in via requiresContext;
  bundle migrators recurse via applyModuleStep helper enforcing the
  single-version-per-root invariant.

migratePayload throws clearly when called for an interval that has
no registered step (caller bug — every shipped bump must register
its migrator) or when a declared context key is missing.
2026-06-05 13:25:58 +03:00
DumiFlex 4758c8402d feat(import-export): parseTolerantAsCurrentShape — strip newer fields
Per spec §3: when payload.schema_version > CURRENT and all bumps in
the interval are additive (AND-fold check), strip unknown top-level
fields and validate the projection against CURRENT's strict shape.

Recursive on bundles: each child is stripped against its subtype's
tolerant validator before the bundle root validates. Single-version-
per-root invariant: bundle root version governs all children.
2026-06-05 13:13:39 +03:00
DumiFlex 810e33ebf4 feat(validators): v1 strict + tolerant validators per subtype + bundle
Per spec §2's 'authoritative on sister' rule: sister owns
structural validation. Adds Zod schemas for all 5 module subtypes
at v1 + bundle v1. Registry keyed by (kind, subtype, version,
mode); strict mode used for declared-version checks (§2 #1) and
publish gating; tolerant mode used on the install-from-newer-
payload strip path (§3).

Fixtures double as the test-derivation source for is_breaking_from_previous
in future schema bumps (§5e).

Adds zod as a runtime dependency.
2026-06-05 13:02:40 +03:00
DumiFlex c46cbbf509 feat(engine): migration 014 - payload schema_version + verbatim-local mirror
Adds the four payload-versioning columns to modules + bundles per
the community-web spec sections 4-5:
- schema_version (NOT NULL DEFAULT 1)
- original_payload_json (NULL - only set during tolerant install)
- tolerant_drift_status ('none' | 'pending' | 'broken')
- schema_migrated_at (separate from updated_at - structural migration
  is not a user edit)

Backfill is implicit via the DEFAULT on the NOT NULL columns.
2026-06-05 12:50:54 +03:00
DumiFlex 97c92b97e0 fix(manager): keep editor header Publish + Copy grouped
CommunityRowActions in labeled mode rendered as fragment, so the two
buttons became separate flex items inside EditorFrame's header-extra
slot. wp-page__header uses justify-content space-between, which
distributed Title - Publish - Copy across the row instead of keeping
the pills together on the right.

Wrap labeled mode in a single .wpc-row-actions-labeled flex container
so it's one flex item from the parent's perspective. Icon-only mode
stays as a fragment (row-action cells want each button as a sibling).
2026-06-05 09:59:58 +03:00
DumiFlex 9c7a110d22 fix: refresh token field + show update pill outside #name slot
Two independent fixes in the sister.

1. tokenStore refresh body field. Backend's RefreshRequest uses
   pydantic extra=forbid and requires refresh_token (not refresh);
   the embed was sending {refresh: ...} which got rejected as 422.
   The wipe path only fires on 401/403, so the failed refresh
   returned null silently and the embed lost auth at the first
   access-token expiry. Refresh now correctly rotates and the
   embed stays logged in.

2. ModuleListView update-available pill rendered inside the #name
   slot's fallback only. Every list view (Wildcards / FixedValues /
   Combines / Derivations / Constraints / Bundles) overrides
   #name, so the fallback never ran and the pill never showed -
   even when communityUpdateStore logged fetched=N updates=N.
   Move community + update pills outside the slot into a new
   .wp-row-name-wrap flex container that always renders alongside
   the slot output, regardless of override.
2026-06-05 09:54:57 +03:00
DumiFlex ea3c628615 feat(manager): move editor Publish + Copy from footer to header
Footer-left was crowded (History + Publish + Copy + Delete) and the
community actions visually clashed with the destructive Delete pill
next door. Move CommunityRowActions into EditorFrame's header-extra
slot so Publish + Copy sit top-right next to the page title, matching
the actions-on-the-right convention the rest of the SPA already uses.

Footer now only carries Delete (left) and Cancel + Save (right).

Affects all six editors: Wildcard, Fixed, Combine, Derivation,
Constraint, Bundle. Five already used header-extra for the 'used by'
cascade-refs badge; the new actions render next to it.
2026-06-05 09:40:05 +03:00
DumiFlex 59c5d705bd feat(community): route Publish through embed instead of new tab
Per-row Upload buttons used window.open(WPC_API_URL/upload) which
threw away the in-extension bearer token (cross-origin localStorage)
and forced a fresh device-flow login. Now routes to /community/publish
inside the SPA — the community embed bundle's EmbedPublish reads
window.location.hash on mount and hydrates the form. Auth survives;
the user stays inside ComfyUI.

- single-row-publish.ts publishToCommunity(pub, router) → router.push
- Community.vue parseRoute recognizes 'publish' segment
- embedLoader EmbedNavigateTarget / EmbedMountOptions widened to
  match community's EmbedView union (adds bundles/library/publish)
- CommunityRowActions + ExportTab inject useRouter and pass through

Pairs with community web's new EmbedPublish view.
2026-06-05 08:37:56 +03:00
DumiFlex 9c0b11f7da feat(manager): editor footers gain Upload + Copy buttons
Each of the six editors (Wildcard, FixedValues, Combine, Derivation, Constraint, Bundle) now renders Publish + Copy pills in the footer-left slot next to Delete (only when isEdit, i.e. when there's an actual row to act on). Uses the same CommunityRowActions component the list rows added in 34d58e6 — single source of truth via single-row-publish helpers.
2026-06-05 08:23:09 +03:00
DumiFlex 7a5c382eb4 feat(manager): per-row community upload + copy + update diag
Pulls publish + copy helpers out of ExportTab into shared single-row-publish util. New CommunityRowActions component renders Upload + Copy icon buttons; wired into all six library list views (Wildcards, FixedValues, Combines, Derivations, Constraints, Bundles) so users no longer need the Import/Export tab for single-row publish. Also adds diagnostic console.info in communityUpdateStore.check showing module/bundle/candidate/update counts per check — helps debug 'no update pill' reports without UI changes.
2026-06-05 08:10:11 +03:00
DumiFlex b85f21a01d fix(manager): dashboard tabs left + settings restart confirm
- Dashboard's Recents card moved the tab row (Recently opened /
  Recent edits / Favorites) out of the Card #actions slot (which
  forces right alignment via wp-spacer) and into the card body
  itself. Tabs now sit on the LEFT; View all stays right via a
  spacer. The left half of the row used to be empty.

- Settings → Database card's Restart ComfyUI button now opens a
  ConfirmDialog before kicking the process. Same gate StaleBanner
  uses for its Restart pill; the SPA had two surfaces firing the
  restart action and only one was confirmed.
2026-06-05 07:48:21 +03:00
DumiFlex af314d3abe feat(community): update-action dialog with in-place + as-new + dismiss
Closes the loop on community-tracked rows. The warn-coloured update
pill on ModuleListView is now a button; clicking it opens
CommunityUpdateDialog with three resolutions:

1. Update in place — fetches the post's latest version payload, pre-
   injects a history snapshot of the local row's pre-update state
   into the new payload's `history` sidecar (modules only — bundles
   have no per-row history feature), wraps in the engine envelope,
   then routes through installEnvelope with a forced `replace`
   decision. Engine `_update_module` / `_update_bundle` now rewrite
   community_post_slug + community_version_number when the new
   content carries them, so the row's tracked version bumps to the
   post's latest in one transaction. The user can roll back via the
   editor's existing History panel.

2. Install as new entry — installEnvelope with a forced `rename`
   decision (auto-suffix `-v{N}` on the entity name). Keeps the
   original row exactly as it was; the new row also stamps community
   origin so it gets tracked on its own.

3. Dismiss — persists `{post_slug: version}` to localStorage. The
   community update store reads this map on every check; pills clear
   for the dismissed version, but pop again if the post ships a
   newer version (dismissal is per-version, not per-post).

Importer extends `_update_module` + `_update_bundle` to rewrite
community_* columns when the incoming content carries them. Vanilla
Import-tab replaces (no community origin in the entity) keep the
pre-existing UPDATE shape so locally-authored rows don't get their
community_* fields silently nulled by a non-community replace.

ModuleListView mounts the dialog once at the list root and stores
the active update entry locally — pill click hands the entry to the
dialog, dialog drives all three actions through stores + install
pipeline + localStorage, then closes.
2026-06-05 06:49:22 +03:00
DumiFlex 9957702099 feat(community): track install origin + update-available indicator
Library rows installed from the community embed now carry the source
post slug + installed version_number in the engine sqlite. The SPA
uses both surfaces: a "community" pill next to the row name (so users
can tell at a glance which entries were authored locally vs pulled
from the embed), and a warn-coloured "v{N}" pill when the post has
shipped a newer version since install.

Engine:
- Migration 013 adds community_post_slug + community_version_number
  to modules + bundles. Both nullable; locally-authored rows stay
  NULL forever. Implemented as a .py migration with PRAGMA
  table_info guards so rerunning against an already-migrated DB
  (test fixtures rewind migrations to exercise older paths) is a
  clean no-op rather than "duplicate column name".
- _insert_module / _insert_bundle (importer.py) persist the fields
  when the entity dict carries them.
- _row_to_module / _row_to_bundle (repositories.py) surface them on
  every read path. Try/except guard for pre-013 DB shape so a
  connection opened mid-rollout does not crash.

Manager:
- installEnvelope grows optional `origin: { post_slug, version_number }`
  in InstallOptions. When set, stamps every module + bundle in the
  selection before the commit roundtrip.
- Host bridge in main.ts threads origin from `install(envelope, opts)`
  through to installEnvelope. ABI stays at 1 (additive).
- New communityUpdateStore walks the library snapshot, batch-fetches
  each unique community post (deduped, 8-wide concurrency), surfaces
  rows whose installed version is behind the post's
  latest_version_number. Network failures clear the store silently
  so an offline session does not render misleading "v2 available"
  pills.
- AppLayout kicks one check after the initial library fetch
  resolves. No background polling.
- ModuleListView renders two distinct pills per row: a static
  "community" indicator on origin-stamped rows, and a separate
  warn-coloured "v{N}" pill when the update store has an entry.

Migration tests updated for the new schema head + bundle/module
column sets.
2026-06-04 17:21:04 +03:00
DumiFlex 519abd399a feat(install): pre-commit collision detection + resolver callback
installEnvelope grows an optional library snapshot + resolveCollisions
callback in its options. When both are wired (host bridge passes
them per call), the function walks every incoming module + bundle
against the live library before the commit roundtrip, surfaces
duplicates to the resolver, and applies the user's per-id decisions
(skip / replace / rename) to the commit payload.

Decisions translate to existing commit-shape verbs:
- skip drops the entity from the bucket
- replace rewrites the decision wrapper from {add} to {replace}
- rename mints a fresh 8-hex id via newShortId, swaps the entity's
  id field, and emits {rename, new_id, new_name} — same shape the
  Import tab's picker produces

main.ts wires the host bridge to snapshot moduleStore.catalog +
bundleStore.catalog on every install() call and forwards the
embed's resolveCollisions callback through. The bridge accepts an
optional second arg; callers that don't pass it keep the original
server-rejects-on-conflict behaviour for back-compat.

ABI stays at 1 — additive change to install signature.
2026-06-04 16:52:51 +03:00
DumiFlex bb82b667dd fix(manager): confirm before destructive actions
Three UX gaps from the v1.7 release:

1. Editor Delete pill (Wildcard, Constraint, FixedValues, Combine,
   Derivation, Bundle) used to skip the confirm dialog when the
   entity had zero inbound refs. A user editing a newly-installed
   module could lose it on a single click. All six editors now
   always open the cascade dialog — its dry-run handles 0 refs
   cleanly (empty affected list, Delete button still front-and-
   centre), just with a 100ms scan round-trip.

2. StaleBanner's Restart ComfyUI pill now goes through a themed
   ConfirmDialog instead of firing on click. Restart kicks the
   process, interrupting any in-flight workflow / queued prompts,
   so the user gets a chance to bail out.

3. AppSidebar's search input gains margin-top so it doesn't read
   like it's glued to whatever sits above it in the nav column.
2026-06-04 16:21:48 +03:00
DumiFlex c6c42c4d9b feat(manager): add Publish to community button on Export tab
Single-row deeplink to community web's /upload?from=spa flow. Builds
the engine-row payload from the live ModuleRow / BundleRow, utf8-safe
base64-encodes it, and opens
  ${WPC_API_URL}/upload?from=spa#payload=<b64>&name=<n>&description=<d>
in a new tab. Community web's PublishView.hydrateFromSpaHash decodes
the hash on mount and prefills the textarea + metadata fields.

Hash (not query) keeps the payload off the request log and sidesteps
Edge's ~2KB address-bar limit on bundle exports. Button is enabled
only when exactly one module OR one bundle is selected — multi-row
publish is not a flow the community supports (each post is one
entity). Categories + templates are excluded; the community can't
host those yet.
2026-06-04 15:43:47 +03:00
DumiFlex 3d92673078 fix(manager): add Delete button to WildcardEditor 2026-06-04 15:32:43 +03:00
DumiFlex a2cc1bb844 fix(community): tokenStore refresh hits /auth/token/refresh not /auth/refresh
Embed token refresh was 404-ing silently because the path used /auth/refresh while the backend router exposes /auth/token/refresh (see api/app/routers/auth.py line 425). Body key also went from refresh_token to refresh to match the backend RefreshBody schema. Effect: bearer auth survived past the access-token TTL without forcing the user to re-login.
2026-06-04 15:15:31 +03:00
DumiFlex 738ede31c3 fix(cascade): allow whole-entity delete for fixed_values/combine/derivation/constraint
Orchestrator dispatch table only handled wildcard/category/bundle/option deletes (the kinds that have reverse-refs to fix). Leaf module types fell through to the unsupported (kind, action) error path even though there is no cascade work to do for them — just drop the row + register undo.

Added a fallback branch for (fixed_values | combine | derivation | constraint, delete) that emits empty touched/diff lists; _delete_target was already routing the kind through ModuleRepository correctly via its else-branch, just needed the kind added to the whole-entity-delete guard.

Caveat noted in code: combine.output_var is referenced by name from downstream payloads. A combine delete leaves any consumer with an unresolved variable, surfaced separately by the integrity/broken-refs scan rather than by cascade-apply.
2026-06-04 15:10:16 +03:00
DumiFlex b9132af764 fix(install): drop normalizeEntity now that community speaks engine-row shape
Community web reshaped its seed/validator/preview to emit engine-row payloads directly (top-level name; type-specific fields nested under payload). normalizeEntity was lifting meta.name to mask a community-side bug; the community side is fixed at the source now, so the install pipeline can trust whatever wire shape lands. Anything that's still missing the top-level name is a real bug to fix at source, not silently patch in the bridge.
2026-06-04 14:54:58 +03:00
DumiFlex 160b729e15 fix(install): lift meta.name to top-level name in install pipeline
Engine importer expects every entity to carry a top-level 'name', but engine EXPORTS (and therefore community-uploaded payloads round-tripped through the export envelope) park it at meta.name. The on-disk shape and the importer's required-fields shape differ by one nesting level, so install through the community embed kept failing with 'add for wildcard missing required field(s): [name]'.

normalizeEntity() in install.ts now lifts meta.name to top level when absent, with a short-id fallback if both slots are empty. Idempotent: existing top-level name is left alone. Applied per-entity in wrapAdds + wrapCategoryAdds before the commit assembles.
2026-06-04 14:45:43 +03:00
DumiFlex a10a321e3b feat(manager): expose window.__wpcRuntime host bridge for community installs
New import-export/install.ts wraps parsePayload + buildCommitPayload + POST /import/commit into a single installEnvelope() function. Same migration + integrity guarantees the Import tab gives, but skips the per-entity picker — every entity flows as add (server collision detector surfaces dupes if any).

main.ts sets window.__wpcRuntime = {abi: 1, schemaVersion, install} after the manager mounts. The community embed bundle (loaded into this document from wp.dumiflex.dev) detects this global at runtime and calls install(envelope) instead of saving a JSON file. ABI gate keeps older hosts safe from contract drift; schemaVersion lets the embed refuse newer-than-supported packs upfront.
2026-06-04 14:28:32 +03:00
DumiFlex c0cfd134cb feat(manager): real 404 page + global error boundary
Router catch-all was `redirect: '/dashboard'` — typos and dead deep-links silently bounced home, so users assumed nothing was wrong. New `views/NotFound.vue` is now the catch-all component (named not-found) and shows the URL that didn't match plus links back to dashboard / library.

`components/ErrorBoundary.vue` mirrors the SPA's boundary: `onErrorCaptured` returns false to contain a render-time throw inside the main content slot, route-fullPath watcher auto-resets so a one-off crash doesn't strand the user on the error card after they navigate away. AppLayout wraps the RouterView with it; sidebar + topbar stay interactive throughout.
2026-06-04 14:05:00 +03:00
DumiFlex 84cd82faa6 fix(manager): parseRoute handles :rest(.*)* array params
vue-router 4 hands back route.params.rest as an ARRAY of segments for a  matcher with a star quantifier, not a pre-joined string. String(arr) comma-joined; the subsequent split('/') failed; parseRoute always returned browse; the route watcher's handle.navigate({view:'browse'}) immediately re-pushed /community after every detail nav — URL flickered to /community/p/owner/name and bounced back. Use Array.isArray(raw) ? raw : raw ? [raw] : [] so both shapes work.
2026-06-04 14:01:01 +03:00
DumiFlex 6f2b804af4 fix(manager): community routes share layoutKey to stop embed re-mount
Both /community and /community/p/* are different route records using
the same Community.vue component. vue-router treats them as distinct
records, so AppLayout's keyed RouterView re-mounted the host on every
nav between the two — the embed bundle tore down + remounted, the
first card click silently re-mounted (losing state), and only the
second click actually navigated. Adding meta.layoutKey='community' on
both records (same pattern docs routes use) makes the keyed view
reuse the same instance across the catch-all family.
2026-06-04 13:45:20 +03:00
DumiFlex 6f51e4a804 feat(community): surface underlying import error in EmbedLoadError
The offline screen previously said 'Failed to load community embed from <url>' with no detail — useless for diagnosing CORS / CSP / MIME / network problems because every failure mode rendered the same message. Now the constructor appends the caught error's .message so the offline screen shows the actual reason (e.g. CSP refusal, network DNS, MIME rejection).
2026-06-04 12:40:36 +03:00
DumiFlex 7d43164d26 feat(manager): embed Wildcard Pipeline Community via runtime bundle
The Community tab no longer renders a WIP placeholder — it dynamic-
imports `wpc-embed.js` from WPC_API_URL and mounts the embed into a
host div, exposing browse / detail / profile views with the same
auth + theming contract documented in the community web repo.

Three new modules:
- community/embedLoader.ts — cached `loadEmbed()`, typed mount opts,
  `EmbedLoadError` for offline / CORS / host-down paths.
- community/tokenStore.ts — localStorage-backed TokenStore. Reports
  null until device flow lands (anonymous render is the v1
  fallback); proactive refresh with skew window + in-flight
  coalescing so concurrent mounts don't burn the refresh token.
- views/Community.vue — host shell. Parses `/community/:rest` into
  embed view targets, mirrors embed-initiated nav back into the
  router so the URL bar tracks state, ships an offline retry +
  Discord invite escape hatch.

Router catch-all (`community/:rest(.*)*`) lets the embed own its own
deep-link grammar (`/community/p/owner/name`, `/community/u/handle`)
without a route per shape.

CommunityWip.vue is now orphan and removed; links.ts doc comment
points at Community.vue.
2026-06-04 12:16:26 +03:00
374 changed files with 37820 additions and 3941 deletions
+6
View File
@@ -12,6 +12,12 @@ node_modules/
pnpm-debug.log
.pnpm-store/
# Vite per-contributor env overrides (e.g. VITE_WPC_API_URL pointing
# at a dev community host). Never commit — leaks dev URLs into the
# tree and makes `pnpm build` produce divergent bundles per checkout.
.env.local
.env.*.local
# Build outputs
js/
web/
+264
View File
@@ -1,3 +1,267 @@
## 🎉 Wildcard Pipeline 2.3.1
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #3 from DumiFlex/fix/instance-modal-scroll ([6a5a8d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5a8d3)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll ([a19e5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a19e5f4))
* fix(extension): scroll instance edit modals with pinned header + footer ([c43bff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c43bff0))
</details>
## 🎉 Wildcard Pipeline 2.3.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #2 from DumiFlex/feat/manager-bulk-editor ([6e8696f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6e8696f)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
* feat(manager): add bulk editor for wildcard options and fixed values ([959cfbe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/959cfbe))
* feat(widgets): shield clipboard shortcuts on editable widget inputs ([325c908](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/325c908))
* fix(manager): recover from rich-text editor crash and persist drafts on unload ([0f5166e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f5166e))
</details>
## 🎉 Wildcard Pipeline 2.2.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #1 from DumiFlex/feat/community-embed ([a53d526](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a53d526)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
* test: lock cascade-restore end-to-end constraint repoint (#2) ([67de90c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67de90c)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
* test(cascade): edge-case qa for subcat rename/delete (collapse, null, multi-surface) ([54054fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/54054fb))
* test(constraint): double-cast findComponent to VueWrapper in cached-names test ([c102361](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c102361))
* test(engine): lock constraint source-pick fallback semantics ([690169b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/690169b))
* test(sp2a): lock multi-pick debug render + var.K cascade/validation ([d1ee97b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d1ee97b))
* test(spa): lock accessor-aware var autocomplete match ([8d7bda2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8d7bda2))
* test(syntax): move subcat_filter tests into tests/syntax for the coverage gate ([3065e99](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3065e99))
* test(validators): engine-parity harness to stop validator drift ([51f6793](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51f6793))
* test(wp_api): lock comma rejection on variable names server-side ([94ccd7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/94ccd7f))
* docs: document schema_version machine + column-add procedure ([ede4f4e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ede4f4e))
* docs: flag pick-bucket collision + deprecate dead remapBundleUuids ([96fc26a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/96fc26a))
* docs: update constraint conventions to the SP3 reach model ([8314963](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8314963))
* docs(constraint): document target reach selectors and axis reattach ([844d16e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/844d16e))
* docs(derivation): document nested refs in action values ([402fdcc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/402fdcc))
* docs(syntax): document multi-pick, nested-in-inline, $var.K, and the surface gate ([7297b2f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7297b2f))
* docs(wildcard): document multi-pick, sub-categories, and nested refs ([8b99795](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8b99795))
* feat: pick_min/pick_max/pick_separator wildcard instance fields ([17a2149](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/17a2149))
* feat(api): modules/hashes returns type + fingerprint per id ([885f7fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/885f7fb))
* feat(bundles): add extractBundleChildren fresh-id materialization helper ([223770e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/223770e))
* feat(bundles): extract bundle children to library with ref remap ([59af070](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/59af070))
* feat(bundles): manual per-child reattach with community download ([f58d849](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f58d849))
* feat(bundles): move extract action to children header, used-by beside title ([f1ab33c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f1ab33c))
* feat(bundles): relink bundle to extracted modules + gate extract on dangling children ([b1fa0ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b1fa0ce))
* feat(canvas): @{} chips + autocomplete in derivation instance modal ([ab813bb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ab813bb))
* feat(cascade): expression-aware subcat rename/delete (engine + ts) ([11ad8ca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11ad8ca)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
* feat(cascade): per-entity wildcard-delete cleanup via cleanup_ids ([9a97613](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a97613))
* feat(cascade): reconcileSubcatExpr token reconciler for remap preview ([fd25a63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd25a63))
* feat(cascade): rewriteBrokenRef pure rewriter for broken-ref remap ([1d59df9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1d59df9))
* feat(collision): add type-conflict state + classifyOne core ([9e34d23](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9e34d23))
* feat(community): add public post detail + download fetch helpers ([c858bef](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c858bef))
* feat(community): add transitive dependency closure walker ([1ee8fe2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ee8fe2))
* feat(community): orchestrate transitive dep download for dangling refs ([7dc9817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7dc9817))
* feat(community): route Publish through embed instead of new tab ([59c5d70](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/59c5d70))
* feat(community): surface underlying import error in EmbedLoadError ([6f51e4a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f51e4a))
* feat(community): track install origin + update-available indicator ([9957702](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9957702))
* feat(community): update-action dialog with in-place + as-new + dismiss ([af314d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af314d3))
* feat(conflicts): surface broken nested @{} refs at module row ([fd254a6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd254a6))
* feat(constraint): backfill cached wildcard name from live catalog on save ([d188ead](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d188ead))
* feat(constraint): cache source/target wildcard names for broken-ref banner ([70da7e0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/70da7e0))
* feat(constraint): canvas modal detects dangling source/target and reattaches via walkremap ([77f00ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/77f00ae))
* feat(constraint): canvas parity for stranded read-only matrix + colored exception modes ([5ae0ff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5ae0ff0))
* feat(constraint): download missing deps from community + reattach ([02ad0eb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02ad0eb))
* feat(constraint): list derivation-nested occurrences in reach pick-list + orphan scan ([098c069](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/098c069))
* feat(constraint): lock stranded wildcard fields + color-code exception modes ([4598f68](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4598f68))
* feat(constraint): reattach section for dangling source/target with blast-radius warning ([f7369ff](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f7369ff))
* feat(constraint): recognise derivations as @{} carriers (branch-key option ids) ([8247d73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8247d73))
* feat(constraint): show stranded constraint matrix + exceptions read-only in spa editor ([fa68a36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa68a36))
* feat(constraint): spa editor reattach control mirrors the canvas modal ([1bf1e42](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1bf1e42))
* feat(context): type-aware drift/missing + id-clash state via classifyOne ([c13351d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c13351d))
* feat(engine): bulk lazy runner — eager iteration of lazy_migrate_row ([73ca8a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/73ca8a3))
* feat(engine): capture constraint's own bundle_origin on registered meta ([febb9f3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/febb9f3))
* feat(engine): constraint reach selector (first/next/all) + multi-constraint combine; retire one-shot ([be1f765](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be1f765))
* feat(engine): content_rating column on library rows (migration 015) ([395af63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/395af63))
* feat(engine): independent (allow-repeats) multi-pick for the wildcard pool ([33b3600](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33b3600))
* feat(engine): lazy_migrate_row — the ONE migration runtime ([07fe3ab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/07fe3ab))
* feat(engine): list-backed variables + var.K accessor ([8eeb0a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8eeb0a8))
* feat(engine): migrate library v1->v2 (multi-tag) + bump schema version ([39529a6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39529a6))
* feat(engine): migration 014 - payload schema_version + verbatim-local mirror ([c46cbbf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c46cbbf))
* feat(engine): multi-pick prefix gains N-M range + ~ independent flag ([0b36ce8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0b36ce8))
* feat(engine): multi-select wildcard binds a list variable ([443db49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/443db49))
* feat(engine): multi-tag options + boolean sub-category filters (v2) ([13de622](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/13de622))
* feat(engine): per-pick recording + apply constraints via combine_constraint_factor ([f68d2ec](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f68d2ec))
* feat(engine): pure combine_constraint_factor + shared constraint corpus ([dd785c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dd785c5))
* feat(engine): range count + independent mode + lone-ref pool multi-pick ([63b824e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/63b824e))
* feat(engine): record per-origin source pick view in __wp_picks__ ([d6094ed](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d6094ed))
* feat(engine): resolve @{} refs on the derivation surface ([059517f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/059517f))
* feat(engine): stamp derivation carrier (uid, branch_key) for nested reach ([599c3b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/599c3b1))
* feat(engine): thread bundle_origin into resolve ctx per module ([7bcfcac](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7bcfcac))
* feat(engine): thread carrier uid + option id for nested-pick constraint reach ([4165b2e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4165b2e))
* feat(engine): tokenize var.K list accessor (py+ts parity) ([3ac0afa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3ac0afa))
* feat(engine): validate + register constraint target_select; instance override field ([8c3c9ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c3c9ae))
* feat(import-export): install decision tree per spec §4 Flow 3 ([19836dd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/19836dd))
* feat(import-export): parseTolerantAsCurrentShape — strip newer fields ([4758c84](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4758c84))
* feat(import-export): publish stamps schema_version + producer_engine_version ([ad0f8c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ad0f8c2))
* feat(import): apply walkremap follow-through over committed entities on rename ([236c39a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/236c39a))
* feat(import): surface cross-kind id clashes as CLASH; never replace ([4fabb76](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4fabb76))
* feat(import): walkremap follow-through helper for renamed friend ids ([4dd6d6c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4dd6d6c))
* feat(install): add buildReattachRemap slug-bridge helper ([7845d91](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7845d91))
* feat(install): add reattach type surface (deps, slug, count) ([e138868](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e138868))
* feat(install): auto-reattach inner-bundle references on bundle install ([b4f2d6b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b4f2d6b))
* feat(install): auto-reattach references via slug-bridge on install ([0494e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0494e3c))
* feat(install): flag + backstop cross-kind id clashes (never replace) ([ca54a3a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ca54a3a))
* feat(install): host bridge forwards post deps + library slug ([a9bde14](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a9bde14))
* feat(install): pre-commit collision detection + resolver callback ([519abd3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/519abd3))
* feat(manager): add Publish to community button on Export tab ([c6c42c4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6c42c4))
* feat(manager): editor footers gain Upload + Copy buttons ([9c0b11f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9c0b11f))
* feat(manager): embed Wildcard Pipeline Community via runtime bundle ([7d43164](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d43164))
* feat(manager): enforce identifier grammar on variable names ([c661550](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c661550))
* feat(manager): expose window.__wpcRuntime host bridge for community installs ([a10a321](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a10a321))
* feat(manager): mirror v1->v2 migration + bump client schema version ([221aa90](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/221aa90))
* feat(manager): move editor Publish + Copy from footer to header ([ea3c628](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ea3c628))
* feat(manager): nsfw content filter on every list view + all items ([4b491b8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4b491b8))
* feat(manager): nsfw toggle in editors + pill on library rows ([99374cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/99374cf))
* feat(manager): per-row community upload + copy + update diag ([7a5c382](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7a5c382))
* feat(manager): real 404 page + global error boundary ([c0cfd13](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0cfd13))
* feat(manager): remaprefpopup for healing broken nested chips ([e897bd3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e897bd3))
* feat(manager): thread module-id so remap clears broken-ref warnings in all #3 hosts ([770909d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/770909d)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* feat(manager): wire @remap in richtextinput to heal the open field ([a75c737](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a75c737))
* feat(pool): multi-select pick-count control + null disable in multi ([d167a1f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d167a1f))
* feat(publish): add listReferencedUuids dependency detector ([e709d53](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e709d53))
* feat(publish): auto-detect + inject module dependencies into publish hash ([107d5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/107d5f4))
* feat(publish): guided unmet-dependency checklist before publish ([292a0a5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/292a0a5))
* feat(publish): surface bundle children external refs as dependencies ([33316aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33316aa))
* feat(publish): surface inner-bundle dependencies in guided publish ([e7e844b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e7e844b))
* feat(publish): verify published deps + reclassify stale ones to publish-first ([feb760e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/feb760e))
* feat(publish): wire inner-bundle dependency publish button to navigate ([f8edcfe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f8edcfe))
* feat(publish): writeback community origin to local row after publish ([bbd1a37](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bbd1a37))
* feat(refchip): distinct remap emit for unresolved broken chips ([43de217](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43de217))
* feat(spa): "Allow repeats" toggle for the wildcard pool multi-pick ([e785928](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e785928))
* feat(spa): author @{} nested refs in derivation action values ([3564938](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3564938))
* feat(spa): canvas wildcard editor decomposes blocks into inner chips (sp2b) ([0962aad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0962aad))
* feat(spa): decompose brace blocks into scaffolding + inner chips, render v2 ([ef6fadc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ef6fadc))
* feat(spa): render var.K as a single chip + accessor-aware autocomplete ([64d1cb1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/64d1cb1))
* feat(spa): resolve inline blocks in the combine preview (deterministic) ([62f2e4a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/62f2e4a))
* feat(spa): stamp sp2b schema_version on publish when range/independent used ([36942a2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/36942a2))
* feat(syntax): boolean sub-category filter parser, matcher + ts parity ([0e1803e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e1803e))
* feat(ts): badge reframe — reach suffix, contributor cluster + solid collapse chip, before name ([02ed686](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02ed686))
* feat(ts): combineConstraintFactor mirror + shared corpus parity ([2eb0e36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2eb0e36))
* feat(ts): constraint reach selector — segmented modes + cross-node pick checklist ([c80605a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c80605a))
* feat(ts): debug rows for constraint never_applied + partial_reach ([764c82e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/764c82e))
* feat(ts): library-default target_select in the manager constraint editor ([a0f9651](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a0f9651))
* feat(ts): pairings mark-all reach + contributors (drop exclusive claim) ([3902210](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3902210))
* feat(ts): per-selector constraint orphan scan (drop one-shot claim-order) ([3f14a6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3f14a6f))
* feat(ts): per-WP_Context node_label, tagged through the cross-node walker + editable header chip ([a94cd54](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a94cd54))
* feat(ts): preview resolver applies combine + reach (engine parity) ([b20faa0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b20faa0))
* feat(ts): stamp schema_version 4 when constraint target_select is non-default ([826d86f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/826d86f))
* feat(ts): transitive nested-target carrier walk (cap + cycle) with route chain ([1ddbe45](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ddbe45))
* feat(ui): nested-ref filter popover + chip + in-graph pool editor ([33e6bd0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33e6bd0))
* feat(ui): wildcard editor sub-category groups + per-option multi-tag ([c374863](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c374863))
* feat(validators): v1 strict + tolerant validators per subtype + bundle ([810e33e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/810e33e)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
* fix: refresh token field + show update pill outside #name slot ([9c7a110](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9c7a110)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
* fix(assembler): render list-valued vars + .K accessor in node preview ([dc05a5d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dc05a5d))
* fix(bundle): remap constraint source/target to restored uuids on cascade push ([bb9ec6d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb9ec6d))
* fix(bundles): reconcile restored inner-bundle fingerprint to clear false drift ([e458e8d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e458e8d))
* fix(bundles): repoint cascade-restored standalone library refs (phase 1 pass 2) ([fa0f2ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa0f2ce))
* fix(bundles): repoint workflow module refs on cascade restore, not just ids ([6fe52c0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6fe52c0))
* fix(bundles): stamp payload_hash on cascade-restored bundle children so re-insert shows no drift ([eff7a6a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eff7a6a))
* fix(bundles): sync restored module payload_hash on cascade so canvas shows no drift ([37ca8bd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/37ca8bd))
* fix(canvas): picker z-index + teleport theme + inline ↪#N badge in derivation summary ([ec3508a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ec3508a)), closes [#N](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/N) [#N](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/N)
* fix(cascade): allow whole-entity delete for fixed_values/combine/derivation/constraint ([738ede3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/738ede3))
* fix(cascade): discover v2 boolean sub-category filter refs ([4cda98f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4cda98f))
* fix(cascade): keep bundles frozen on wildcard rename (no auto-mutate) ([e748064](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e748064)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
* fix(cascade): stamp deleted wildcard name onto dependent constraints for broken-ref banner ([97d3036](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/97d3036))
* fix(cascade): wildcard-delete leaves constraints, opt-in nested-ref strip ([e288d1b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e288d1b))
* fix(combine): filter multi-pick representative to engine pool + honor .K in template preview ([b9df102](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9df102))
* fix(community): preserve bundles/library/deleted views in host nav ([f8efcc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f8efcc9))
* fix(community): sync embed theme with the manager toggle live ([04841aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/04841aa))
* fix(community): tokenStore refresh hits /auth/token/refresh not /auth/refresh ([a2cc1bb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a2cc1bb))
* fix(constraint): derive real dropped-cell count for reattach preview ([47af97e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/47af97e))
* fix(constraint): float cached-name match in reattach dropdown (auto-suggest) ([07dcf31](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/07dcf31)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* fix(constraint): keep @{} carrier regex strict-hex (engine/dep-graph parity) ([b27b9f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b27b9f8))
* fix(constraint): origin-stamp downloaded deps so they get the community badge ([0984a7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0984a7f))
* fix(context): constraint-reach QA, node codenames, bundle-aware toolbar ([73a00a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/73a00a3))
* fix(engine): bind constraint to its own bundle copy's source pick ([9071dfb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9071dfb))
* fix(engine): fold list-valued vars in derivation + converter reads ([d41be86](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d41be86))
* fix(engine): guard junk pick counts + warn on multi-pick constraint source ([ace0486](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ace0486))
* fix(engine): slugify nested-ref tags in v1->v2 migrate + sp1 e2e smoke ([a628512](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a628512))
* fix(graph): honor .K accessor + multi-pick representative in static preview ([f922dca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f922dca))
* fix(import-export): refresh also reloads the export tab listing ([7b2d057](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b2d057))
* fix(install): drop normalizeEntity now that community speaks engine-row shape ([b9132af](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9132af))
* fix(install): lift meta.name to top-level name in install pipeline ([160b729](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/160b729))
* fix(library): validate all-items bundles against loaded catalog not empty store ([c66c1f5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c66c1f5))
* fix(manager): add Delete button to WildcardEditor ([3d92673](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3d92673))
* fix(manager): anchor remap popup to the chip using its real height ([498a4ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/498a4ba))
* fix(manager): bundle-editor constraint names + list keyboard nav reachability ([0da118e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0da118e)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* fix(manager): community pill lifecycle, list keyboard nav, dashboard intro ([24e83ea](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/24e83ea)), closes [4/#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6) [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8) [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9) [#10](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10)
* fix(manager): community routes share layoutKey to stop embed re-mount ([6f2b804](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f2b804))
* fix(manager): confirm before destructive actions ([bb82b66](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb82b66))
* fix(manager): dashboard tabs left + settings restart confirm ([b85f21a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b85f21a)), closes [#actions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/actions)
* fix(manager): dismiss subcat menus on outside click + flip near edge + drag-to-regroup ([723adde](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/723adde))
* fix(manager): keep editor header Publish + Copy grouped ([97c92b9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/97c92b9))
* fix(manager): never chipify on backspace/delete in rich-text editor ([af90203](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af90203))
* fix(manager): parseRoute handles :rest(.*)* array params ([84cd82f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/84cd82f))
* fix(manager): re-anchor remap popup on resize + cap height to viewport ([3884ded](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3884ded))
* fix(manager): render unsettled $var/@ref as plain text in editor ([bf189b3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf189b3))
* fix(manager): row actions/focus follow hover+keyboard, not a sticky state ([915e0cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/915e0cc))
* fix(pool): custom pick steppers + min<=max clamp + null-aware enabled/probability ([2a24f9f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2a24f9f))
* fix(pool): strike + uncheck null row in multi-pick; align pick stepper styling ([7d267d0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d267d0))
* fix(pool): surface multi-pick count in pool summary + instance subtitle ([31fe070](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/31fe070))
* fix(preview): serialize list-valued vars in resolve endpoint ([6703583](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6703583))
* fix(publish): bypass vue-router hash encoder ([63a7cd9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/63a7cd9))
* fix(publish): correct fixed_values shape + strip history sidecar ([191f880](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/191f880))
* fix(publish): forward content_rating + tags + origin slug to embed ([39c0637](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39c0637))
* fix(publish): forward schema_version stamp into the community publish hash ([9a3b5fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a3b5fa))
* fix(publish): normalize bundle children to module-row shape ([47b1535](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/47b1535))
* fix(publish): stamp constraint source/target wildcard names so shares self-describe ([50612dc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/50612dc))
* fix(resolver): carry structured list-vars through static resolve so $mood.0 indexes ([435cdb1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/435cdb1))
* fix(schema): accept natively-supported future versions on install (max-known, not chain head) ([9f7ee12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9f7ee12))
* fix(schema): advertise max-known schema version to publish gate, not chain head ([0cb5706](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0cb5706))
* fix(snapshot): include SP2a pick fields in instance baseline + snapshot type ([d3881c8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3881c8))
* fix(spa): $var autocomplete fires after the $$ multi-pick delimiter ([3a9a09a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3a9a09a))
* fix(spa): block native delete eating the ZWSP pad at field boundaries ([2a1123f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2a1123f))
* fix(spa): chip whitespace-padded block arms + recolour blocks on edit ([51a526e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51a526e)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* fix(spa): colour rich-text syntax on the ComfyUI canvas ([ba455fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ba455fb))
* fix(spa): detect @{uuid} refs nested inside brace/multi blocks ([6097590](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6097590))
* fix(spa): drop leftover background box behind multi-pick blocks ([b9ae37a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9ae37a))
* fix(spa): keep autocomplete open across a $var.K accessor (no Enter newline split) ([d9235c3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d9235c3))
* fix(spa): keep brace-block scaffolding colour through atom-direct edits ([619cd5a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/619cd5a)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* fix(spa): resolve \.K to base name in derivation validation + conflict scan ([bc7ea33](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bc7ea33))
* fix(spa): show rich-text placeholder ghost (zwsp pad defeated :empty) ([e69ceae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e69ceae))
* fix(spa): show the !null ban mark on canvas inner-block ref chips ([55d9ef1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/55d9ef1))
* fix(spa): single-char backspace/delete on raw-typed $var (atomic-delete only committed chips) ([743f8b0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/743f8b0))
* fix(spa): stable $var.K chip round-trip in rich-text editor ([1208222](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1208222))
* fix(ts): pick reach stores bare _uid; partial_reach severity info + stale-comment cleanup ([d826c6b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d826c6b))
* fix(ts): thread cross-node chain to the constraint modal (chain-modules prop) ([71e05b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/71e05b1))
* fix(ui): anchor ref-filter popover snug to its chip + move delete button far-left ([ff0f9dc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff0f9dc))
* fix(ui): clamp ref-filter popover on-screen, show match count, link null toggle to exclude_null ([b23e29e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b23e29e))
* fix(ui): peel !null in nested-ref filter chips on canvas + preview ([c5720e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c5720e7))
* fix(ui): render embed/canvas checkboxes via a scoped WpCheck component ([8000e1b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8000e1b))
* fix(ui): shrink ref-filter action buttons by overriding the fixed .wp-btn height ([c777810](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c777810))
* fix(ui): stack the pool allow-repeats + exclude-null toggles ([8ad2d0c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8ad2d0c))
* fix(ui): style wildcard editor sub-category groups + option-tag picker ([3bcafc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3bcafc9))
* fix(ui): subcategory polish across spa + graph (colours, exclude-null, palette, popover) ([c2d0ef5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c2d0ef5)), closes [1/#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2) [#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6) [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8) [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4) [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [9/#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6)
* fix(validation): parse v2 boolean sub-category ref filters ([5a0e05e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a0e05e))
* fix(validators): match real engine shapes + widget-native bundle children ([8a9ee8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9ee8c))
* refactor(bundles): delete dead remapBundleUuids, keep walkRemap ([a4d1913](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a4d1913))
* refactor(constraint): centralise derivation branch-key format + py/ts corpus parity ([0e9b5cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9b5cf))
* refactor(engine): extract pick_k_unique from multi-pick resolver ([e5f54c6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e5f54c6))
* refactor(import-export): per-row migratePayload contract per spec §2 ([320315c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/320315c))
* refactor(install): applyCollisionDecisions returns rename map ([a5f95cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a5f95cf))
* refactor(manager): harmonize module-list row pills ([87b8c6d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/87b8c6d))
* refactor(ui): convert remaining native checkboxes to the shared wp-check look ([c6baeb8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6baeb8))
* refactor(ui): make .wp-check the one checkbox style + restyle to wildcard-option look ([62d3768](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/62d3768))
</details>
## 🎉 Wildcard Pipeline 2.1.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
+83 -2
View File
@@ -44,8 +44,8 @@ ruff check . # Python lint
- **Toast singleton** — one Vue app mounted to body-level div renders all toasts pushed via `src/components/shared/toast-store.ts`. Every Context node import same store so toasts surface from anywhere. Position mirror ComfyUI native PrimeVue toast anchor (rect-relative to `.graph-canvas-container`).
- **Subgraph traversal** — `collectUpstreamVariables` / `collectUpstreamValues` / `findDownstreamAssemblers` cross subgraph boundaries via `-10` SubgraphInputNode + `-20` SubgraphOutputNode sentinel ids. Walkers expect `app.graph` (root); per-call `buildSubgraphParents` map handle step-out. Use `walkAllNodes(rootGraph)` for graph-wide scans (e.g. pre-run validation).
- **Subgraph badges** — `extension/subgraph-badge.ts` attach `LGraphBadge` (via `window.LGraphBadge`) to any SubgraphNode containing WP nodes. Badge surface worst inner conflict severity with human label (e.g. `missing $foo`, `2 conflicts`). Wire via `nodeCreated` + `loadedGraphNode` hooks — `beforeRegisterNodeDef` does NOT fire for SubgraphNodes.
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — no available target instance DOWNSTREAM, count-aware: N constraints targeting same wildcard need N downstream instances). Engine emits parallel runtime warning `constraint_never_applied` (warning) when a constraint's target never came up.
- **Constraint first-instance** — each constraint module is a one-shot. Fires on the FIRST target wildcard instance encountered downstream at runtime (including via nested `@{uuid}` refs), then is consumed. Engine tracks consumption in `ctx["__wp_consumed_constraints__"]` (set keyed by the owning constraint module's id). To affect multiple target instances, author multiple constraint modules. Position in chain controls which target each constraint claims — no Local/Global / Stack/Override scope modes.
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — the constraint's reach selector covers ZERO reachable downstream instances; per-selector, NOT count-aware — overlapping constraints may freely share an instance). Engine emits runtime warnings `constraint_never_applied` (the reach matched nothing at runtime) and `constraint_partial_reach` (a `next N` / `pick` reach matched fewer instances than requested).
- **Constraint reach (SP3)** — each constraint carries a `target_select` reach selector: `{mode: "first"|"next"|"all"|"pick", count?, picks?}`, default `all`. Every constraint whose reach covers a firing downstream target instance applies, COMBINED — per-pick × per-tag × per-constraint multiply, with `exclude` (factor 0) absorbing. No more one-shot/consumed-set: the engine counts per-constraint downstream hits in `ctx["__wp_constraint_hits__"]`. Reach is downstream-relative (counts from the constraint's own chain position) and cross-node + transitive (resolves `@{uuid}` at any depth). `pick` selects specific instances by `_uid` (direct top-level) or carrier `_uid` + option id (nested). The re-weight math is the pure `engine/modules/_constraint_math.py::combine_constraint_factor`, mirrored in TS at `src/manager/utils/constraint-math.ts` via the shared `tests/fixtures/constraint-corpus.json`. Published payloads using a non-default `target_select` stamp `schema_version = 4` (additive; community catalog v4).
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Pre-commit run `pnpm build:extension && pnpm size`. Raising budget require explicit PR discussion.
- **Coverage gate** — vitest v8 coverage with thresholds (lines/statements 50%, functions 40%, branches 65%). Anchored to current baseline; ratchet up alongside new tests.
- **Caching** — `WP_Context` + `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
@@ -65,6 +65,87 @@ ruff check . # Python lint
- **Reactivity** `extension/reactive.ts:reactiveFromGraph` — wires `onConnectionsChange` chain + 400ms polling fallback + `afterConfigureGraph` re-sync so widgets recompute on graph edits + workflow loads without flashing stale state.
- **Adding new widget**: (1) write SFC under `src/components/<name>/`, (2) add `src/widgets/<name>.ts` exposing `create(node, inputName)` that return `createDomWidgetHost(...)`, (3) wire in `src/main.ts` — either as `getCustomWidgets` entry (for inputs declared with matching widget-type) or via `beforeRegisterNodeDef → onNodeCreated` (for free-floating helpers). Use `import("./widgets/<name>")` so stay out of entry chunk + size gate keep holding.
## Schema versioning + lazy migration machine
Spec: `D:\Desktop\Wildcard-Pipeline-Community\docs\superpowers\specs\2026-06-05-schema-versioning-migration-design.md` (cross-repo lock; read before bumping).
**Three axes** every payload + library row carries:
1. **`schema_version`** (per row, engine column added in migration 014): which payload shape this row's `payload` is in. Single version per payload root — bundles' children inherit the bundle's `schema_version`.
2. **`producer_engine_version`** (per published community version, server-side only): diagnostic. Never load-bearing.
3. **`version_number`** (per community post, server-side): the user-facing 1/2/3 publish revision. Unrelated to schema_version.
**Engine row columns** (migration 014):
- `schema_version INTEGER NOT NULL DEFAULT 1` — current shape this row targets.
- `original_payload_json TEXT` — verbatim local mirror of the payload as it arrived from community (or import). Lazy migrator reads from here, never from the live `payload` field. Lets us re-project from the original if a migrator chain gets fixed retroactively.
- `tolerant_drift_status TEXT` — `none` | `tolerant` | `strict`. Set by parseTolerantAsCurrentShape during install.
- `schema_migrated_at TEXT` — wall-clock stamp of last lazy migration step.
**The machine**:
- **Validator registry** (`src/manager/import-export/validators/`): one Zod validator per known `schema_version`. Strict at `≤ CURRENT_SCHEMA_VERSION`; future versions go through `parseTolerantAsCurrentShape` (additive-strip).
- **`parseTolerantAsCurrentShape(raw, currentVersion)`** (`src/manager/import-export/tolerant-parse.ts`): strips fields the current shape doesn't know, returns the projection that matches `currentVersion`. Used at install when source `schema_version > CURRENT`.
- **`migratePayload(payload, from, to)`** (`src/manager/import-export/migration-machine.ts`): forward-only chain. Each step is a registered function in the migrators map. `from > to` is illegal — no downgrades.
- **`lazy_migrate_row(row, currentVersion, migrators, validators)`** (`engine/db/lazy_migrate.py`): reads `original_payload_json` (or `payload` if absent), walks the migrator chain to `currentVersion`, validates, writes back. Bulk-at-boot runner is eager iteration of this routine.
- **Install decision tree** (`src/manager/import-export/install.ts`): branches on `source_schema_version` vs `CURRENT_SCHEMA_VERSION`:
- `=`: strict validate + insert.
- `<`: migratePayload then strict validate + insert.
- `>` and tolerant-only diff: parseTolerantAsCurrentShape, mark `tolerant_drift_status='tolerant'`, insert.
- `>` and breaking-future diff (per catalog `is_breaking_from_previous` AND-fold across the interval): refuse install with a clear error.
**Server probe**: `engine-export-wrap.ts:ENGINE_SCHEMA_VERSION` is the sister's pinned CURRENT — it ships in the sister bundle, not fetched from server. The host bridge install path reads it from `window.__wpcRuntime.schemaVersion`. Server-first deploy ordering (see community CLAUDE.md) means the community catalog always reflects shapes ≥ sister's CURRENT.
### Bumping `schema_version` (the proper way)
When a payload shape change lands (not a row-column change — those don't bump schema):
1. **Add the new shape's validator.** New file under `src/manager/import-export/validators/v<N>.ts` exporting a Zod schema. Register in `validators/index.ts`.
2. **Add the migrator step.** In `src/manager/import-export/migration-machine.ts`, register `migrators[N-1 → N]: (payload) => ...`. Forward-only. The function is pure — no side effects.
3. **Add fixtures.** `src/manager/__tests__/import-export/fixtures/v<N>/` — at minimum: a `valid-strict.json`, a `from-v<N-1>-migrated.json` round-trip case, and one `breaking-shape.json` if the bump is breaking.
4. **Bump the constant.** `CURRENT_SCHEMA_VERSION` lives in the validator registry. Bump it to `N`. Also bump `ENGINE_SCHEMA_VERSION` in `src/manager/components/engine-export-wrap.ts`.
5. **Write the migration test.** `tests/engine/db/test_lazy_migrate.py` should cover v<N-1>→v<N> round-trip + the breaking-future path if applicable.
6. **Run `pytest tests/engine/ -q && pnpm test` until green.** Sister pre-commit hook (~2 min) also runs lint + typecheck + build + size; expect that wait.
7. **Coordinate the community-side catalog row.** Community web's `schema_catalog` table gets the matching `(version, is_breaking_from_previous, notes)` row + a deploy that lands BEFORE sister's PR merges. See community CLAUDE.md "Bumping the schema" for the server side.
8. **Sister deploys after server.** Once catalog HEAD ≥ sister's CURRENT, sister can safely refuse breaking-future shapes.
### Validator ↔ engine parity (DON'T skip)
The community TS validators in `src/validators/` are a hand-authored
re-implementation of the engine's payload shapes, and re-implementations
drift — fixed_values shipped for weeks as `entries/{variable_name}` when
the engine has always used `values/{id,name,value}`. The guard against
this:
- `scripts/dump_engine_shapes.py` builds one sample payload per subtype,
validates each against that subtype's **own engine `Handler.validate_payload`**
(the authority — raises if not engine-valid), and writes the engine-row
shape to `src/validators/fixtures/engine-parity/*.json`.
- `src/validators/__tests__/engine-parity.test.ts` asserts the **strict**
community validator accepts every one of those fixtures.
**Any time you change an engine module payload shape** (a `*_handler.py`
`validate_payload`, or what the editor stores), run
`python scripts/dump_engine_shapes.py` and commit the regenerated
fixtures. If the sample no longer validates against the handler, the
script fails — update the sample. If the TS validator then rejects the
new shape, the parity test fails — update the validator. Either failure
means the two have diverged; fix it before publish breaks for a user.
### Row-level column changes (no schema_version bump needed)
The most recent example is **migration 015 (`content_rating` column)**. Adding a column to `modules` or `bundles` is just an Alembic-style SQLite migration — no payload-shape change, no validator update, no migrator chain. Procedure:
1. **New file** `engine/db/migrations_sql/<NNN>_<name>.py` mirroring 013/015 shape: `_has_column` gate + `with conn: conn.execute("ALTER TABLE … ADD COLUMN …")` for both `modules` and `bundles` if the field applies to both.
2. **Update `_row_to_module` / `_row_to_bundle`** in `engine/db/repositories.py` — wrap the new column read in `try/except (IndexError, KeyError)` so pre-migration fixtures don't blow up.
3. **Update `ModuleRepository.create/update` + `BundleRepository.create/update`** to accept the new field as a kwarg.
4. **Update `_insert_module` / `_insert_bundle` (+ `_update_module` / `_update_bundle` if needed)** in `engine/importer.py` to pass the field through from the entity dict. Use `COALESCE(?, col)` on the UPDATE side when you want missing-in-content to preserve existing value (NOT overwrite to NULL).
5. **Update `wp_api/modules.py` + `wp_api/bundles.py`**: add the field to `_UPDATABLE_FIELDS` + plumb into the create handlers.
6. **TS types**: extend `ModuleRow` / `BundleRow` + `*CreateInput` / `*UpdateInput` in `src/manager/api/types.ts`.
7. **Bump the head-migration test**: `tests/engine/db/test_migrations.py:test_migrate_records_version` asserts the head version — update to `<NNN>`. Same for the expected-columns set tests.
8. **Write a migration test** at `tests/engine/db/test_migration_<NNN>.py` — column exists, default backfill correct, idempotent rerun, accepts the new values.
If the column also flows through the host-bridge install seam (community origin metadata), extend `InstallOrigin` in `src/manager/import-export/install.ts` AND the embed's `HostInstallOpts.origin` in `web/src/embed/EmbedDetail.vue` (community web repo). Map community-side naming → engine-side naming at the seam if they differ (e.g. `'sfw'` → `'safe'`).
## Anti-patterns (inherited from reference project `ComfyUI-Wildcard-Pipeline`)
- Never use V1 `NODE_CLASS_MAPPINGS` / `EXTENSION_WEB_DIRS`.
+253 -85
View File
@@ -37,6 +37,9 @@ 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
@@ -59,24 +62,112 @@ def _rewrite_var_in_string(s: str, old_name: str, new_name: str) -> str:
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:
"""Replace ``@{wildcard_id[#name]:old_subcat}`` →
``@{wildcard_id[#name]:new_subcat}``. Preserves any optional
``#name`` segment so a subcat rename doesn't strip the wildcard's
cached display name from refs."""
pattern = re.compile(
r"@\{" + re.escape(wildcard_id)
+ r"(#[^#:}@{]*)?:" + re.escape(old_subcat) + r"\}"
"""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 repl(m: re.Match[str]) -> str:
name_seg = m.group(1) or ""
return f"@{{{wildcard_id}{name_seg}:{new_subcat}}}"
return pattern.sub(repl, s)
def _strip_subcat_ref_in_string(
@@ -84,13 +175,38 @@ def _strip_subcat_ref_in_string(
wildcard_id: str,
subcat_name: str,
) -> str:
"""Remove ``@{wildcard_id[#name]:subcat_name}`` occurrences from
*s*. Matches with or without the optional ``#name`` segment."""
pattern = re.compile(
r"@\{" + re.escape(wildcard_id)
+ r"(?:#[^#:}@{]*)?:" + re.escape(subcat_name) + r"\}"
"""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,
)
return pattern.sub("", s)
def _strip_whole_ref_in_string(s: str, wildcard_id: str) -> str:
"""Remove every whole ``@{wildcard_id...}`` token from *s*.
Unlike ``_strip_subcat_ref_in_string`` (which only drops the
``:expr`` filter, collapsing to a bare ``@{uuid}``), this deletes the
entire nested-insertion token — used by ``fix_wildcard_delete`` when
the referenced wildcard itself is gone, so the insertion can't
resolve at all. Surrounding text is preserved; whitespace left at the
seam is collapsed (doubled spaces -> single) and the result trimmed,
so ``"warm @{u} glow"`` -> ``"warm glow"`` rather than
``"warm glow"``.
Reuses the shared 4-segment ref pattern (``_ref_expr_pattern``) so the
``#name`` / ``:expr`` / ``!null`` variants of the token all match.
"""
pattern = _ref_expr_pattern(wildcard_id)
stripped = pattern.sub("", s)
if stripped == s:
return s
# Collapse whitespace left at the seam, then trim edges.
return re.sub(r"[ \t]{2,}", " ", stripped).strip()
def _rewrite_ref_name_in_string(
@@ -124,21 +240,40 @@ def _rewrite_ref_name_in_string(
def fix_wildcard_delete(
conn: sqlite3.Connection,
wildcard_id: str,
cleanup_ids: list[str],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Strip all references to *wildcard_id* from constraints.
"""Opt-in strip of nested ``@{wildcard_id...}`` refs to a deleted wildcard.
* Constraints whose source or target is *wildcard_id* are deleted —
they reference the wildcard by id only, with no fallback shape.
Constraints are never DELETED here. They reference the wildcard by id
only and are healable via the reattach UI — the cascade leaves them
broken so the user can re-point them later, rather than deleting them
(the old behavior, removed). The ONE constraint mutation that does run
is a display-only name stamp: the deleted wildcard's name is cached onto
every dependent constraint's ``source_wildcard_name`` /
``target_wildcard_name`` (the last moment the live name is known), so the
broken-reference banner shows "(was …)" instead of a bare uuid. The
id/matrix/exceptions stay untouched. The stamp is in `touched` (undo) but
not in `diff` (it's not a ref removal). It runs regardless of cleanup_ids.
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.
Nested ``@{wildcard_id...}`` refs (the deleted wildcard inserted
inside ANOTHER wildcard's option value, or a derivation action's
string value) are stripped ONLY for the specific entity ids the
caller lists in *cleanup_ids*. Every entity not in *cleanup_ids* is
left untouched — its broken ref survives and is healed later via a
remap. An empty/absent *cleanup_ids* therefore strips nothing: only
the wildcard row itself is dropped (by the orchestrator), and all
constraints + nested refs survive.
For each entity in *cleanup_ids*:
* ``wildcard`` — every ``@{wildcard_id...}`` token is removed from
each ``options[].value`` string (whole token, surrounding text
preserved).
* ``derivation`` — same strip applied to every string value inside
``rules[].branches[].actions[]`` (mirrors ``_scan_wildcard_delete``).
Bundles are intentionally NOT touched (unchanged): bundle children
are full frozen snapshots, so the bundle keeps resolving with its
captured payload even when the source library row is deleted.
The fixer does NOT delete the wildcard row itself; the orchestrator
owns that mutation.
@@ -148,16 +283,78 @@ def fix_wildcard_delete(
mod_repo = ModuleRepository(conn)
# --- Constraints --------------------------------------------------------
# Stamp the about-to-be-deleted wildcard's name onto every dependent
# constraint's source/target name cache, BEFORE the orchestrator drops the
# row. This is the last moment the live name is known; without it a
# constraint broken by the deletion shows only a bare uuid in the reattach
# banner ("Source wildcard 1e9c0e2c is not in your library"). Display-only:
# the id/matrix/exceptions are untouched and the constraint is never deleted
# (still healable via reattach). Recorded in `touched` for undo but NOT
# emitted as a `diff` entry — it's not a ref removal, so it must not inflate
# affected_count. Runs regardless of cleanup_ids (the empty-cleanup
# early-return is below).
target = mod_repo.get(wildcard_id)
wc_name = target.get("name") if target else None
if wc_name:
for c in mod_repo.list():
if c["type"] != "constraint":
continue
cp = c.get("payload") or {}
new_cp = copy.deepcopy(cp)
stamped = False
if cp.get("source_wildcard_id") == wildcard_id:
if cp.get("source_wildcard_name") != wc_name:
new_cp["source_wildcard_name"] = wc_name
stamped = True
if cp.get("target_wildcard_id") == wildcard_id:
if cp.get("target_wildcard_name") != wc_name:
new_cp["target_wildcard_name"] = wc_name
stamped = True
if stamped:
touched.append(_deepcopy_row(c))
mod_repo.update(c["id"], payload=new_cp)
cleanup = set(cleanup_ids)
if not cleanup:
return touched, diff
for m in mod_repo.list():
if m["type"] != "constraint":
if m["id"] not in cleanup:
continue
t = m["type"]
p = m.get("payload") or {}
if (p.get("source_wildcard_id") == wildcard_id
or p.get("target_wildcard_id") == wildcard_id):
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 = _strip_whole_ref_in_string(v, wildcard_id)
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 = _strip_whole_ref_in_string(v, wildcard_id)
if new_v != v:
action[k] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
mod_repo.delete(m["id"])
diff.append({"entity_id": m["id"], "removed": True})
mod_repo.update(m["id"], payload=new_payload)
diff.append({
"entity_id": m["id"],
"remove_ref": {"kind": "wildcard", "wildcard_id": wildcard_id},
})
return touched, diff
@@ -193,11 +390,12 @@ def fix_subcat_delete(
s for s in declared if s != subcat_name
]
changed = True
# Per-option singular assignment — null out options pointing at
# the deleted subcat (preserves the option, releases the link)
# 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 []:
if opt.get("sub_category") == subcat_name:
opt["sub_category"] = None
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":
@@ -270,10 +468,13 @@ def fix_subcat_rename(
new_name if s == old_name else s for s in declared
]
changed = True
# Per-option singular assignment (`opt.sub_category`, not plural)
# Per-option membership list (`opt.sub_categories`)
for opt in new_payload.get("options") or []:
if opt.get("sub_category") == old_name:
opt["sub_category"] = new_name
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":
@@ -335,9 +536,12 @@ def fix_wildcard_rename_name(
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.
skipped. Bundle children are frozen point-in-time snapshots and are
intentionally NOT rewritten (mirrors ``fix_wildcard_delete``): a rename
leaves the bundle's cached ``#name`` / refs stale until the user
explicitly refreshes the bundle. Otherwise editing ANY module would
silently mutate every bundle holding a snapshot of it — a bundle must
stay a fixed snapshot, not auto-track its source modules.
Skips the renamed wildcard itself — its own name lives on the row,
not in its own option values.
@@ -346,7 +550,6 @@ def fix_wildcard_rename_name(
diff: list[dict[str, Any]] = []
mod_repo = ModuleRepository(conn)
bundle_repo = BundleRepository(conn)
for m in mod_repo.list():
if m["id"] == wildcard_id:
@@ -390,45 +593,10 @@ def fix_wildcard_rename_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,
},
})
# Bundle children are frozen snapshots — intentionally NOT rewritten
# here (see docstring). They drift; the explicit bundle-refresh flow
# re-snapshots them on demand, so a module edit never auto-mutates a
# bundle.
return touched, diff
+46 -10
View File
@@ -53,9 +53,7 @@ def _rename_subcat_target_only(
"""Rename a subcat inside the source wildcard ONLY (no cascade to refs).
Touches both the top-level `payload.sub_categories` declared list and
each option's singular `sub_category` field. The plural-array shape
that the prior version operated on does not exist in real data — see
`engine/modules/wildcard_handler.py:206` for the actual schema.
each option's `sub_categories` membership list (SP1 multi-tag shape).
"""
mod = ModuleRepository(conn)
wc = mod.get(wildcard_id)
@@ -71,8 +69,11 @@ def _rename_subcat_target_only(
new_options = []
for opt in payload.get("options") or []:
new_opt = dict(opt)
if new_opt.get("sub_category") == old_name:
new_opt["sub_category"] = new_name
subs = new_opt.get("sub_categories")
if isinstance(subs, list) and old_name in subs:
new_opt["sub_categories"] = [
new_name if s == old_name else s for s in subs
]
new_options.append(new_opt)
payload["options"] = new_options
@@ -181,7 +182,12 @@ def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, An
key = (kind, action)
if key == ("wildcard", "delete"):
touched_before, diff = fix_wildcard_delete(conn, target_id)
# Opt-in nested-ref cleanup: only entities the caller
# lists get their @{wildcard_id...} token stripped.
# Constraints are never deleted (healed via reattach UI);
# an empty list strips nothing.
cleanup_ids = req.get("cleanup_ids") or []
touched_before, diff = fix_wildcard_delete(conn, target_id, cleanup_ids)
elif key == ("subcategory", "delete"):
touched_before, diff = fix_subcat_delete(
conn, target_id, extra.get("subcat_name", "")
@@ -202,16 +208,46 @@ def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, An
touched_before, diff = fix_option_delete(
conn, extra.get("wildcard_id", ""), target_id,
)
elif action == "delete" and kind in (
"fixed_values",
"combine",
"derivation",
"constraint",
):
# These module types are leaves from a reverse-ref
# perspective: nothing in another row's payload points
# at them by id the way `option.id` is referenced from
# constraint matrices or `wildcard.id` is referenced
# from constraint source/target. A whole-entity delete
# is just a row drop + undo snapshot — no fix step,
# no diff entries.
#
# Caveat: combine.output_var IS referenced by name
# from downstream payloads, but the cascade pipeline
# treats that as a "rename" concern (already wired as
# combine_output_var/rename above). Deleting a combine
# leaves any consumer referencing the output_var with
# an unresolved variable, surfaced separately by the
# integrity/broken-refs scan.
touched_before, diff = [], []
else:
return {
"ok": False,
"error": f"unsupported (kind, action) pair: ({kind}, {action})",
}
# Delete target entity for whole-entity delete ops.
# Subcategory deletes are NOT in this set — the fixer already
# mutated the source wildcard's payload; there is no separate row.
if action == "delete" and kind in ("wildcard", "category", "bundle"):
# Delete target entity for whole-entity delete ops. Module
# subtypes route through ModuleRepository via _delete_target
# (the else-branch of its dispatch).
if action == "delete" and kind in (
"wildcard",
"category",
"bundle",
"fixed_values",
"combine",
"derivation",
"constraint",
):
target_snapshot = _delete_target(conn, kind, target_id)
touched_before.append(target_snapshot)
diff.append({"entity_id": target_id, "removed": True})
+21 -10
View File
@@ -17,14 +17,18 @@ import re
import sqlite3
from typing import Any
from engine.cascade.fixers import collect_tags
from engine.db.repositories import ModuleRepository
# ``@{8hex}``, ``@{8hex#name}``, ``@{8hex:subcat}``, or
# ``@{8hex#name:subcat}`` — uuid + optional cached display name +
# optional subcat filter. Mirrors `engine/syntax/tokenize.py:_REF_RE`
# and the TS twin in `src/manager/cascade/dep-graph.ts`. Name segment
# is informational; this scanner only ever reads the captured uuid.
_REF_REGEX = re.compile(r"@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}")
# 4-segment ref: ``@{8hex [#name] [:expr] [!null]}`` — uuid + optional
# cached display name + optional boolean sub-category filter expression
# + optional exclude-null marker. Mirrors `engine/syntax/tokenize.py:_REF_RE`
# and the TS twin in `src/manager/cascade/dep-graph.ts`. findall groups:
# (uuid, name, expr, null-marker). This scanner reads the uuid; the
# subcat scan also reads the raw expr (see `_scan_subcat`).
_REF_REGEX = re.compile(
r"@\{([0-9a-f]{8})(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
)
# ``$varname`` — leading-letter identifier
_VAR_REGEX = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
@@ -66,7 +70,7 @@ def _scan_wildcard_delete(conn: sqlite3.Connection, wildcard_id: str) -> list[di
v = opt.get("value")
if not isinstance(v, str):
continue
if any(uid == wildcard_id for uid, _name, _sub in _REF_REGEX.findall(v)):
if any(m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)):
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
break
continue
@@ -85,7 +89,7 @@ def _scan_wildcard_delete(conn: sqlite3.Connection, wildcard_id: str) -> list[di
continue
for v in action.values():
if isinstance(v, str) and any(
uid == wildcard_id for uid, _name, _sub in _REF_REGEX.findall(v)
m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)
):
out.append(_ref_entry(
"derivation", m,
@@ -149,8 +153,15 @@ def _scan_subcat(
v = opt.get("value")
if not isinstance(v, str):
continue
for uid, _name, sub in _REF_REGEX.findall(v):
if uid == wildcard_id and sub == subcat_name:
# SP1: `sub` is the raw `:expr` segment (the 4-segment regex
# already split `!null` into the null group). A boolean filter
# like `warm or cold` references multiple tags, so parse it and
# test tag membership — the same set the fixer rewrites
# (engine/cascade/fixers.py). Exact-string equality would miss
# every multi-tag / negated / parenthesized filter and make the
# preview undercount what the apply actually changes.
for uid, _name, sub, _null in _REF_REGEX.findall(v):
if uid == wildcard_id and subcat_name in collect_tags(sub):
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
break
+5 -4
View File
@@ -10,6 +10,8 @@ Each function:
import re
from typing import Protocol
from engine.syntax.types import deref_var_value, split_var_accessor
class _CtxLike(Protocol):
context: dict[str, object]
@@ -80,7 +82,6 @@ def lookup_var(context: _CtxLike, name: str) -> str:
bare = (name or "").lstrip("$").strip()
if not bare:
return ""
value = context.context.get(bare) if context and getattr(context, "context", None) else None
if value is None:
return ""
return value if isinstance(value, str) else str(value)
base, index = split_var_accessor(bare)
raw = context.context.get(base) if context and getattr(context, "context", None) else None
return deref_var_value(raw, index)
+165
View File
@@ -0,0 +1,165 @@
"""Lazy-read routine — the ONE migration runtime.
See community-web spec
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
2026-06-05-schema-versioning-migration-design.md §4 Flow 5. Every row
read (loadModule / loadBundle) goes through this function; the engine
startup bulk runner also calls it for each stale row.
Six branches:
1. No-op (row at CURRENT, original NULL or original.sv ≤ CURRENT).
2. NULL-drop optimization (row.sv == CURRENT, original.sv == CURRENT).
3. Recovery exact catch-up (original.sv ≤ CURRENT, strict-validate
succeeds): promote original into payload, NULL original column.
4. Still-drifted re-project (original.sv > CURRENT): tolerantly strip.
5. Deferred strict-validate failure: mark row 'broken'.
6. Standard forward chain (row.sv < CURRENT, no recovery applicable).
CRITICAL bug-fix from spec §4: the write-back gate is
'base_v < current OR base IS original', NOT just 'base_v < current'.
Otherwise the recovery exact-catch-up case (base_v == current AND base
IS original) silently no-ops and the row stays drifted.
The three dependency-injected dicts:
migrators[(kind, v)] → (payload, ctx) -> next_payload
validators[(kind, v, subtype)] → payload -> bool
tolerant_strip[(kind, target_v, sub)] → payload -> stripped_payload
Subtype lookup: for modules, derived from payload["type"]; for bundles, None.
original_payload_json is INERT — only the initial tolerant install + this
NULL-drop touch it. schema_migrated_at is SEPARATE from updated_at
(structural migration is not a user edit).
"""
from __future__ import annotations
import json
import sqlite3
from collections.abc import Callable
from typing import Any
from engine._utils import now_iso
Migrators = dict[tuple[str, int], Callable[[dict[str, Any], dict], dict]]
Validators = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], bool]]
TolerantStrip = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], dict]]
def lazy_migrate_row(
conn: sqlite3.Connection,
*,
kind: str,
row_id: str,
current_version: int,
migrators: Migrators,
validators: Validators,
tolerant_strip: TolerantStrip | None = None,
) -> None:
"""Apply lazy migration to a single row.
Idempotent: if row is already at CURRENT and original is NULL or
matches CURRENT, returns without writing (or fires NULL-drop only).
"""
table = "modules" if kind == "module" else "bundles"
row = conn.execute(f"SELECT * FROM {table} WHERE id=?", (row_id,)).fetchone()
if row is None:
return
sv = row["schema_version"]
original_raw = row["original_payload_json"]
original = json.loads(original_raw) if original_raw else None
# Narrow original_v to int so the rest of the function's `original is not None`
# checks correctly imply an int. A malformed original (no schema_version, or
# non-int) is demoted to the no-original branch — the row's payload column is
# the only thing we trust.
original_v: int | None = None
if original is not None:
raw_v = original.get("schema_version")
if isinstance(raw_v, int):
original_v = raw_v
else:
original = None
payload = json.loads(row["payload"])
subtype = payload.get("type") if kind == "module" else None
# Branch 1: no-op or NULL-drop
if sv == current_version and (
original is None or (original_v is not None and original_v <= current_version)
):
if original is not None and original_v == sv:
conn.execute(
f"UPDATE {table} SET original_payload_json=NULL, "
f"tolerant_drift_status='none' WHERE id=?",
(row_id,),
)
conn.commit()
return
# Decide base
base_is_original = False
base_v: int
if original is not None and original_v is not None and original_v > sv:
if original_v <= current_version:
# Recovery candidate — deferred strict-validate FIRST
strict = validators.get((kind, original_v, subtype))
if strict is not None and not strict(original):
conn.execute(
f"UPDATE {table} SET tolerant_drift_status='broken', "
f"schema_migrated_at=? WHERE id=?",
(now_iso(), row_id),
)
conn.commit()
return
base = original
base_v = original_v
base_is_original = True
else:
# Still drifted; tolerantly strip original down to current
strip = (tolerant_strip or {}).get((kind, current_version, subtype))
if strip is None:
return
projected = strip(original)
conn.execute(
f"UPDATE {table} SET payload=?, schema_version=?, "
f"tolerant_drift_status='pending', schema_migrated_at=? WHERE id=?",
(
json.dumps(projected),
current_version,
now_iso(),
row_id,
),
)
conn.commit()
return
else:
base = payload
base_v = sv
# Write-back gate (THE BUG FIX): write when base_v < current OR base IS original.
# The recovery exact-catch-up case has zero chain steps (base_v == current_version)
# but still needs the write to promote original into payload + NULL the original
# column. Gating on 'base_v < current_version' alone silently no-ops the recovery.
needs_write = (base_v < current_version) or base_is_original
if not needs_write:
return
new_payload = base
for v in range(base_v, current_version):
migrator = migrators.get((kind, v))
if migrator is None:
return
new_payload = migrator(new_payload, {})
promote_from_original = base_is_original
conn.execute(
f"UPDATE {table} SET payload=?, schema_version=?, schema_migrated_at=?, "
f"original_payload_json=?, tolerant_drift_status=? WHERE id=?",
(
json.dumps(new_payload),
current_version,
now_iso(),
None if promote_from_original else row["original_payload_json"],
"none" if promote_from_original else row["tolerant_drift_status"],
row_id,
),
)
conn.commit()
@@ -0,0 +1,49 @@
"""Migration 013 — community install origin columns.
Adds ``community_post_slug`` and ``community_version_number`` to both
``modules`` and ``bundles``. Stamped at install time by the host-bridge
``install()`` path (via the embed's ``origin`` opt) so the SPA can:
- Render an "installed from community" pill on origin-stamped rows.
- Run an update-availability check that compares
``community_version_number`` against the post's
``latest_version_number`` in the community API.
Both columns are nullable on purpose — locally-authored rows (Create
flow, Import-from-file, starter set) leave them NULL forever.
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
the migration against a DB that already has the columns is a no-op.
The migration runner's existing version table guards against re-runs
in production; this guard exists for tests that explicitly rewind the
``migrations`` table to exercise the migration on a populated DB
(see ``tests/engine/db/test_migration_010.py``).
"""
from __future__ import annotations
import sqlite3
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
cur = conn.execute(f"PRAGMA table_info({table});")
return any(row[1] == column for row in cur.fetchall())
def up(conn: sqlite3.Connection) -> None:
with conn:
if not _has_column(conn, "modules", "community_post_slug"):
conn.execute("ALTER TABLE modules ADD COLUMN community_post_slug TEXT;")
if not _has_column(conn, "modules", "community_version_number"):
conn.execute("ALTER TABLE modules ADD COLUMN community_version_number INTEGER;")
if not _has_column(conn, "bundles", "community_post_slug"):
conn.execute("ALTER TABLE bundles ADD COLUMN community_post_slug TEXT;")
if not _has_column(conn, "bundles", "community_version_number"):
conn.execute("ALTER TABLE bundles ADD COLUMN community_version_number INTEGER;")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_modules_community_slug "
"ON modules(community_post_slug);"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_bundles_community_slug "
"ON bundles(community_post_slug);"
)
@@ -0,0 +1,86 @@
"""Migration 014: payload schema_version + verbatim-local mirror columns.
See the community-web design doc at
docs/superpowers/specs/2026-06-05-schema-versioning-migration-design.md
(in the community-web repo) sections 4 + 5 for the design. The columns:
- schema_version (INTEGER NOT NULL DEFAULT 1): the payload's shape
version. ``1`` is the implicit baseline — every row that existed
before this migration was authored against shape v1, so the DEFAULT
doubles as the backfill.
- original_payload_json (TEXT, nullable): verbatim future-shaped
producer bytes preserved through tolerant installs. NULL means no
preservation was needed (row was installed at-or-below CURRENT, i.e.
no future-shape tolerance was applied).
- tolerant_drift_status (TEXT NOT NULL DEFAULT 'none'): enum tracking
the drift state of the row's payload vs the catalog. ``'none'`` = no
drift; ``'pending'`` = drifted (tolerant-installed from a future
shape and not yet reconciled); ``'broken'`` = deferred strict-validate
failed.
- schema_migrated_at (TEXT, nullable): timestamp of the last structural
migration on the row. Intentionally SEPARATE from ``updated_at`` —
structural migration is the engine moving the payload between shape
versions, not a user edit, so it must not dirty user-edit state.
Backfill: every pre-existing row stamps ``schema_version=1`` and
``tolerant_drift_status='none'`` implicitly via the DEFAULT on the
NOT NULL columns — SQLite materializes the default into existing rows
when ADD COLUMN runs.
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
the migration against a DB that already has the columns is a no-op.
The migration runner's existing version table guards against re-runs
in production; this guard exists for tests that explicitly rewind the
``migrations`` table to exercise the migration on a populated DB
(see ``tests/engine/db/test_migration_010.py``).
"""
from __future__ import annotations
import sqlite3
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
cur = conn.execute(f"PRAGMA table_info({table});")
return any(row[1] == column for row in cur.fetchall())
def up(conn: sqlite3.Connection) -> None:
with conn:
# modules
if not _has_column(conn, "modules", "schema_version"):
conn.execute(
"ALTER TABLE modules ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
)
if not _has_column(conn, "modules", "original_payload_json"):
conn.execute("ALTER TABLE modules ADD COLUMN original_payload_json TEXT;")
if not _has_column(conn, "modules", "tolerant_drift_status"):
conn.execute(
"ALTER TABLE modules ADD COLUMN tolerant_drift_status "
"TEXT NOT NULL DEFAULT 'none';"
)
if not _has_column(conn, "modules", "schema_migrated_at"):
conn.execute("ALTER TABLE modules ADD COLUMN schema_migrated_at TEXT;")
# bundles
if not _has_column(conn, "bundles", "schema_version"):
conn.execute(
"ALTER TABLE bundles ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
)
if not _has_column(conn, "bundles", "original_payload_json"):
conn.execute("ALTER TABLE bundles ADD COLUMN original_payload_json TEXT;")
if not _has_column(conn, "bundles", "tolerant_drift_status"):
conn.execute(
"ALTER TABLE bundles ADD COLUMN tolerant_drift_status "
"TEXT NOT NULL DEFAULT 'none';"
)
if not _has_column(conn, "bundles", "schema_migrated_at"):
conn.execute("ALTER TABLE bundles ADD COLUMN schema_migrated_at TEXT;")
# Bulk run: at the time 014 ships, CURRENT_SCHEMA_VERSION is still 1, so
# every existing row is already at CURRENT and the bulk is a no-op pass.
# We invoke it anyway to exercise + validate the wiring on first run.
# FUTURE: when a bump migration 015 ships, it ALSO calls run_bulk_lazy
# with the new CURRENT and the bump's migrators.
run_bulk_lazy(conn, current_version=1, migrators={}, validators={}, tolerant_strip={})
@@ -0,0 +1,44 @@
"""Migration 015 -- content_rating column on library rows.
Adds ``content_rating`` (TEXT, default 'safe') to both ``modules`` and
``bundles``. Stamped by:
- The IdentityCard's NSFW toggle in every editor (user-controlled
flag the author sets on their own row).
- The community install bridge when ``opts.origin.content_rating`` is
supplied -- NSFW community posts auto-stamp the row at install
time, so the SPA can render the ``18+`` pill without an extra
fetch.
Default 'safe' means rows that pre-date this migration (every
locally-authored row prior to v2.3) end up marked safe. Toggle to
'nsfw' if needed. No CHECK constraint at the DB level (SQLite ALTER
TABLE does not support adding CHECK); validation happens at the
application layer (``set_content_rating``).
Idempotent: each ALTER is gated on ``PRAGMA table_info``. The
migration runner's version table is the primary re-run guard; the
column check exists for test fixtures that rewind ``migrations``.
"""
from __future__ import annotations
import sqlite3
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
cur = conn.execute(f"PRAGMA table_info({table});")
return any(row[1] == column for row in cur.fetchall())
def up(conn: sqlite3.Connection) -> None:
with conn:
if not _has_column(conn, "modules", "content_rating"):
conn.execute(
"ALTER TABLE modules ADD COLUMN content_rating TEXT "
"NOT NULL DEFAULT 'safe';"
)
if not _has_column(conn, "bundles", "content_rating"):
conn.execute(
"ALTER TABLE bundles ADD COLUMN content_rating TEXT "
"NOT NULL DEFAULT 'safe';"
)
@@ -0,0 +1,41 @@
"""Migration 016: wildcard multi-sub-category v2 (SP1).
Marches every stale row from schema_version 1 -> 2 by eager iteration of
the lazy-read routine (see `_014_helpers.run_bulk_lazy`), using the SP1
per-row migrators. The transform:
* option `sub_category: "x"` -> `sub_categories: ["x"]`;
* sub-category names slugified (whitespace / grammar chars -> `_`),
reserved words suffixed, de-duped, cascaded to options + groups;
* nested `@{uuid:a,b,null}` refs -> `@{uuid:a or b!null}`.
See engine/migrations/v1_to_v2.py for the shared transform + the envelope
mirror used by import/export, and CLAUDE.md "Schema versioning".
Idempotent: rows already at schema_version 2 are not stale, so a re-run
(or a run on a fresh DB with no v1 rows) is a no-op. validators /
tolerant_strip are empty — the forward 1->2 chain needs neither (there is
no future-shaped `original_payload_json` to recover at this bump).
"""
from __future__ import annotations
import sqlite3
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
from engine.migrations.v1_to_v2 import (
migrate_bundle_v1_to_v2,
migrate_module_v1_to_v2,
)
def up(conn: sqlite3.Connection) -> None:
run_bulk_lazy(
conn,
current_version=2,
migrators={
("module", 1): migrate_module_v1_to_v2,
("bundle", 1): migrate_bundle_v1_to_v2,
},
validators={},
tolerant_strip={},
)
+73
View File
@@ -0,0 +1,73 @@
"""Helpers for migration 014 — the bulk runner used at startup + at
future payload-bump migrations.
See community-web spec
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
2026-06-05-schema-versioning-migration-design.md §4 Flow 4: bulk is
EAGER ITERATION of the lazy-read routine, NOT a parallel forward-chain
implementation. This eliminates the transient-wrong window where bulk
could write fabricated defaults over recoverable data preserved in
original_payload_json.
"""
from __future__ import annotations
import sqlite3
from engine.db.lazy_migrate import lazy_migrate_row
STALE_PREDICATE = (
"schema_version < ? "
"OR (original_payload_json IS NOT NULL AND "
" json_extract(original_payload_json, '$.schema_version') > schema_version)"
)
def run_bulk_lazy(
conn: sqlite3.Connection,
*,
current_version: int,
migrators,
validators,
tolerant_strip,
) -> int:
"""Eager iteration of lazy_migrate_row over every stale row.
The stale predicate catches BOTH 'sv < current' AND 'tolerantly-
installed row whose original now outranks the projection'. The
second clause handles rows where the extension has caught up after
a previous tolerant install.
Returns count of rows that failed to migrate (caller surfaces a
dashboard banner when > 0).
"""
failed = 0
for table, kind in [("modules", "module"), ("bundles", "bundle")]:
ids = [
row["id"]
for row in conn.execute(
f"SELECT id FROM {table} WHERE {STALE_PREDICATE}",
(current_version,),
).fetchall()
]
for row_id in ids:
try:
lazy_migrate_row(
conn,
kind=kind,
row_id=row_id,
current_version=current_version,
migrators=migrators,
validators=validators,
tolerant_strip=tolerant_strip,
)
except Exception: # noqa: BLE001 — defensive sweep
failed += 1
continue
# After lazy_migrate_row: if the row's schema_version is still
# below current_version, count as failed.
after = conn.execute(
f"SELECT schema_version FROM {table} WHERE id=?", (row_id,)
).fetchone()
if after and after["schema_version"] < current_version:
failed += 1
return failed
+131 -15
View File
@@ -19,6 +19,21 @@ from engine.modules.snapshot import payload_hash
_VALID_TYPES: frozenset[str] = frozenset({
"wildcard", "fixed_values", "combine", "derivation", "constraint",
})
# Free-text adult-content flag on every library row (migration 015).
# Stamped by the IdentityCard's NSFW toggle or auto-stamped at install
# time when a community post carried `content_rating='nsfw'`. No DB-level
# CHECK constraint (SQLite ALTER limit); validate at the repository.
_VALID_CONTENT_RATINGS: frozenset[str] = frozenset({"safe", "nsfw"})
def _validate_content_rating(rating: str) -> str:
if rating not in _VALID_CONTENT_RATINGS:
raise ValueError(
f"content_rating must be one of {sorted(_VALID_CONTENT_RATINGS)}, "
f"got {rating!r}"
)
return rating
# Module ids are 8-hex short uuids — same shape the tokenizer's
# `@{8hex}` ref token captures and the engine catalog keys by. Slug
# prefixes (e.g. `wc_outfit_a1b2c3d4`) were removed in migration 004
@@ -55,6 +70,24 @@ class ModuleNotFound(LookupError):
def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
payload = json.loads(row["payload"])
# Community origin columns (migration 013) — NULL for any row that
# didn't come from a community install. Surfaced verbatim so the
# SPA can render an "installed from community" badge + drive
# update-available checks.
try:
community_post_slug = row["community_post_slug"]
community_version_number = row["community_version_number"]
except (IndexError, KeyError):
# Defensive: a connection opened against a DB that hasn't run
# migration 013 yet (e.g. mid-rollout, test fixtures) won't
# have these columns. Treat absence as NULL.
community_post_slug = None
community_version_number = None
try:
content_rating = row["content_rating"]
except (IndexError, KeyError):
# Pre-015 fixture: column absent, treat as 'safe'.
content_rating = "safe"
return {
"id": row["id"],
"type": row["type"],
@@ -69,6 +102,9 @@ def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"community_post_slug": community_post_slug,
"community_version_number": community_version_number,
"content_rating": content_rating,
}
@@ -125,13 +161,14 @@ class ModuleRepository:
payload: dict[str, Any],
is_favorite: bool = False,
id: str | None = None,
content_rating: str = "safe",
) -> dict[str, Any]:
"""Insert a new module row.
``id`` is normally generated; pass an explicit id to import
a workflow-resident snapshot back into the library at the
SAME uuid (so a freshly-saved row immediately matches the
workflow's existing references — no broken `@{uuid}` refs).
workflow's existing references -- no broken `@{uuid}` refs).
Validates the supplied id is 8-hex to prevent the workflow
from injecting arbitrary primary-key shapes.
"""
@@ -139,6 +176,7 @@ class ModuleRepository:
raise ValueError(
f"unknown module type {type!r}; expected one of {sorted(_VALID_TYPES)}"
)
_validate_content_rating(content_rating)
if type == "wildcard":
payload = self._backfill_option_ids(payload)
if id is None:
@@ -163,12 +201,13 @@ class ModuleRepository:
self._conn.execute(
"INSERT INTO modules("
"id, type, name, description, category_id, tags, "
"is_favorite, payload, snapshot_fingerprint, version, created_at, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
"is_favorite, payload, snapshot_fingerprint, version, "
"created_at, updated_at, content_rating"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?);",
(
mid, type, name, description, category_id,
json.dumps(tags), int(is_favorite),
json.dumps(payload), fp, now, now,
json.dumps(payload), fp, now, now, content_rating,
),
)
return self.get(mid)
@@ -226,6 +265,7 @@ class ModuleRepository:
tags: list[str] | _Unset = _UNSET,
payload: dict[str, Any] | _Unset = _UNSET,
is_favorite: bool | _Unset = _UNSET,
content_rating: str | _Unset = _UNSET,
) -> dict[str, Any]:
existing = self.get(module_id)
# Backfill missing option ids when caller writes a fresh wildcard payload.
@@ -235,6 +275,8 @@ class ModuleRepository:
and isinstance(payload, dict)
):
payload = self._backfill_option_ids(payload)
if not isinstance(content_rating, _Unset):
_validate_content_rating(content_rating)
new = {
"name": existing["name"] if isinstance(name, _Unset) else name,
"description": (
@@ -248,6 +290,11 @@ class ModuleRepository:
"is_favorite": (
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
),
"content_rating": (
existing["content_rating"]
if isinstance(content_rating, _Unset)
else content_rating
),
}
new_payload_hash = payload_hash(new["payload"])
new_fp = module_fingerprint({
@@ -263,16 +310,44 @@ class ModuleRepository:
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
"version = version + 1, updated_at = ? "
"version = version + 1, updated_at = ?, content_rating = ? "
"WHERE id = ?;",
(
new["name"], new["description"], new["category_id"],
json.dumps(new["tags"]), int(new["is_favorite"]),
json.dumps(new["payload"]), new_fp, now, module_id,
json.dumps(new["payload"]), new_fp, now,
new["content_rating"], module_id,
),
)
return self.get(module_id)
def set_community_origin(
self, module_id: str, *, post_slug: str, version_number: int,
) -> dict[str, Any]:
"""Stamp community origin on an existing row WITHOUT a version
bump or fingerprint change.
Used by the publish-writeback path (host bridge
``markPublished``): after a row is published to community, the
local row gets linked to the resulting post so the SPA renders
the community pill, the update-available checker tracks it, and
a future re-publish detects "this is my own post" (version bump
vs fork). Origin metadata is NOT runtime-affecting, so unlike
``update`` this neither bumps ``version`` nor recomputes the
snapshot fingerprint -- a published row stays byte-identical to
what consumers already installed.
"""
with self._conn:
cur = self._conn.execute(
"UPDATE modules SET "
"community_post_slug = ?, community_version_number = ? "
"WHERE id = ?;",
(post_slug, version_number, module_id),
)
if cur.rowcount == 0:
raise ModuleNotFound(module_id)
return self.get(module_id)
def delete(self, module_id: str) -> None:
with self._conn:
cur = self._conn.execute("DELETE FROM modules WHERE id = ?;", (module_id,))
@@ -481,6 +556,16 @@ def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
intentionally frozen — the saved hash is the source of truth for
insert-time `inserted_at_hash` capture + library-drift detection.
"""
try:
community_post_slug = row["community_post_slug"]
community_version_number = row["community_version_number"]
except (IndexError, KeyError):
community_post_slug = None
community_version_number = None
try:
content_rating = row["content_rating"]
except (IndexError, KeyError):
content_rating = "safe"
return {
"id": row["id"],
"name": row["name"],
@@ -494,6 +579,9 @@ def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"community_post_slug": community_post_slug,
"community_version_number": community_version_number,
"content_rating": content_rating,
}
@@ -529,7 +617,9 @@ class BundleRepository:
children: list[dict[str, Any]] | None = None,
is_favorite: bool = False,
id: str | None = None,
content_rating: str = "safe",
) -> dict[str, Any]:
_validate_content_rating(content_rating)
if id is None:
bid = self._gen_id()
else:
@@ -541,7 +631,7 @@ class BundleRepository:
)
bid = id
children_blob = list(children) if children else []
# `payload_hash` is computed off the children blob — the only
# `payload_hash` is computed off the children blob -- the only
# field whose change should signal "library has a newer
# version" to inserted instances.
ph = payload_hash({"children": children_blob})
@@ -551,12 +641,12 @@ class BundleRepository:
"INSERT INTO bundles("
"id, name, description, color, category_id, tags, "
"is_favorite, children, payload_hash, version, "
"created_at, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
"created_at, updated_at, content_rating"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?);",
(
bid, name, description, color, category_id,
json.dumps(tags or []), int(is_favorite),
json.dumps(children_blob), ph, now, now,
json.dumps(children_blob), ph, now, now, content_rating,
),
)
return self.get(bid)
@@ -580,8 +670,11 @@ class BundleRepository:
tags: list[str] | _Unset = _UNSET,
children: list[dict[str, Any]] | _Unset = _UNSET,
is_favorite: bool | _Unset = _UNSET,
content_rating: str | _Unset = _UNSET,
) -> dict[str, Any]:
existing = self.get(bundle_id)
if not isinstance(content_rating, _Unset):
_validate_content_rating(content_rating)
new = {
"name": existing["name"] if isinstance(name, _Unset) else name,
"description": (
@@ -598,11 +691,16 @@ class BundleRepository:
"is_favorite": (
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
),
"content_rating": (
existing["content_rating"]
if isinstance(content_rating, _Unset)
else content_rating
),
}
# Recompute payload_hash whenever children change. Other field
# edits (rename, recolor, retag) don't affect runtime behavior
# so they don't bump the hash — inserted instances stay clean
# on a pure-cosmetic library update.
# edits (rename, recolor, retag, NSFW toggle) don't affect runtime
# behavior so they don't bump the hash -- inserted instances stay
# clean on a pure-cosmetic library update.
ph = (
existing["payload_hash"]
if isinstance(children, _Unset)
@@ -614,17 +712,35 @@ class BundleRepository:
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, category_id = ?, "
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
"version = version + 1, updated_at = ? "
"version = version + 1, updated_at = ?, content_rating = ? "
"WHERE id = ?;",
(
new["name"], new["description"], new["color"],
new["category_id"], json.dumps(new["tags"]),
int(new["is_favorite"]),
json.dumps(new["children"]), ph, now, bundle_id,
json.dumps(new["children"]), ph, now,
new["content_rating"], bundle_id,
),
)
return self.get(bundle_id)
def set_community_origin(
self, bundle_id: str, *, post_slug: str, version_number: int,
) -> dict[str, Any]:
"""Stamp community origin on a bundle row WITHOUT a version bump
or payload-hash change. See ModuleRepository.set_community_origin
for the rationale -- origin metadata is not runtime-affecting."""
with self._conn:
cur = self._conn.execute(
"UPDATE bundles SET "
"community_post_slug = ?, community_version_number = ? "
"WHERE id = ?;",
(post_slug, version_number, bundle_id),
)
if cur.rowcount == 0:
raise BundleNotFound(bundle_id)
return self.get(bundle_id)
def delete(self, bundle_id: str) -> None:
with self._conn:
cur = self._conn.execute(
+119 -35
View File
@@ -154,8 +154,10 @@ def _insert_module(
"INSERT INTO modules("
"id, type, name, description, category_id, tags, "
"is_favorite, payload, snapshot_fingerprint, version, "
"created_at, updated_at"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
"created_at, updated_at, "
"community_post_slug, community_version_number, "
"content_rating"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
mid, kind, entity["name"], entity.get("description", ""),
entity.get("category_id"),
@@ -166,6 +168,17 @@ def _insert_module(
entity.get("version", 1),
entity.get("created_at", now),
entity.get("updated_at", now),
# Community origin -- only set when the install bridge stamps
# them on the entity (see manager/main.ts snapshotLibrary +
# the embed's install call). Local Create/Import-from-file
# paths leave these NULL so the row stays "locally authored".
entity.get("community_post_slug"),
entity.get("community_version_number"),
# content_rating defaults to 'safe' when the entity carries
# no value -- migration 015 stamps the same default. Community
# install passes 'nsfw' through via the entity when the post
# carries content_rating='nsfw'.
entity.get("content_rating", "safe"),
),
)
return mid
@@ -193,8 +206,10 @@ def _insert_bundle(
"INSERT INTO bundles("
"id, name, description, color, category_id, tags, "
"is_favorite, children, payload_hash, version, "
"created_at, updated_at"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
"created_at, updated_at, "
"community_post_slug, community_version_number, "
"content_rating"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
bid, entity["name"], entity.get("description", ""),
entity.get("color"),
@@ -206,6 +221,11 @@ def _insert_bundle(
entity.get("version", 1),
entity.get("created_at", now),
entity.get("updated_at", now),
# Community origin -- see _insert_module comment above.
entity.get("community_post_slug"),
entity.get("community_version_number"),
# content_rating -- see _insert_module comment above.
entity.get("content_rating", "safe"),
),
)
return bid
@@ -219,50 +239,114 @@ def _update_module(
Preserves `mid` and the row's `type` (the kind dispatch already
routed us here so type is implied). Bumps `version` and stamps
`updated_at` like `ModuleRepository.update`.
Community origin (migration 013) is rewritten ONLY when the new
content explicitly carries it — a community Update-in-place
install stamps both fields on the entity, while a vanilla Import
tab replace leaves them absent and the row keeps its prior
community_* values (or NULL for locally-authored rows).
"""
_require_entity_fields(kind, "replace", content, ("name",))
now = now_iso()
fp = _module_fp_for_entity({**content, "type": kind})
conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
"version = version + 1, updated_at = ? "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(content.get("payload") or {}),
fp, now, mid,
),
)
if "community_post_slug" in content or "community_version_number" in content:
# `content_rating` uses COALESCE so missing key preserves the
# existing value -- community-update installs that only bump
# the version don't accidentally wipe a user-set NSFW flag.
conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
"version = version + 1, updated_at = ?, "
"community_post_slug = ?, community_version_number = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(content.get("payload") or {}),
fp, now,
content.get("community_post_slug"),
content.get("community_version_number"),
content.get("content_rating"),
mid,
),
)
else:
conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
"version = version + 1, updated_at = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(content.get("payload") or {}),
fp, now,
content.get("content_rating"),
mid,
),
)
def _update_bundle(
conn: sqlite3.Connection, bid: str, content: dict[str, Any],
) -> None:
"""UPDATE the bundles row identified by `bid` to mirror `content`."""
"""UPDATE the bundles row identified by `bid` to mirror `content`.
See _update_module for the community_* rewrite semantics.
"""
_require_entity_fields("bundle", "replace", content, ("name",))
children_blob = list(content.get("children") or [])
ph = payload_hash({"children": children_blob})
now = now_iso()
conn.execute(
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, category_id = ?, "
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
"version = version + 1, updated_at = ? "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("color"),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(children_blob), ph, now, bid,
),
)
if "community_post_slug" in content or "community_version_number" in content:
conn.execute(
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, category_id = ?, "
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
"version = version + 1, updated_at = ?, "
"community_post_slug = ?, community_version_number = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("color"),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(children_blob), ph, now,
content.get("community_post_slug"),
content.get("community_version_number"),
content.get("content_rating"),
bid,
),
)
else:
conn.execute(
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, category_id = ?, "
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
"version = version + 1, updated_at = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("color"),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(children_blob), ph, now,
content.get("content_rating"),
bid,
),
)
def _module_exists(conn: sqlite3.Connection, mid: str) -> bool:
+24 -3
View File
@@ -10,11 +10,26 @@ from __future__ import annotations
from typing import Any
from engine.migrations.v0_to_v1 import migrate_v0_to_v1
from engine.migrations.v1_to_v2 import migrate_v1_to_v2
CURRENT_SCHEMA_VERSION = 1
CURRENT_SCHEMA_VERSION = 2
# Highest schema version this runtime can natively READ + WRITE — distinct
# from CURRENT_SCHEMA_VERSION (the migration-chain head, which stays 2 because
# v2->v3/v3->v4 are no-ops). v3 is the SP2b text-grammar bump (shape-identical
# to v2) and v4 is the additive `target_select` constraint reach; both are
# handled natively, so a payload at CURRENT < v <= MAX_KNOWN installs AS-IS
# with no migration. Mirror of TS `MAX_KNOWN_SCHEMA_VERSION` in
# `src/manager/import-export/migrations.ts`.
#
# MAINTENANCE CONTRACT: bump this whenever the TS `schemaVersionForPayload()`
# learns to stamp a new (higher) version — otherwise this commit-side
# re-validate will reject the very shapes the runtime just learned to produce.
MAX_KNOWN_SCHEMA_VERSION = 4
_CHAIN = {
0: migrate_v0_to_v1,
1: migrate_v1_to_v2,
}
@@ -32,10 +47,16 @@ def migrate_payload(payload: dict[str, Any]) -> dict[str, Any]:
version = payload.get("schema_version")
if not isinstance(version, int):
return {"ok": False, "reason": "payload missing schema_version field"}
if version > CURRENT_SCHEMA_VERSION:
# Reject threshold reads MAX_KNOWN (highest natively-supported version),
# NOT CURRENT (the migration-chain head). A payload at
# CURRENT < v <= MAX_KNOWN is shape-compatible and handled natively, so it
# passes through AS-IS — the while-loop below is bound by CURRENT, so there
# is nothing to migrate and schema_version is preserved. Only > MAX_KNOWN
# rejects. Mirror of TS `migrateImportEnvelope`.
if version > MAX_KNOWN_SCHEMA_VERSION:
return {
"ok": False,
"reason": f"future schema version {version} (current: {CURRENT_SCHEMA_VERSION})",
"reason": f"future schema version {version} (max known: {MAX_KNOWN_SCHEMA_VERSION})",
}
current = {
"schema_version": version,
+199
View File
@@ -0,0 +1,199 @@
"""v1 -> v2 payload migration (SP1 multi-tag + boolean sub-category filter).
Transforms, all idempotent:
* option `sub_category: "x"` -> `sub_categories: ["x"]` (None/absent -> []);
* registry + option + tag_group sub-category names slugified to a single
safe token (whitespace / boolean-grammar / ref chars -> `_`), reserved
words suffixed (`or` -> `or_1`), de-duped — cascaded so an option/group
membership tracks its renamed registry entry;
* instance `category_filter: [...]` -> a boolean expression string
(`" or ".join`) + a separate `exclude_null` flag (the old `"null"`
list-entry);
* nested `@{uuid#name:a,b,null}` refs -> `@{uuid#name:a or b!null}`
(comma list -> `or`, `null` keyword -> trailing `!null` marker).
Two entry shapes share one core (`_deep_migrate`):
* `migrate_v1_to_v2(envelope)` — the import/export multi-row envelope
(`wildcards[]`/`bundles[]`/...); registered in the version `_CHAIN`.
* `migrate_module_v1_to_v2` / `migrate_bundle_v1_to_v2` — per-row payload
migrators for the lazy/bulk DB path (`engine/db/lazy_migrate.py`); the
row's `payload` column is the inner module payload (`{options, ...}`,
no `type` field — wildcard-ness is detected structurally).
Mirror: src/manager/import-export/migrations.ts.
"""
from __future__ import annotations
import re
from typing import Any
from engine.syntax.subcat_filter import RESERVED
# Chars a v2 sub-category name may not contain (mirrors subcat_filter
# validate_subcat_name): whitespace + the boolean-grammar + ref delimiters.
_BAD = re.compile(r"[\s()!,#:}@{$]+")
# Broad ref matcher (v1 OR v2 form) so v1 comma/null lists get rewritten
# while v2 refs pass through untouched. Groups: uuid, `#name` seg, `:tail`.
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(#[^:}]*)?(:[^}]*)?\}")
def _slug(name: str) -> str:
return _BAD.sub("_", name).strip("_") or "_"
def _name_map(registry: list[str]) -> dict[str, str]:
"""old sub-category name -> sanitized, reserved-safe, unique name."""
out: dict[str, str] = {}
used: set[str] = set()
for raw in registry:
s = _slug(raw)
if s.lower() in RESERVED:
s = f"{s}_1"
base, n = s, 1
while s in used:
n += 1
s = f"{base}_{n}"
used.add(s)
out[raw] = s
return out
def _rewrite_ref(m: re.Match[str]) -> str:
uuid = m.group(1)
name_seg = m.group(2) or ""
tail = m.group(3)
if tail is None:
return m.group(0) # bare `@{uuid}` / `@{uuid#name}` — nothing to do
lst = tail[1:] # strip the leading ':'
has_comma = "," in lst
parts = [x.strip() for x in lst.split(",")]
has_null = "null" in parts
if not has_comma and not has_null:
# Single tag OR an already-v2 boolean expression — leave as-is so
# the rewrite is idempotent and never corrupts a v2 ref.
return m.group(0)
# Slug each tag so a cross-wildcard ref tracks the target's renamed
# registry entry (deterministic — same _slug the target applies).
tags = [_slug(p) for p in parts if p and p != "null"]
expr = " or ".join(tags)
out = "@{" + uuid + name_seg
if expr:
out += ":" + expr
if has_null:
out += "!null"
return out + "}"
def _rewrite_refs(s: str) -> str:
return _REF_RE.sub(_rewrite_ref, s)
def _is_wildcard_payload(p: Any) -> bool:
"""A wildcard payload is the only shape carrying a sub-category model:
an `options` list plus either a `sub_categories` registry or an option
bearing the legacy singular `sub_category` field."""
if not isinstance(p, dict) or not isinstance(p.get("options"), list):
return False
if "sub_categories" in p:
return True
return any(isinstance(o, dict) and "sub_category" in o for o in p["options"])
def _apply_wildcard_payload(
p: dict[str, Any], nmap: dict[str, str] | None = None
) -> dict[str, Any]:
reg = [s for s in p.get("sub_categories", []) if isinstance(s, str)]
if nmap is None:
nmap = _name_map(reg)
out = {**p, "sub_categories": [nmap[s] for s in reg]}
new_opts: list[Any] = []
for o in p.get("options", []):
if not isinstance(o, dict):
new_opts.append(o)
continue
o = dict(o)
if isinstance(o.get("sub_categories"), list): # already-v2 / idempotent
o["sub_categories"] = [nmap.get(t, _slug(t)) for t in o["sub_categories"]]
else:
sc = o.pop("sub_category", None)
o["sub_categories"] = (
[nmap.get(sc, _slug(sc))] if isinstance(sc, str) and sc else []
)
if isinstance(o.get("value"), str):
o["value"] = _rewrite_refs(o["value"])
new_opts.append(o)
out["options"] = new_opts
if isinstance(p.get("tag_groups"), dict):
out["tag_groups"] = {
g: [nmap.get(m, _slug(m)) for m in mem if isinstance(m, str)]
for g, mem in p["tag_groups"].items()
}
return out
def _migrate_instance(inst: dict[str, Any], nmap: dict[str, str]) -> dict[str, Any]:
cf = inst.get("category_filter")
if not isinstance(cf, list):
return inst
out = dict(inst)
has_null = "null" in cf
tags = [nmap.get(c, _slug(c)) for c in cf if isinstance(c, str) and c and c != "null"]
out["category_filter"] = " or ".join(tags)
out["exclude_null"] = bool(out.get("exclude_null")) or has_null
return out
def _deep_migrate(obj: Any) -> Any:
"""Recursive transform shared by the per-row module/bundle migrators:
migrate any wildcard-shaped dict in place, rewrite `@{}` refs in every
string, recurse into containers. Idempotent."""
if isinstance(obj, dict):
if _is_wildcard_payload(obj):
return _apply_wildcard_payload(obj)
return {k: _deep_migrate(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_deep_migrate(x) for x in obj]
if isinstance(obj, str):
return _rewrite_refs(obj)
return obj
def migrate_module_v1_to_v2(payload: dict[str, Any], ctx: Any = None) -> dict[str, Any]:
"""Per-row module-payload migrator for the lazy/bulk DB path."""
return _deep_migrate(payload)
def migrate_bundle_v1_to_v2(payload: dict[str, Any], ctx: Any = None) -> dict[str, Any]:
"""Per-row bundle-payload migrator — recurses into frozen child snapshots."""
return _deep_migrate(payload)
def _migrate_wildcard_entry(w: dict[str, Any]) -> dict[str, Any]:
p = w.get("payload")
nmap: dict[str, str] = {}
out = dict(w)
if isinstance(p, dict):
reg = [s for s in p.get("sub_categories", []) if isinstance(s, str)]
nmap = _name_map(reg)
out["payload"] = _apply_wildcard_payload(p, nmap)
inst = w.get("instance")
if isinstance(inst, dict):
out["instance"] = _migrate_instance(inst, nmap)
return out
def migrate_v1_to_v2(payload: dict[str, Any]) -> dict[str, Any]:
"""Envelope migrator (mirrors the TS chain step). Maps each wildcard
entry structurally (+ its instance), deep-migrates the other entity
arrays (ref rewrite + any nested wildcard snapshots), bumps to v2."""
out = dict(payload)
out["wildcards"] = [
_migrate_wildcard_entry(w) if isinstance(w, dict) else w
for w in payload.get("wildcards", [])
]
for key in ("bundles", "fixed_values", "combines", "derivations",
"constraints", "categories", "templates"):
if isinstance(payload.get(key), list):
out[key] = [_deep_migrate(e) for e in payload[key]]
out["schema_version"] = 2
return out
+57 -18
View File
@@ -31,15 +31,30 @@ class _RuntimeResolveContext:
# bucket is empty/None.
_constraints: list[dict[str, Any]] = field(default_factory=list)
_picks: dict[str, dict[str, Any]] = field(default_factory=dict)
# First-instance consume bookkeeping (2026-05-24 spec). Threaded
# by reference so mutations inside apply_constraints_for_target
# propagate back to the pipeline's ctx bucket and to subsequent
# target-instance resolves later in the same chain.
_consumed: set[str] = field(default_factory=set)
# SP3 reach-selector hit counter (per-constraint, keyed by
# `__constraint_module_id__`). Threaded by reference so increments
# inside apply_constraints_for_target on the nested-ref path share
# one counter with the chain-level direct path + the pipeline's ctx
# bucket — first/next coverage spans both surfaces.
_hits: dict[str, int] = field(default_factory=dict)
# SP3 nested-pick carrier identity for the CURRENT resolve frame:
# the wildcard whose chosen option value textually contains the
# `@{T}` ref being resolved, plus that option's id. MUTABLE +
# save/restored by the resolver around each nested-option recursion
# (mirroring the rng swap) so `_resolve_ref` can tell
# apply_constraints_for_target which (carrier_uid, option_id) is
# firing target T — the only thing a `pick` selector's `nested`
# occurrence matches on. None outside any carrier option.
_carrier_uid: str | None = None
_carrier_option_id: str | None = None
def get_var(self, name: str) -> str | None:
# SP2a: return the raw stored value (may be a ListVar from a
# multi-select wildcard); the resolver formats it. Typed str | None
# for the Protocol — a ListVar flows through at runtime via the
# Any-valued vars dict, and the resolver narrows on isinstance.
if name in self._vars:
return str(self._vars[name])
return self._vars[name]
return None
def get_module(self, uuid: str) -> dict[str, Any] | None:
@@ -71,13 +86,37 @@ class _RuntimeResolveContext:
source wildcard's pick when reweighting the target."""
return self._picks
def get_consumed_constraints(self) -> set[str]:
"""``ctx['__wp_consumed_constraints__']`` — set of constraint
module ids that have already fired against their first
downstream target instance. Threaded by reference so the
nested-ref resolver's mutation is observed by the pipeline + by
subsequent target-instance resolves later in the chain."""
return self._consumed
def get_constraint_hits(self) -> dict[str, int]:
"""``ctx['__wp_constraint_hits__']`` — per-constraint firing
count keyed by module id. Threaded by reference so the
nested-ref resolver's increments are observed by the pipeline +
by subsequent target-instance resolves later in the chain
(first/next coverage spans direct + nested encounters)."""
return self._hits
def set_carrier(self, carrier_uid: str | None, option_id: str | None) -> None:
"""Set the CURRENT carrier identity (the wildcard whose chosen
option value contains the `@{T}` ref about to resolve, + that
option's id). The resolver save/restores this around each nested
option recursion (mirroring the rng swap)."""
self._carrier_uid = carrier_uid
self._carrier_option_id = option_id
def get_carrier(self) -> tuple[str | None, str | None]:
"""Return the current ``(carrier_uid, option_id)`` so the
resolver can save it before a recursion and restore it after."""
return self._carrier_uid, self._carrier_option_id
def get_carrier_ctx(self) -> dict[str, Any] | None:
"""The current carrier as the dict shape
``apply_constraints_for_target`` /
``_constraints._occurrence_matches`` expect, or ``None`` when no
carrier is set (outside any carrier option — e.g. a top-level
ref). A `pick` selector's `nested` entry matches on these two
keys; every other selector mode ignores them."""
if self._carrier_uid is None and self._carrier_option_id is None:
return None
return {"carrier_uid": self._carrier_uid, "option_id": self._carrier_option_id}
def build_resolve_ctx(
@@ -93,7 +132,7 @@ def build_resolve_ctx(
"""
constraints = ctx.get("__wp_constraints__")
picks = ctx.get("__wp_picks__")
consumed = ctx.setdefault("__wp_consumed_constraints__", set())
hits = ctx.setdefault("__wp_constraint_hits__", {})
return _RuntimeResolveContext( # type: ignore[return-value]
rng=ctx["__wp_rng__"],
max_ref_depth=int(ctx.get("__wp_max_ref_depth__", 8)),
@@ -110,10 +149,10 @@ def build_resolve_ctx(
# support) — defaults handle that path.
_constraints=constraints if isinstance(constraints, list) else [],
_picks=picks if isinstance(picks, dict) else {},
# First-instance consume set — passed by reference so the
# nested-ref resolver's mutation sticks in ctx for subsequent
# target-instance resolves later in the chain.
_consumed=consumed if isinstance(consumed, set) else set(),
# SP3 hit counter — passed by reference so the nested-ref
# resolver's increments stick in ctx + share one counter with
# the direct path for first/next coverage.
_hits=hits if isinstance(hits, dict) else {},
)
+62
View File
@@ -0,0 +1,62 @@
"""Pure constraint re-weight math (SP3). No engine/ctx imports — testable bare.
One multiply operator at three levels: across an option's matching matrix
cells (multi-tag), across source picks (multi-pick), and — by the caller —
across constraints. `exclude` (factor 0) is the absorbing element.
"""
from __future__ import annotations
from typing import Any
EXCLUDE = object() # sentinel: option drops out of the pool
def _apply_rule(rule: dict[str, Any]):
mode = rule.get("mode")
if mode == "exclude":
return EXCLUDE
if mode in ("boost", "reduce"):
try:
return max(0.0, float(rule.get("factor", 1.0)))
except (TypeError, ValueError):
return 1.0
return 1.0 # allow / unknown -> no weight change
def combine_constraint_factor(source_picks, option, matrix, exceptions):
matrix = matrix or {}
exc_by_pair: dict[tuple[str, str], dict] = {}
for e in (exceptions or []):
if not isinstance(e, dict):
continue
s = e.get("source_value")
if s is None:
s = e.get("source")
t = e.get("target_value")
if t is None:
t = e.get("target")
if isinstance(s, str) and isinstance(t, str):
exc_by_pair[(s, t)] = e
opt_value = str(option.get("value", ""))
opt_tags = option.get("tags") or []
factor = 1.0
for pick in (source_picks or []):
p_value = str(pick.get("value", ""))
p_tags = pick.get("tags") or []
exc = exc_by_pair.get((p_value, opt_value))
if exc is not None:
r = _apply_rule(exc)
if r is EXCLUDE:
return EXCLUDE
factor *= r
continue
for s in p_tags:
row = matrix.get(s)
if not isinstance(row, dict):
continue
for t in opt_tags:
rule = row.get(t)
if isinstance(rule, dict):
r = _apply_rule(rule)
if r is EXCLUDE:
return EXCLUDE
factor *= r
return factor
+131 -116
View File
@@ -17,89 +17,10 @@ handler) or narrow ResolveContext-style accessors (ref resolver).
"""
from __future__ import annotations
import re
from typing import Any
from engine.modules.wildcard_handler import _apply_constraint_to_options
# Mirror of `engine.syntax.tokenize._REF_RE` (kept local to avoid
# importing a private symbol across the module boundary). Captures the
# 8-hex uuid; the optional `#name` / `:subcat` segments are skipped.
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#[^#:}@{]*)?(?::[^}]*)?\}")
def claim_carrier_constraints(
options: list[dict[str, Any]],
constraints: list[dict[str, Any]] | None,
picks: dict[str, dict[str, Any]] | None,
consumed: set[str] | None,
) -> None:
"""First-instance positional-claim failsafe (2026-05-26).
After a wildcard rolls + resolves its chosen option, any *unconsumed*
constraint whose target this wildcard CARRIES — i.e. has an
``@{target}`` ref in ANY of its options, not just the chosen one — is
consumed-as-skipped, provided the constraint's source has already
been picked.
Why: the pair badge assigns a constraint to the FIRST downstream
wildcard that could host its target (direct instance OR nested-ref
carrier). Without this failsafe the runtime only consumed a
constraint when the target *actually* resolved, so a carrier whose
chosen option happened to omit the ref let the constraint "spill"
onto a later target instance — contradicting the badge and
surprising the user. Claiming here binds the constraint to the
carrier positionally: if the carrier's roll skips the ref, the
constraint is spent (no spill) and no never_applied warning fires
for it (the carrier did roll — it just didn't use the ref).
No-op cases:
- chosen option DID resolve the ref → the nested-resolve path
already consumed the constraint; the ``cid in consumed`` guard
skips it here.
- source not yet picked → don't claim (matches the direct-target
apply path, which also defers + warns rather than consuming an
un-appliable constraint). Lets it potentially fire once the
source is available later in the chain.
"""
if not constraints or not isinstance(constraints, list):
return
if consumed is None:
return
if not isinstance(picks, dict):
picks = {}
carried: set[str] = set()
for opt in options:
val = opt.get("value")
if isinstance(val, str):
for m in _REF_RE.finditer(val):
carried.add(m.group(1))
if not carried:
return
# Claim at most ONE constraint per carried target — the first
# unconsumed one in registration order. A carrier hosts a single
# nested resolution per target, so it consumes a single constraint
# for it (matching `apply_constraints_for_target`, which also fires
# one per call). Claiming ALL would let a carrier swallow an entire
# family of same-target constraints, starving a later direct target
# instance that the pair badge assigns to constraint #2.
claimed_targets: set[str] = set()
for c in constraints:
if not isinstance(c, dict):
continue
tgt = c.get("target_wildcard_id")
if tgt not in carried or tgt in claimed_targets:
continue
cid = c.get("__constraint_module_id__")
if cid is None or cid in consumed:
continue
src_id = c.get("source_wildcard_id")
src_pick = picks.get(src_id) if isinstance(src_id, str) else None
if not isinstance(src_pick, dict):
continue
consumed.add(cid)
claimed_targets.add(tgt)
def _push_constraint_warning(
warnings: list[dict[str, Any]],
@@ -126,6 +47,80 @@ def _push_constraint_warning(
})
def _occurrence_matches(pick_entries, firing_uid, carrier_ctx) -> bool:
"""True when an explicit ``pick`` reach selector covers THIS firing
target occurrence.
Two occurrence kinds:
- ``direct`` — a top-level wildcard instance. Matched by its
per-instance uid (``firing_uid``). ``carrier_ctx`` is ``None``
for these (the wildcard handler fires them directly).
- ``nested`` — a ``@{target}`` ref resolved inside a carrier
wildcard's chosen option. Matched by (carrier_uid, option_id).
``carrier_ctx`` carries those, threaded by the nested-resolve
path (``engine.syntax.resolve``). The direct-firing wildcard
handler passes ``carrier_ctx=None`` (it fires top-level
instances, not nested refs), so the two kinds never cross-match.
"""
for p in pick_entries:
if not isinstance(p, dict):
continue
if carrier_ctx is None:
if p.get("kind") == "direct" and p.get("uid") == firing_uid:
return True
else:
if (
p.get("kind") == "nested"
and p.get("carrier_uid") == carrier_ctx.get("carrier_uid")
and p.get("option_id") == carrier_ctx.get("option_id")
):
return True
return False
def _select_source_pick(
picks: dict[str, dict[str, Any]],
src_id: Any,
constraint: dict[str, Any],
) -> dict[str, Any] | None:
"""Resolve the source pick a constraint should re-weight against.
Selection rule (task_5200c1fc — source-instance binding):
1. Look up the top-level entry `picks[src_id]` (today's bucket, the
last-writer-wins survivor when two instances share a library uuid).
2. If the constraint carries a `bundle_origin` AND that entry has a
`by_origin[origin]` view, return that view — the source instance in
the SAME bundle copy as this constraint.
3. Otherwise return the top-level entry as-is = TODAY'S behavior,
exactly. Covers: a no-bundle constraint, a constraint inside a
bundle whose source is OUTSIDE the bundle, and every legacy
workflow. This exact-fallback is what makes the change safe.
DOCUMENTED LIMITATION (task_5200c1fc): the same library wildcard added
MANUALLY twice OUTSIDE any bundle shares no `bundle_origin`, so both
manual instances land in the same top-level bucket and the constraint
falls back to last-writer-wins. The constraint payload genuinely does
not encode WHICH manual instance the user meant, so the design declines
to guess rather than guessing wrong. This is a principled residual, not
a gap; an explicit stored `source_uid` binding (Option 2 in the design)
can be layered on later with precedence explicit > bundle-scope >
fallback, foreclosing nothing.
"""
if not isinstance(src_id, str):
return None
top = picks.get(src_id)
if not isinstance(top, dict):
return None
origin = constraint.get("__constraint_bundle_origin__")
if origin:
by_origin = top.get("by_origin")
if isinstance(by_origin, dict):
scoped = by_origin.get(origin)
if isinstance(scoped, dict):
return scoped
return top
def apply_constraints_for_target(
options: list[dict[str, Any]],
target_uuid: str,
@@ -133,54 +128,60 @@ def apply_constraints_for_target(
picks: dict[str, dict[str, Any]] | None,
warnings: list[dict[str, Any]],
*,
consumed: set[str] | None = None,
consumed=None,
hits=None,
firing_uid=None,
carrier_ctx=None,
) -> tuple[list[dict[str, Any]], bool]:
"""Apply the FIRST unconsumed constraint targeting ``target_uuid``.
"""Apply EVERY constraint whose reach selector covers this firing
target instance, combined sequentially.
Returns ``(adjusted_options, any_applied)``. ``any_applied`` is True
iff a constraint actually fired — callers use it to decide whether
to run the excludes-all check.
iff at least one constraint actually fired — callers use it to
decide whether to run the excludes-all check.
**One-shot semantic (2026-05-24 first-instance redesign).** Each
constraint module is a single ammo round: it fires on the first
downstream target wildcard instance, gets marked consumed via its
``__constraint_module_id__`` key, and is skipped on subsequent
target instances. A second target instance later in the chain
claims the NEXT unconsumed constraint, not the same one again.
**Reach selector (SP3).** Each constraint carries
``target_select = {mode, count?, picks?}`` (default ``{mode: "all"}``).
On every firing target instance the per-constraint hit counter
``hits[cid]`` increments; the selector then decides coverage:
- ``all`` — covers every instance (re-weights every downstream
target occurrence).
- ``first`` — covers only the 1st (``n == 1``).
- ``next`` — covers the first ``count`` (``n <= count``).
- ``pick`` — covers the explicitly-listed occurrences
(``_occurrence_matches``).
Multiple covering constraints stack: their factors multiply in
registration order and ``exclude`` is absorbing. This replaces the
pre-SP3 one-shot / consumed-set model.
Constraints are walked in registration (chain) order so the first
unconsumed match wins. After applying, the function **breaks** out
of the loop — exactly one constraint application per call.
Reach is downstream-relative by construction: a constraint only
appears in ``ctx["__wp_constraints__"]`` after its own module ran,
so target occurrences upstream of it never see it.
``consumed`` is the ctx-resident ``__wp_consumed_constraints__``
set; the caller is expected to thread it from ctx. ``None`` is
accepted only by legacy test paths that pre-date the consume model.
``hits`` is the ctx-resident ``__wp_constraint_hits__`` dict; the
caller threads it from ctx so first/next counters persist across the
run (direct + nested encounters share one counter). ``consumed`` is
accepted but ignored — kept so legacy callers don't break.
Each constraint warns + skips when its source wildcard hasn't been
picked yet — caller code is responsible for ordering source
wildcards before the target's roll. The warning gives the user a
signal in WP_Debug instead of a silent no-op.
See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
"""
if not constraints or not isinstance(constraints, list):
return options, False
if not isinstance(picks, dict):
picks = {}
if consumed is None:
consumed = set()
if hits is None:
hits = {}
any_applied = False
for c in constraints:
if not isinstance(c, dict):
continue
if c.get("target_wildcard_id") != target_uuid:
if not isinstance(c, dict) or c.get("target_wildcard_id") != target_uuid:
continue
cid = c.get("__constraint_module_id__")
if cid is not None and cid in consumed:
continue
src_id = c.get("source_wildcard_id")
src_pick = picks.get(src_id) if isinstance(src_id, str) else None
src_pick = _select_source_pick(picks, src_id, c)
if not isinstance(src_pick, dict):
_push_constraint_warning(
warnings,
@@ -195,14 +196,33 @@ def apply_constraints_for_target(
},
)
continue
adjustment_warnings: list[dict[str, Any]] = []
options = _apply_constraint_to_options(
options, c, src_pick, adjustment_warnings,
)
if cid is not None:
consumed.add(cid)
hits[cid] = hits.get(cid, 0) + 1
n = hits.get(cid, 1)
sel = c.get("target_select") or {"mode": "all"}
mode = sel.get("mode", "all")
if mode == "first":
covered = n == 1
elif mode == "next":
try:
cnt = int(sel.get("count", 1))
except (TypeError, ValueError):
cnt = 1
covered = n <= cnt
elif mode == "pick":
covered = _occurrence_matches(
sel.get("picks") or [], firing_uid, carrier_ctx
)
else:
covered = True
if not covered:
continue
adjust_warnings: list[dict[str, Any]] = []
options = _apply_constraint_to_options(
options, c, src_pick, adjust_warnings,
)
any_applied = True
for w in adjustment_warnings:
for w in adjust_warnings:
_push_constraint_warning(
warnings,
type_=w.get("type", "unknown_constraint_mode"),
@@ -213,11 +233,6 @@ def apply_constraints_for_target(
"target_wildcard_id": target_uuid,
},
)
# Key change for first-instance semantic: one constraint per
# target-instance call. Subsequent target instances re-enter
# this function and pick up the NEXT unconsumed constraint.
break
return options, any_applied
+93 -16
View File
@@ -24,6 +24,55 @@ from engine.modules.dispatcher import ModuleHandler
_VALID_MODES = {"allow", "exclude", "boost", "reduce"}
# SP3 reach selector — which downstream target instance(s) a constraint
# reaches. Shape only here; reach *behaviour* is applied by a later task.
_REACH_MODES = {"first", "next", "all", "pick"}
def _validate_target_select(ts):
"""Validate the optional ``target_select`` reach selector.
Shape: ``{mode: "first"|"next"|"all"|"pick", count?, picks?}``. ``None``
(absent) is allowed — the resolver defaults it to ``{mode: "all"}``.
"""
if ts is None:
return
if not isinstance(ts, dict):
raise ValueError("constraint payload.target_select must be an object")
mode = ts.get("mode", "all")
if mode not in _REACH_MODES:
raise ValueError(
f"constraint payload.target_select.mode must be one of {sorted(_REACH_MODES)}"
)
if mode == "next":
count = ts.get("count")
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
raise ValueError(
"constraint payload.target_select.count must be an int >= 1 for mode 'next'"
)
if mode == "pick":
picks = ts.get("picks")
if not isinstance(picks, list):
raise ValueError(
"constraint payload.target_select.picks must be a list for mode 'pick'"
)
for i, p in enumerate(picks):
if not isinstance(p, dict) or p.get("kind") not in ("direct", "nested"):
raise ValueError(
f"constraint payload.target_select.picks[{i}].kind must be 'direct' or 'nested'"
)
if p["kind"] == "direct" and not isinstance(p.get("uid"), str):
raise ValueError(
f"constraint payload.target_select.picks[{i}].uid must be a string"
)
if p["kind"] == "nested" and not (
isinstance(p.get("carrier_uid"), str) and isinstance(p.get("option_id"), str)
):
raise ValueError(
f"constraint payload.target_select.picks[{i}] nested "
"needs carrier_uid + option_id"
)
def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
"""Append ``meta`` to ``ctx['__wp_constraints__']`` (best-effort).
@@ -185,6 +234,7 @@ class ConstraintHandler(ModuleHandler):
raise ValueError(
f"constraint payload.exceptions[{i}].factor must not be negative"
)
_validate_target_select(payload.get("target_select"))
@classmethod
def resolve(
@@ -208,6 +258,16 @@ class ConstraintHandler(ModuleHandler):
exception_factor_overrides = instance.get("exception_factor_overrides") or {}
extra_exceptions = instance.get("extra_exceptions") or []
# SP3 reach selector — instance override wins over the payload's
# value, falling back to the {mode:"all"} default. Shape was
# already validated in validate_payload (payload side); the
# instance side is validated when the instance is loaded.
ts = (
instance.get("target_select")
or payload.get("target_select")
or {"mode": "all"}
)
# ── Matrix: filter then override ─────────────────────────────
raw_matrix = payload.get("matrix", {})
matrix: dict[str, Any] = {}
@@ -353,20 +413,22 @@ class ConstraintHandler(ModuleHandler):
exceptions.append(dict(exc))
# Carry the owning constraint module's id into the registered
# bucket entry so `apply_constraints_for_target` can mark it
# consumed once its first downstream target instance fires
# (one-shot semantic per 2026-05-24 first-instance spec).
# bucket entry as `__constraint_module_id__` (the `cid`). Under the
# SP3 reach model it identifies the constraint for two things:
# (1) the per-constraint hit counter `__wp_constraint_hits__[cid]`
# that drives first / next / pick coverage in
# `apply_constraints_for_target`, and (2) the dedup key + ref of
# the never-applied / partial-reach finalisation warnings.
#
# Key on the PER-INSTANCE uid (`__wp_current_module_uid__`), not
# the library uuid (`__wp_current_module_id__`): two instances of
# the same library constraint entry must be INDEPENDENT one-shots
# the same library constraint entry must reach INDEPENDENTLY
# (CLAUDE.md — "author multiple constraint modules" to affect
# multiple target instances). Pre-2026-05-26 both keyed on the
# shared library uuid, so claiming/consuming one silently spent
# the other — a carrier could swallow the whole family and a
# downstream direct target then rolled unconstrained. The pipeline
# falls back to the library id when `_uid` is absent (legacy /
# hand-built test modules), preserving the old behaviour there.
# shared library uuid, so the two instances' counters collided —
# one instance's encounters advanced the other's first/next
# selector. The pipeline falls back to the library id when `_uid`
# is absent (legacy / hand-built test modules).
module_id = None
try:
if hasattr(ctx, "get"):
@@ -376,27 +438,33 @@ class ConstraintHandler(ModuleHandler):
)
except Exception:
module_id = None
# The per-instance `_uid` (12+ chars) keys the consumed-set so two
# canvas instances of the same library constraint stay independent
# one-shots. The LIBRARY id (8-hex) is also stashed because the
# never-applied warning text needs it to render the chip — the SPA
# / Debug viewer's chip resolver looks up library uuids in the
# module catalog, and a per-instance `_uid` would never resolve
# (silently rendering as raw `@{…}` text in the warning).
# The per-instance `_uid` (12+ chars) keys the per-constraint hit
# counter so two canvas instances of the same library constraint
# reach independently. The LIBRARY id (8-hex) is also stashed
# because the never-applied / partial-reach warning text needs it
# to render the chip — the SPA / Debug viewer's chip resolver looks
# up library uuids in the module catalog, and a per-instance `_uid`
# would never resolve (silently rendering as raw `@{…}` text in the
# warning).
library_id = None
library_name = None
bundle_origin = None
try:
if hasattr(ctx, "get"):
library_id = ctx.get("__wp_current_module_id__")
library_name = ctx.get("__wp_current_module_name__")
bundle_origin = ctx.get("__wp_current_module_bundle_origin__")
except Exception:
library_id = None
library_name = None
bundle_origin = None
meta = {
"source_wildcard_id": payload["source_wildcard_id"],
"target_wildcard_id": payload["target_wildcard_id"],
"matrix": matrix,
"exceptions": exceptions,
# SP3 reach selector — recorded for the (later) apply pass.
"target_select": ts,
"__constraint_module_id__": module_id,
"__constraint_library_id__": library_id,
# Cached display name (e.g. "Starter pairing"). The never-applied
@@ -407,6 +475,15 @@ class ConstraintHandler(ModuleHandler):
# endpoint would 404 there, leaving the chip stuck on the raw
# uuid otherwise.
"__constraint_library_name__": library_name,
# Per-insertion bundle discriminator (task_5200c1fc). When this
# constraint instance lives inside a bundle copy, it shares one
# `bundle_origin` with the source wildcard instance in the SAME
# copy. `apply_constraints_for_target` uses it to read that
# copy's source pick (`by_origin[origin]`) rather than the
# last-writer survivor. Falsy (None) for a top-level / manual /
# legacy constraint — then apply falls back to today's
# last-writer-wins lookup, unchanged.
"__constraint_bundle_origin__": bundle_origin,
}
_ctx_set_constraint(ctx, meta)
return {}
+61 -14
View File
@@ -19,6 +19,7 @@ from engine.modules import build_resolve_ctx
from engine.modules._seed import derive_module_rng
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
from engine.syntax.types import deref_var_value, split_var_accessor
_VALID_OPS = {
"equals", "not_equals", "contains", "matches",
@@ -33,25 +34,34 @@ _VALID_OPS = {
_VALID_MODES = {"replace", "append", "prepend"}
def _ctx_get(ctx: Any, name: str) -> str:
def _ctx_get_raw(ctx: Any, name: str) -> Any:
"""Read the RAW stored value for `name` (may be a ListVar) through the
callable-getter -> dict-lookup indirection. Missing / error -> None."""
if ctx is None:
return ""
return None
getter = getattr(ctx, "get", None)
if callable(getter):
try:
value = getter(name, "")
return getter(name, None)
except TypeError:
try:
value = getter(name)
return getter(name)
except Exception:
return ""
return "" if value is None else str(value)
return None
try:
if name in ctx: # type: ignore[operator]
return str(ctx[name]) # type: ignore[index]
return ctx[name] # type: ignore[index]
except Exception:
return ""
return ""
return None
return None
def _ctx_get(ctx: Any, name: str) -> str:
"""Read `name` as a string, honoring a `.K` list accessor and folding a
ListVar to its joined value (SP2a): `$mood.0` splits to base `mood` +
index 0. The fold/accessor contract lives in deref_var_value."""
base, index = split_var_accessor(name)
return deref_var_value(_ctx_get_raw(ctx, base), index)
def _ctx_has(ctx: Any, name: str) -> bool:
@@ -66,6 +76,7 @@ def _ctx_has(ctx: Any, name: str) -> bool:
"""
if ctx is None:
return False
name = split_var_accessor(name)[0] # SP2a: presence checks the base var
# Engine ctx is a dict in tests + at runtime; check `__contains__`
# before falling back to a getter probe so we get the cheap path.
try:
@@ -91,6 +102,7 @@ def _ctx_has(ctx: Any, name: str) -> bool:
def _ctx_set(ctx: Any, name: str, value: str) -> None:
if ctx is None:
return
name = split_var_accessor(name)[0] # SP2a: writing $x.K targets the base var
setter = getattr(ctx, "set", None)
if callable(setter):
setter(name, value)
@@ -132,17 +144,48 @@ def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
return False
def branch_carrier_key(rule_id: str, branch: int | str) -> str:
"""Carrier key for a derivation branch occurrence -- the `option_id` the
SP3 nested-occurrence model matches a constraint `pick` against. `branch`
is the 0-based branch index (0 = IF) or the literal ``"else"``.
MUST stay byte-identical to the TS twin ``branchKey`` in
``src/extension/constraint-pairs.ts``; both are corpus-locked against
``tests/fixtures/constraint-corpus.json`` ``branch_key_cases``.
"""
return f"{rule_id}:{branch}"
def _apply_action(
action: dict[str, Any],
ctx: Any,
resolve_ctx: Any,
carrier_key: str | None = None,
) -> tuple[str, str] | None:
target = action.get("target_var", "")
if not target:
return None
mode = action.get("mode", "replace")
raw_value = str(action.get("value", ""))
new_value = resolve_text(raw_value, resolve_ctx)
# Stamp this derivation instance as the CARRIER of any `@{}` in the action
# value, keyed by the branch key (`${rule_id}:${bi}` / `${rule_id}:else`) —
# the `option_id` the SP3 nested-occurrence model matches a `pick` against.
# Save/restore around the resolve, mirroring the resolver's own carrier
# save/restore for nested recursion.
set_carrier = getattr(resolve_ctx, "set_carrier", None)
get_carrier = getattr(resolve_ctx, "get_carrier", None)
if carrier_key is not None and callable(set_carrier) and callable(get_carrier):
module_uid = None
if isinstance(ctx, dict):
module_uid = ctx.get("__wp_current_module_uid__") or ctx.get("__wp_current_module_id__")
prev = get_carrier()
set_carrier(module_uid, carrier_key)
try:
new_value = resolve_text(raw_value, resolve_ctx)
finally:
set_carrier(*prev)
else:
new_value = resolve_text(raw_value, resolve_ctx)
if mode == "replace":
result = new_value
elif mode == "append":
@@ -296,7 +339,7 @@ class DerivationHandler(ModuleHandler):
# Branch-level disable — `r1:1` etc. IF (bi=0) ignored
# even when listed because disabling IF == disabling rule
# (the per-rule toggle handles that case cleanly).
if bi != 0 and f"{rule_id}:{bi}" in disabled_branch_keys:
if bi != 0 and branch_carrier_key(rule_id, bi) in disabled_branch_keys:
continue
# Condition-value override per branch index.
@@ -320,7 +363,9 @@ class DerivationHandler(ModuleHandler):
if isinstance(action_override, str):
action = {**action, "value": action_override}
pair = _apply_action(action, ctx, resolve_ctx)
pair = _apply_action(
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, bi)
)
if pair is not None:
out[pair[0]] = pair[1]
applied = True
@@ -328,7 +373,7 @@ class DerivationHandler(ModuleHandler):
if not applied:
# ELSE skip when listed in disabled_branch_keys.
if f"{rule_id}:else" in disabled_branch_keys:
if branch_carrier_key(rule_id, "else") in disabled_branch_keys:
continue
else_clause = rule.get("else")
if isinstance(else_clause, dict):
@@ -341,7 +386,9 @@ class DerivationHandler(ModuleHandler):
)
if isinstance(action_override, str):
action = {**action, "value": action_override}
pair = _apply_action(action, ctx, resolve_ctx)
pair = _apply_action(
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, "else")
)
if pair is not None:
out[pair[0]] = pair[1]
return out
+9
View File
@@ -70,6 +70,15 @@ def _fresh_instance() -> dict[str, Any]:
"enabled_options": None,
"category_filter": None,
"option_weights": None,
# SP2a multi-select + the null toggle — part of the pool-shaping
# override set, so a frozen snapshot carries them too (all None =
# "no override"). Keeps this dict honest with the "every field" claim
# above + the instance schema (engine/schemas/instance_schemas.py).
"exclude_null": None,
"pick_min": None,
"pick_max": None,
"pick_separator": None,
"pick_independent": None, # SP2c: multi-pick with replacement when true
# Pick mode — `None`/missing → `random` (legacy behavior:
# weighted RNG over enabled options). `pinned` short-circuits
# the RNG and always picks `pinned_option_id`. `subcategory`
+427 -182
View File
@@ -24,25 +24,40 @@ from engine.modules import build_resolve_ctx
from engine.modules._seed import derive_module_rng as _derive_module_rng
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
from engine.syntax.resolve import pick_k_unique
from engine.syntax.subcat_filter import (
matches as _subcat_matches,
)
from engine.syntax.subcat_filter import (
parse as _parse_subcat,
)
from engine.syntax.subcat_filter import (
validate_subcat_name,
)
from engine.syntax.types import ListVar
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
_MAX_IDENT_LEN = 64
# Characters reserved by the nested-ref grammar `@{uuid[#name][:subcat,subcat...]}`.
# Forbidden in:
# - wildcard `meta.name` / library `name` (the `#name` segment)
# - wildcard `payload.sub_categories[*]` (the `:subcat` segment)
# Comma is in here because subcat lists are comma-separated; the other
# chars are structural delimiters of the ref grammar. Mirror in
# `src/manager/utils/validateName.ts` so the editor + API agree.
_REF_FORBIDDEN_CHARS = frozenset("{}:#@,")
# `_derive_module_rng` lifted to engine/modules/_seed.py so combine +
# fixed_values handlers share the same helper (Phase: combine v2 +
# syntax parity cycle). The private alias preserves the existing call
# site at line ~376 unchanged.
def _coerce_pick_int(value: Any, default: int) -> int:
"""SP2a: coerce a pick-count field (`pick_min`/`pick_max`) to int, falling
back to `default` on junk. The engine is reachable by hand-built and legacy
instances, so a non-numeric value must degrade to single-pick rather than
raise out of resolve() — mirrors the locked_seed coercion guard."""
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
"""Pick one option weighted by `weight`. Uses rng (no module-global random)."""
if not options:
@@ -66,121 +81,289 @@ def _apply_constraint_to_options(
adjustment_warnings: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""Adjust option weights according to a single constraint, given the
already-picked source option.
already-picked source option(s).
Two layers, in order:
1. Matrix lookup keyed by `(source.sub_category, target.sub_category)`.
Bulk rule expressing "long hair allows positive moods" etc.
2. Exception lookup keyed by the literal `(source.value, target.value)`
pair. Overrides the matrix cell when present — narrower rule wins.
SP3: weight adjustment is delegated to
:func:`engine.modules._constraint_math.combine_constraint_factor`, which
multiplies one factor per matching matrix cell (an option on N source-tag
rows multiplies N times — true multi-tag) across every source pick (true
multi-pick). The exception layer (literal `(source_value, target_value)`
pair) still overrides the matrix for that pick, and `exclude` is absorbing
(factor 0 → weight 0, option drops out).
Modes:
- ``allow`` → no change to weight (factor honoured but typically 1).
- ``exclude`` → weight set to 0 (option drops out of the pool).
- ``boost`` → multiply weight by factor (expect factor > 1).
- ``reduce`` → multiply weight by factor (expect factor < 1).
`source_pick["picks"]` is the per-pick list (`[{value, tags}]`) recorded by
`_record_pick` / `_record_pick_multi`. Legacy callers that hand-build a
source dict without it fall back to a single synthetic pick from the
top-level `value` + `sub_categories`.
Options not covered by either matrix or exception keep their
original weight. Returns shallow-copied list — the input options
array is never mutated, so the underlying snapshot stays clean
across runs.
Returns a shallow-copied list — the input options array is never mutated,
so the underlying snapshot stays clean across runs.
`adjustment_warnings` (optional) collects editor-footgun warnings the
caller forwards to the chain warning list:
- ``constraint_factor_ignored_on_allow`` — a `mode: allow` cell carries a
non-1 factor (user expects weight×factor, engine ignores it).
- ``unknown_constraint_mode`` — a typo'd mode (`exlcude`/`alllow`) the
combine fn treats as a no-op.
These are diagnostic only; they never change the computed weight.
"""
from engine.modules._constraint_math import EXCLUDE, combine_constraint_factor
matrix = constraint.get("matrix") or {}
exceptions = constraint.get("exceptions") or []
src_value = str(source_pick.get("value", ""))
src_sub = source_pick.get("sub_category")
# Index exceptions by (source_value, target_value) for O(1) lookup.
# Empty string is a valid key — it's the null-option marker (a
# wildcard option with `is_null: True` has `value: ""`). So
# `{source: "rain", target: ""}` excludes the null option when the
# source rolls "rain". The validator allows empty strings on these
# fields for the same reason; only fully-missing keys (None on both
# legacy + tier-2 names) are rejected upstream.
exc_by_pair: dict[tuple[str, str], dict[str, Any]] = {}
for exc in exceptions:
if not isinstance(exc, dict):
continue
s = exc.get("source")
t = exc.get("target")
if isinstance(s, str) and isinstance(t, str):
exc_by_pair[(s, t)] = exc
# Per-pick source list (SP3). Fall back to a single synthetic pick for
# legacy callers that pass only the top-level value + sub_categories.
picks = source_pick.get("picks")
if not isinstance(picks, list):
picks = [{
"value": source_pick.get("value", ""),
"tags": list(source_pick.get("sub_categories") or []),
}]
matrix_row = matrix.get(src_sub) if src_sub else None
# Pre-scan for diagnostic warnings only. The combine fn intentionally
# treats `allow` and unknown modes as factor 1.0 (no weight change), so it
# can't surface them — mirror its (pick × option) rule selection here,
# exception-pair winning over matrix cell, and warn where the selected rule
# is an ignored-factor `allow` or an unrecognised mode.
if adjustment_warnings is not None:
_scan_constraint_warnings(
picks, options, matrix, exceptions, adjustment_warnings,
)
adjusted: list[dict[str, Any]] = []
for opt in options:
opt_value = str(opt.get("value", ""))
opt_sub = opt.get("sub_category")
option = {"value": opt.get("value", ""), "tags": opt.get("sub_categories") or []}
f = combine_constraint_factor(picks, option, matrix, exceptions)
weight = float(opt.get("weight", 1))
# Exception layer (specific value pair) wins over matrix layer.
rule = exc_by_pair.get((src_value, opt_value))
if rule is None and isinstance(matrix_row, dict) and opt_sub is not None:
rule = matrix_row.get(opt_sub)
if isinstance(rule, dict):
mode = rule.get("mode")
try:
factor = float(rule.get("factor", 1.0))
except (TypeError, ValueError):
factor = 1.0
if mode == "exclude":
weight = 0.0
elif mode == "boost" or mode == "reduce":
weight = max(0.0, weight * factor)
elif mode == "allow":
# `allow` is a no-op weight-wise; surface a warning when
# the SPA stored a non-1 factor on it because that's
# almost certainly a user expecting weight*factor and
# silently getting full weight.
if factor != 1.0:
out_warns = adjustment_warnings if adjustment_warnings is not None else None
if out_warns is not None:
out_warns.append({
"type": "constraint_factor_ignored_on_allow",
"factor": factor,
"src_value": src_value,
"opt_value": opt_value,
})
else:
# Unknown mode — typo like "exlcude" / "alllow". Silent
# no-op was the worst-of-both: weight unchanged + zero
# signal that the rule was malformed. Bubble it up so
# the caller can emit a `unknown_constraint_mode` warning.
out_warns = adjustment_warnings if adjustment_warnings is not None else None
if out_warns is not None:
out_warns.append({
"type": "unknown_constraint_mode",
"mode": mode,
"src_value": src_value,
"opt_value": opt_value,
})
weight = 0.0 if f is EXCLUDE else max(0.0, weight * float(f))
adjusted.append({**opt, "weight": weight})
return adjusted
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce"})
def _exc_pair_index(exceptions: list[Any]) -> dict[tuple[str, str], dict[str, Any]]:
"""Index exceptions by (source_value, target_value), mirroring the
combine fn's key fallback (tier-2 `source_value`/`target_value` names,
then legacy `source`/`target`). Empty string is a valid key — it's the
null-option marker (`is_null` options carry `value: ""`)."""
out: dict[tuple[str, str], dict[str, Any]] = {}
for exc in exceptions or []:
if not isinstance(exc, dict):
continue
s = exc.get("source_value")
if s is None:
s = exc.get("source")
t = exc.get("target_value")
if t is None:
t = exc.get("target")
if isinstance(s, str) and isinstance(t, str):
out[(s, t)] = exc
return out
def _scan_constraint_warnings(
picks: list[dict[str, Any]],
options: list[dict[str, Any]],
matrix: dict[str, Any],
exceptions: list[Any],
adjustment_warnings: list[dict[str, Any]],
) -> None:
"""Append `constraint_factor_ignored_on_allow` / `unknown_constraint_mode`
diagnostics for the rules that fire (per pick × option). Selection order
matches `combine_constraint_factor`: an exception on the literal value pair
wins over a matrix cell, and only the matched rule is inspected."""
exc_by_pair = _exc_pair_index(exceptions)
for pick in picks:
p_value = str(pick.get("value", ""))
p_tags = pick.get("tags") or []
for opt in options:
opt_value = str(opt.get("value", ""))
opt_tags = opt.get("sub_categories") or []
exc = exc_by_pair.get((p_value, opt_value))
if exc is not None:
_warn_for_rule(exc, p_value, opt_value, adjustment_warnings)
continue
for s in p_tags:
row = matrix.get(s)
if not isinstance(row, dict):
continue
for t in opt_tags:
rule = row.get(t)
if isinstance(rule, dict):
_warn_for_rule(rule, p_value, opt_value, adjustment_warnings)
def _warn_for_rule(
rule: dict[str, Any],
src_value: str,
opt_value: str,
adjustment_warnings: list[dict[str, Any]],
) -> None:
mode = rule.get("mode")
if mode == "allow":
# `allow` is a weight no-op; a non-1 factor is almost certainly a
# user expecting weight×factor and silently getting full weight.
try:
factor = float(rule.get("factor", 1.0))
except (TypeError, ValueError):
factor = 1.0
if factor != 1.0:
adjustment_warnings.append({
"type": "constraint_factor_ignored_on_allow",
"factor": factor,
"src_value": src_value,
"opt_value": opt_value,
})
elif mode not in _KNOWN_CONSTRAINT_MODES:
# Typo like "exlcude" / "alllow". The combine fn no-ops it; bubble up
# so the caller can surface `unknown_constraint_mode`.
adjustment_warnings.append({
"type": "unknown_constraint_mode",
"mode": mode,
"src_value": src_value,
"opt_value": opt_value,
})
def _file_pick_by_origin(
ctx: Any,
entry: dict[str, Any],
bucket: dict[str, Any],
module_id: Any,
) -> None:
"""Attach `entry` under `bucket[module_id]["by_origin"][origin]` when the
active module carries a `bundle_origin` (stamped by pipeline.py). This is
the per-instance source-pick view a constraint in the same bundle copy
reads (task_5200c1fc); the top-level `bucket[module_id]` stays the
last-writer-wins fallback. No-op when no origin is stamped, so a
top-level / manual / legacy module's pick entry is byte-for-byte today's
shape. The `by_origin` map is carried FORWARD across re-records of the
same module_id so two origins writing the same library uuid each keep
their own view (the 2nd record must not drop the 1st origin's bucket).
"""
origin = ctx.get("__wp_current_module_bundle_origin__") if ctx is not None else None
if not origin:
return
prev = bucket.get(module_id)
by_origin = {}
if isinstance(prev, dict) and isinstance(prev.get("by_origin"), dict):
by_origin = dict(prev["by_origin"])
by_origin[origin] = {k: v for k, v in entry.items() if k != "by_origin"}
entry["by_origin"] = by_origin
def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
"""Stash the picked option dict in `ctx["__wp_picks__"][module_id]` so a
downstream constraint-aware wildcard can look up its source's value +
sub_category. Keyed by the active module id (set by pipeline.py).
sub_categories. Keyed by the active module id (set by pipeline.py).
"""
# KNOWN COLLISION (task_5200c1fc): module_id is the LIBRARY uuid
# (pipeline.py stamps __wp_current_module_id__ = library id). Two
# instances of one library wildcard (bundle inserted twice, or the same
# wildcard added twice) share this bucket key — the 2nd pick clobbers the
# 1st, and every constraint sourcing that uuid reads the survivor. Fix =
# key on the per-instance _uid + give constraints a source-INSTANCE bind.
# Do NOT "fix" by regenerating ids on insert: that severs drift/refresh-
# by-id lineage (see the remapBundleUuids dead-code note + task_5200c1fc).
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
if not module_id:
return
bucket = ctx.setdefault("__wp_picks__", {})
if isinstance(bucket, dict):
bucket[module_id] = {
entry = {
"value": chosen.get("value"),
"sub_category": chosen.get("sub_category"),
"sub_categories": chosen.get("sub_categories", []),
"id": chosen.get("id"),
# SP3: per-pick list the combine fn reads. A single pick is a
# one-element list so the multi-tag/multi-pick applier has a
# uniform shape regardless of single- vs multi-select source.
"picks": [{
"value": chosen.get("value", ""),
"tags": list(chosen.get("sub_categories") or []),
}],
}
# Additive per-instance view (task_5200c1fc). When this wildcard
# carries a `bundle_origin`, ALSO file the pick under
# `by_origin[origin]` so a constraint in the SAME bundle copy can
# read its own source instance instead of the last-writer survivor
# at the top-level key. The top-level entry above is left as the
# fallback bucket (unchanged shape) — anything reading it is
# unaffected. Omitted entirely when no origin is stamped.
_file_pick_by_origin(ctx, entry, bucket, module_id)
bucket[module_id] = entry
def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) -> None:
"""Multi-select counterpart to `_record_pick` (SP2a). Stash the joined
value + the individual picked values + the union of their sub-categories,
so the debug Picks view can render the list. Single-pick records (via
`_record_pick`) keep their original shape."""
# Same library-uuid bucket collision as `_record_pick` (see task_5200c1fc).
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
if not module_id:
return
bucket = ctx.setdefault("__wp_picks__", {})
if not isinstance(bucket, dict):
return
values = [str(c.get("value", "")) for c in chosen_list]
union: list[str] = []
for c in chosen_list:
for t in (c.get("sub_categories") or []):
if t not in union:
union.append(t)
# SP3: per-pick list (value + that pick's own tags, NOT the union) so the
# combine fn can apply the matrix per pick. The union above stays for the
# debug Picks view; `picks` is what constraint application reads.
per_pick = [
{"value": str(c.get("value", "")), "tags": list(c.get("sub_categories") or [])}
for c in chosen_list
]
entry = {
"value": sep.join(values),
"values": values,
"sub_categories": union,
"id": chosen_list[0].get("id") if chosen_list else None,
"picks": per_pick,
}
# Same per-instance view as `_record_pick` (task_5200c1fc).
_file_pick_by_origin(ctx, entry, bucket, module_id)
bucket[module_id] = entry
class WildcardHandler(ModuleHandler):
type_id = "wildcard"
@staticmethod
def _apply_pool_filter(
options: list[dict[str, Any]],
expr: str,
*,
exclude_null: bool,
) -> list[dict[str, Any]]:
"""Narrow an option pool by a boolean sub-category expression.
`expr` is parsed via the shared subcat_filter matcher and tested
against each non-null option's tag set (`sub_categories`). Empty
/ whitespace `expr` ⇒ no tag filtering. The null option
(`is_null`) is governed solely by `exclude_null`; the expression
never applies to it (spec §3.4).
"""
ast = _parse_subcat(expr) if isinstance(expr, str) else None
out: list[dict[str, Any]] = []
for o in options:
if o.get("is_null"):
if not exclude_null:
out.append(o)
continue
if _subcat_matches(ast, set(o.get("sub_categories") or [])):
out.append(o)
return out
@classmethod
def validate_payload(cls, payload: dict[str, Any]) -> None:
if not isinstance(payload, dict):
@@ -188,6 +371,56 @@ class WildcardHandler(ModuleHandler):
options = payload.get("options")
if not isinstance(options, list):
raise ValueError("wildcard payload.options must be a list")
# Sub-category registry — the allowed tag names. Validated first
# so each option's membership + tag_groups can be checked against
# it. `validate_subcat_name` enforces the §3.6 name rules (single
# token; no whitespace / boolean-grammar / ref chars; not a
# reserved word incl. `null`).
registry_list = payload.get("sub_categories", [])
if not isinstance(registry_list, list):
raise ValueError("wildcard payload.sub_categories must be a list")
registry: set[str] = set()
for i, sc in enumerate(registry_list):
if not isinstance(sc, str):
raise ValueError(
f"wildcard payload.sub_categories[{i}] must be a string"
)
name_err = validate_subcat_name(sc)
if name_err:
raise ValueError(
f"wildcard payload.sub_categories[{i}]: {name_err}"
)
registry.add(sc)
# Optional UI-only grouping of registry tags into named axes. The
# engine ignores it at resolve time; validate the shape, that
# every member is a registry tag, and that a tag lives in at most
# one group.
tag_groups = payload.get("tag_groups")
if tag_groups is not None:
if not isinstance(tag_groups, dict):
raise ValueError("wildcard payload.tag_groups must be an object")
seen_grouped: set[str] = set()
for gname, members in tag_groups.items():
if not isinstance(gname, str) or not isinstance(members, list):
raise ValueError(
"wildcard payload.tag_groups entries must be "
"name -> string[]"
)
for mem in members:
if mem not in registry:
raise ValueError(
f"wildcard payload.tag_groups[{gname!r}]: {mem!r} "
f"not in sub_categories registry"
)
if mem in seen_grouped:
raise ValueError(
f"wildcard payload.tag_groups: {mem!r} appears in "
f"more than one group"
)
seen_grouped.add(mem)
seen_ids: set[str] = set()
null_count = 0
for i, opt in enumerate(options):
@@ -220,12 +453,12 @@ class WildcardHandler(ModuleHandler):
f"wildcard payload.options[{i}] null option "
f"{opt_id!r} must have empty value (got {value!r})"
)
sub_null = opt.get("sub_category")
if sub_null is not None and sub_null != "":
null_subs = opt.get("sub_categories", [])
if null_subs:
raise ValueError(
f"wildcard payload.options[{i}] null option "
f"{opt_id!r} must have no sub_category (got "
f"{sub_null!r})"
f"{opt_id!r} must have no sub_categories (got "
f"{null_subs!r})"
)
else:
if not isinstance(value, str) or value == "":
@@ -242,11 +475,22 @@ class WildcardHandler(ModuleHandler):
raise ValueError(
f"wildcard payload.options[{i}].weight must not be negative"
)
sub_cat = opt.get("sub_category")
if sub_cat is not None and not isinstance(sub_cat, str):
subs = opt.get("sub_categories", [])
if not isinstance(subs, list):
raise ValueError(
f"wildcard payload.options[{i}].sub_category must be a string"
f"wildcard payload.options[{i}].sub_categories must be a list"
)
for s in subs:
if not isinstance(s, str):
raise ValueError(
f"wildcard payload.options[{i}].sub_categories must be "
f"strings"
)
if s not in registry:
raise ValueError(
f"wildcard payload.options[{i}].sub_categories: {s!r} "
f"not in sub_categories registry"
)
if null_count > 1:
raise ValueError(
f"wildcard payload may have at most one null option "
@@ -277,35 +521,6 @@ class WildcardHandler(ModuleHandler):
raise ValueError(
f"wildcard payload.var_binding {binding!r} is not a valid identifier"
)
sub_categories = payload.get("sub_categories", [])
if not isinstance(sub_categories, list):
raise ValueError("wildcard payload.sub_categories must be a list")
for i, sc in enumerate(sub_categories):
if not isinstance(sc, str):
raise ValueError(
f"wildcard payload.sub_categories[{i}] must be a string"
)
# `null` is a reserved keyword in the nested-ref filter
# syntax — `@{uuid:warm,null}` opts the null option into
# the pool. Forbid sub_category names colliding with it.
if sc == "null":
raise ValueError(
f"wildcard payload.sub_categories[{i}] 'null' is reserved "
f"(used by the nested ref filter syntax)"
)
# Sub-category name must not contain characters used by the
# nested-ref grammar: `,` (subcat list separator), `:`
# (subcat segment opener), `}` (ref terminator), `#` (name
# segment opener), `@` / `{` (ref-prefix collision risk).
# Editor + API guard against this so the regex never sees
# ambiguous input.
bad = set(sc) & _REF_FORBIDDEN_CHARS
if bad:
raise ValueError(
f"wildcard payload.sub_categories[{i}] {sc!r} contains "
f"forbidden characters {sorted(bad)} "
f"(would break the @{{uuid:subcat}} syntax)"
)
@classmethod
def resolve(
@@ -342,21 +557,23 @@ class WildcardHandler(ModuleHandler):
return {binding: resolve_text(value, resolve_ctx)}
# else: pinned target is missing — fall through to random.
# `category_filter` narrows the option pool to entries whose
# `sub_category` is in the chosen list. Empty list / None = no
# filter (all sub-categories eligible). Options with a `None`
# / missing sub_category are excluded by an explicit filter
# (they're "unsorted" and the user opted into a curated set) —
# EXCEPT the null option (`is_null: True`), which is an
# orthogonal "no-output" slot and survives every category
# filter. See docs/superpowers/specs/2026-05-24-null-wildcard-option-design.md.
# `category_filter` narrows the option pool to entries whose tag
# set (`sub_categories`) satisfies a boolean expression
# (`and`/`or`/`not`/parens; comma=or). Empty / missing = no tag
# filter. `exclude_null` is a separate flag: when true the null
# option is dropped, otherwise it is an orthogonal "no-output"
# slot that survives every filter (the expression never applies
# to it). Untagged non-null options bypass the expression unless
# a bare tag term excludes them (spec §3.4). See the shared
# matcher in engine/syntax/subcat_filter.py.
category_filter = instance.get("category_filter")
if isinstance(category_filter, list) and category_filter:
allowed_cats = set(category_filter)
options = [
o for o in options
if o.get("is_null") or o.get("sub_category") in allowed_cats
]
exclude_null = bool(instance.get("exclude_null", False))
if (isinstance(category_filter, str) and category_filter.strip()) or exclude_null:
options = cls._apply_pool_filter(
options,
category_filter if isinstance(category_filter, str) else "",
exclude_null=exclude_null,
)
enabled = instance.get("enabled_options")
if enabled is not None:
@@ -400,16 +617,22 @@ class WildcardHandler(ModuleHandler):
if my_id:
constraints = ctx.get("__wp_constraints__") if ctx is not None else None
picks = ctx.get("__wp_picks__") if ctx is not None else None
# First-instance one-shot: pass the ctx-resident consumed
# set so the apply_constraints helper marks fired
# constraints + skips them on subsequent target instances.
consumed = (
ctx.setdefault("__wp_consumed_constraints__", set())
# SP3 reach selector: thread the ctx-resident per-constraint
# hit counter so each covering constraint re-weights this
# firing target instance (no more one-shot / consumed-set).
# `firing_uid` identifies THIS direct top-level instance for
# the `pick` selector's occurrence match.
hits = (
ctx.setdefault("__wp_constraint_hits__", {})
if isinstance(ctx, dict) else {}
)
firing_uid = (
ctx.get("__wp_current_module_uid__")
if isinstance(ctx, dict) else None
)
options, any_constraint_applied = apply_constraints_for_target(
options, my_id, constraints, picks, ctx["__wp_warnings__"],
consumed=consumed,
hits=hits, firing_uid=firing_uid,
)
if any_constraint_applied:
warn_excludes_all(options, my_id or "", ctx["__wp_warnings__"])
@@ -434,6 +657,48 @@ class WildcardHandler(ModuleHandler):
effective_seed = chain_seed
rng = _derive_module_rng(effective_seed, binding)
# SP2a multi-select: a count range other than (1, 1) picks N options
# without replacement and binds a ListVar. The null option is never in
# the multi-pick pool (exclude_null is single-pick-only); "maybe
# nothing" is min=0. (1, 1) falls through to the single-pick path
# below unchanged — same RNG draws, so existing locked seeds reproduce.
raw_min = _coerce_pick_int(instance.get("pick_min"), 1)
raw_max = _coerce_pick_int(instance.get("pick_max"), raw_min)
lo = max(0, min(raw_min, raw_max))
hi = max(lo, raw_max)
if not (lo == 1 and hi == 1):
pool = [o for o in options if not o.get("is_null")]
weights = [max(0.0, float(o.get("weight", 1))) for o in pool]
pool_n = len(pool)
independent = bool(instance.get("pick_independent", False))
if independent:
# SP2c: independent multi-pick draws WITH replacement (repeats
# allowed), mirroring the inline `~` flag — so the count is the
# requested range and is NOT clamped to the pool size.
count = lo if lo == hi else lo + int(rng.random() * (hi - lo + 1))
picks = [
p for p in (_pick_weighted(pool, rng) for _ in range(count))
if p is not None
]
else:
lo_c, hi_c = min(lo, pool_n), min(hi, pool_n)
count = lo_c if lo_c == hi_c else lo_c + int(rng.random() * (hi_c - lo_c + 1))
picks = pick_k_unique(pool, weights, count, rng)
sep = instance.get("pick_separator")
if not isinstance(sep, str):
sep = ", "
items: list[str] = []
saved_rng_multi = ctx.get("__wp_rng__")
ctx["__wp_rng__"] = rng
try:
multi_ctx = build_resolve_ctx(ctx, surface="wildcard")
for opt in picks:
items.append(resolve_text(str(opt.get("value", "")), multi_ctx))
finally:
ctx["__wp_rng__"] = saved_rng_multi
_record_pick_multi(ctx, picks, sep)
return {binding: ListVar(items, sep)}
chosen = _pick_weighted(options, rng)
if chosen is None:
return {binding: ""}
@@ -444,39 +709,9 @@ class WildcardHandler(ModuleHandler):
# constraint exception keys on the literal empty pick value).
_record_pick(ctx, chosen)
# First-instance positional-claim failsafe. This wildcard may
# CARRY a constraint's target via an `@{target}` ref in one of
# its options. The pair badge already assigned the constraint to
# this carrier, so once the carrier ROLLS — regardless of which
# option won, including the `null` / empty-value option — it
# claims the constraint so it doesn't spill onto a later target
# instance. `claim_carrier_constraints` is a no-op when the
# chosen option DID resolve the ref (the nested-resolve path
# below already consumed it) or the source isn't picked yet.
#
# CRITICAL: must fire on EVERY roll path, including the
# empty-value early return — a backdrop that rolls its `null`
# option still "rolled" and must still claim its carried
# constraints. The 2026-05-26 bug was calling this only after
# resolve_text, so a null/empty pick skipped the claim and the
# constraint either spilled or surfaced a misleading
# never_applied warning.
def _claim_carried() -> None:
if ctx is None:
return
from engine.modules._constraints import claim_carrier_constraints
claim_carrier_constraints(
options,
ctx.get("__wp_constraints__"),
ctx.get("__wp_picks__"),
ctx.get("__wp_consumed_constraints__"),
)
value = str(chosen.get("value", ""))
if not value:
# Empty / null pick — no ref to resolve, but the carrier
# still rolled, so claim before returning.
_claim_carried()
# Empty / null pick — no ref to resolve.
return {binding: ""}
# Swap `ctx['__wp_rng__']` to the per-module rng for the
@@ -495,12 +730,22 @@ class WildcardHandler(ModuleHandler):
ctx["__wp_rng__"] = rng
try:
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
# SP3 nested-pick reach: THIS wildcard is the carrier of any
# `@{T}` ref inside `value`. Stamp (its per-instance uid, the
# chosen option's id) on the resolve frame so a constraint on
# T whose `pick` selector lists this (carrier_uid, option_id)
# nested occurrence matches. A `pick` keyed on a DEEPER
# carrier won't match this top-level uid — that's by design
# (only one-hop carriers are individually pickable). Set on
# the freshly-built ctx (carrier defaults None); no restore
# needed — resolve_ctx is scoped to this resolve_text call.
set_carrier = getattr(resolve_ctx, "set_carrier", None)
if callable(set_carrier):
set_carrier(
ctx.get("__wp_current_module_uid__"), chosen.get("id"),
)
resolved = resolve_text(value, resolve_ctx)
finally:
ctx["__wp_rng__"] = saved_rng
# Claim AFTER resolve_text so a chosen option that DID carry the
# ref gets consumed-with-effect by the nested-resolve path
# first; the claim then no-ops on it.
_claim_carried()
return {binding: resolved}
+124 -34
View File
@@ -20,11 +20,14 @@ from engine.modules.snapshot import coerce_legacy_module
logger = logging.getLogger(__name__)
# `@{uuid}` ref matcher — mirror of engine.syntax.tokenize._REF_RE.
# Used by the never_applied warning classifier to tell "target has a
# carrier/instance somewhere in the chain" apart from "target uuid is
# nowhere to be found".
_PIPELINE_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#[^#:}@{]*)?(?::[^}]*)?\}")
# `@{uuid}` ref matcher — mirror of engine.syntax.tokenize._REF_RE
# (4-segment: uuid + optional #name / :expr / !null; only the uuid is
# captured here). Used by the never_applied warning classifier to tell
# "target has a carrier/instance somewhere in the chain" apart from
# "target uuid is nowhere to be found".
_PIPELINE_REF_RE = re.compile(
r"@\{([0-9a-f]{8})(?:#[^#:}@{!]*)?(?::[^}!]*)?(?:![^}]*)?\}"
)
def _module_field(module: Any, key: str) -> Any:
@@ -249,10 +252,12 @@ class PipelineEngine:
ctx.setdefault("__wp_warnings__", [])
ctx.setdefault("__wp_trace__", [])
ctx.setdefault("__wp_internal_flags__", {})
# Tracks constraint module ids that have already fired against
# their first downstream target instance. See
# docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
ctx.setdefault("__wp_consumed_constraints__", set())
# SP3 reach selector: per-constraint firing count keyed by
# `__constraint_module_id__`. Drives first/next/all/pick
# coverage in apply_constraints_for_target + the never_applied /
# partial_reach finalisation below. Replaces the pre-SP3
# one-shot consumed-set.
ctx.setdefault("__wp_constraint_hits__", {})
for index, module in enumerate(modules):
# Normalise id/type/enabled reads to work for both dicts and objects.
@@ -275,12 +280,21 @@ class PipelineEngine:
# imported from someone else's machine), letting WP_Debug
# render a readable chip instead of a raw uuid.
_module_name = module.get("name", "") or ""
# Per-insertion bundle discriminator. Two instances of one
# library wildcard (a bundle inserted twice) carry DISTINCT
# `bundle_origin` values (insert.ts stamps each child with
# its immediate BundleInstance._uid). Threaded into ctx so a
# constraint can bind to the source pick from its OWN bundle
# copy (task_5200c1fc). Empty string when the module has no
# bundle origin (top-level / manually-added / legacy).
_module_bundle_origin = module.get("bundle_origin", "") or ""
else:
_module_id = getattr(module, "id", "")
_module_uid = getattr(module, "_uid", "") or ""
_module_type_raw = getattr(module, "type", None) or ""
_module_enabled = getattr(module, "enabled", True)
_module_name = getattr(module, "name", "") or ""
_module_bundle_origin = getattr(module, "bundle_origin", "") or ""
if not _module_enabled:
# Disabled modules don't run — but the trace row still
@@ -351,15 +365,24 @@ class PipelineEngine:
# wildcard handler matches THIS against a constraint's
# `target_wildcard_id` (which is a library uuid).
# - `__wp_current_module_uid__` = per-instance uid (`_uid`).
# The constraint handler keys its consumed-set entry on
# THIS so two instances of the same library constraint
# entry are independent one-shots (per CLAUDE.md: "author
# multiple constraint modules" to affect multiple
# targets). Falls back to the library id when `_uid` is
# absent (legacy / hand-built test modules).
# This is the BARE per-instance uid (no node prefix). The
# constraint handler keys its `__constraint_module_id__`
# (the SP3 per-constraint hit counter) on THIS so two
# instances of the same library constraint entry reach
# INDEPENDENTLY (per CLAUDE.md: "author multiple constraint
# modules" to affect multiple targets). It's ALSO the
# `firing_uid` a direct `pick` selector matches against —
# so a persisted `pick` must carry the bare `_uid`, not the
# UI's badge rowKey (`${nodeId}#${_uid}`). Falls back to the
# library id when `_uid` is absent (legacy / test modules).
ctx["__wp_current_module_id__"] = _module_id
ctx["__wp_current_module_uid__"] = _module_uid or _module_id
ctx["__wp_current_module_name__"] = _module_name
# See the `_module_bundle_origin` derivation above — exposed to
# handlers (wildcard_handler records it on the pick; constraint_
# handler captures it on the registered meta) so source-instance
# binding can match a constraint to its own bundle copy's pick.
ctx["__wp_current_module_bundle_origin__"] = _module_bundle_origin
meta = _extract_static_meta(module)
try:
bindings = resolve_module(snapshot, ctx)
@@ -466,20 +489,22 @@ class PipelineEngine:
ctx.pop("__wp_current_module_id__", None)
ctx.pop("__wp_current_module_uid__", None)
ctx.pop("__wp_current_module_name__", None)
ctx.pop("__wp_current_module_bundle_origin__", None)
# First-instance one-shot semantic: emit a soft (info) warning
# for every registered constraint whose target instance never
# came up during the chain. Surfaces in WP_Debug so the user
# sees "you put a constraint here but nothing downstream took
# it" — the chain still succeeds.
# See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
# SP3 reach selector finalisation: emit `constraint_never_applied`
# for every registered constraint whose selector covered ZERO
# firing target instances (hit count 0). Surfaces in WP_Debug so
# the user sees "you put a constraint here but nothing downstream
# took it" — the chain still succeeds. The `partial_reach` pass
# below covers constraints that DID fire but reached fewer
# instances than a `next N` / `pick` selector asked for.
#
# Dedup by cid — sibling instances of the same library entry
# share `__constraint_module_id__`, so they show up as two
# entries in the bucket. Without the dedup the user sees the
# same warning twice for the same logical constraint (2026-05-26).
constraints_bucket = ctx.get("__wp_constraints__") or []
consumed_set = ctx.get("__wp_consumed_constraints__") or set()
hits = ctx.get("__wp_constraint_hits__") or {}
catalog = ctx.get("__wp_catalog__")
warned_cids: set[str] = set()
if isinstance(constraints_bucket, list):
@@ -487,7 +512,7 @@ class PipelineEngine:
if not isinstance(c, dict):
continue
cid = c.get("__constraint_module_id__")
if not cid or cid in consumed_set:
if not cid or hits.get(cid, 0) != 0:
continue
if cid in warned_cids:
continue
@@ -523,24 +548,22 @@ class PipelineEngine:
cid_suffix = _build_ref_name_suffix(c.get("__constraint_library_name__"))
tgt_suffix = _build_ref_name_suffix(target_name)
# Classify the cause so the message is actionable.
# With the carrier-claim failsafe in place, a carrier
# that rolls ANY option claims its constraint — so an
# unconsumed constraint whose target IS present in the
# chain means the target instance/carrier ran BEFORE
# this constraint registered (ordering), or sits in a
# Reach is downstream-relative: a constraint only applies
# to target occurrences that resolve AFTER it registers.
# So a never-applied constraint whose target IS present in
# the chain means every occurrence ran BEFORE this
# constraint registered (ordering), or sits in a
# branch/Context this run didn't reach. Absent target =
# a wrong/stale uuid.
present = _target_present_in_chain(
target_uuid, modules, catalog,
)
if present:
# Target IS in the chain. With the carrier-claim
# failsafe, any carrier/instance that ROLLS after the
# constraint registers claims it — so reaching this
# branch means the target's only appearance(s) ran
# Target IS in the chain but the selector covered no
# firing occurrence — every appearance resolved
# BEFORE this constraint registered (e.g. a source
# wildcard that nests its own @{target}), or live in
# a branch / Context this run didn't execute, or are
# wildcard that nests its own @{target}), or lives in
# a branch / Context this run didn't execute, or is
# disabled. Deliberately NOT prescribing "move the
# constraint up" — that's wrong when the target's
# carrier is also the constraint's source (the source
@@ -591,4 +614,71 @@ class PipelineEngine:
"message": message,
})
# SP3 partial-reach finalisation: a constraint that DID fire but
# whose `next N` / `pick` selector matched FEWER occurrences than
# requested. Distinct from never_applied (which fired zero times)
# — this is "the selector asked for more than the chain offered
# downstream". `all` / `first` can't be partial. Deduped by cid,
# same as never_applied.
partial_warned: set[str] = set()
if isinstance(constraints_bucket, list):
for c in constraints_bucket:
if not isinstance(c, dict):
continue
cid = c.get("__constraint_module_id__")
seen = hits.get(cid, 0) if cid else 0
if not cid or seen == 0 or cid in partial_warned:
continue
sel = c.get("target_select") or {"mode": "all"}
mode = sel.get("mode", "all")
if mode == "next":
try:
want = int(sel.get("count", 1))
except (TypeError, ValueError):
want = 1
elif mode == "pick":
want = len(sel.get("picks") or [])
else:
continue
# LIMITATION (holistic-review L1): `seen` is the per-cid
# TOTAL target-encounter count (`hits[cid]`, bumped on
# every firing in apply_constraints_for_target), NOT the
# count of picks that actually MATCHED. So a `pick`
# partial signal is imprecise: it compares encounters to
# `len(picks)` rather than matched-vs-listed. A precise
# count would need a separate ctx-threaded matched-pick
# counter (new bookkeeping across the _constraints apply
# boundary) — deferred to avoid over-plumbing. `next` is
# exact (it covers the first N encounters by construction).
if seen >= want:
continue
partial_warned.add(cid)
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
if isinstance(warnings_bucket, list):
warnings_bucket.append({
# Spec: partial reach is INFO, not warn — the
# selector simply found fewer downstream targets
# than requested this run (not an error condition).
# `constraint_never_applied` stays `warn` (a
# constraint that fired zero times is worth a louder
# signal). DebugViewer maps `info` → blue/accent.
"type": "constraint_partial_reach",
"severity": "info",
"module_id": cid,
"source_field": "",
"position": 0,
"token_index": None,
"detail": {
"constraint_id": cid,
"target_wildcard_id": c.get("target_wildcard_id"),
"requested": want,
"reached": seen,
},
"message": (
f"constraint reach selector ({mode}) asked for "
f"{want} target instance(s) but only {seen} "
f"resolved downstream this run."
),
})
return ctx
+41 -1
View File
@@ -14,6 +14,9 @@ from typing import Any, Literal
InstanceFieldType = Literal[
"string", "list[string]", "dict[string,number]", "dict[string,dict]",
"dict[string,string]",
# Generic object — used by SP3 `target_select`, whose interior shape is
# validated by the constraint handler rather than this type-only schema.
"dict",
"boolean", "number", "list[dict]",
]
@@ -22,9 +25,28 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
"variable_binding": "string",
"enabled_options": "list[string]",
"option_weights": "dict[string,number]",
"category_filter": "list[string]",
# `category_filter` is a boolean sub-category expression string
# (SP1). The separate `exclude_null` instance flag is declared
# here + in the TS `_shell.ts` twin together in the SP1 UI chunk
# (keeps the parity guard green); the engine resolver already
# reads it directly.
"category_filter": "string",
"exclude_null": "boolean",
"locked_seed": "number",
"internal": "boolean",
# SP2a multi-select: per-instance count range (pick_min..pick_max; a
# range other than 1..1 resolves N options without replacement into a
# list-valued variable) + the separator used to join it for a bare
# `$var`. Instance-local: NOT part of a standalone module's published
# payload — but bundle children freeze the whole instance as a
# snapshot, so a published bundle DOES carry them (intentional
# frozen-snapshot fidelity).
"pick_min": "number",
"pick_max": "number",
"pick_separator": "string",
# SP2c: multi-pick draws WITH replacement (repeats allowed) when true,
# without (unique) when false/absent. Mirrors the inline `~` flag.
"pick_independent": "boolean",
# `mode` and `pinned_option_id` removed in v2 — resolve mode is
# implicit in pool state. Engine handler still reads them when
# present in legacy snapshots; the schema validator now flags
@@ -86,6 +108,9 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
"exception_mode_overrides": "dict[string,string]",
"exception_factor_overrides": "dict[string,number]",
"extra_exceptions": "list[dict]",
# SP3 reach selector override. Interior shape (mode/count/picks) is
# validated by ConstraintHandler; here it is type-only ("is a dict").
"target_select": "dict",
},
}
@@ -101,6 +126,8 @@ def _matches_type(value: Any, spec: InstanceFieldType) -> bool:
return isinstance(value, list) and all(isinstance(x, str) for x in value)
if spec == "list[dict]":
return isinstance(value, list) and all(isinstance(x, dict) for x in value)
if spec == "dict":
return isinstance(value, dict)
if spec == "dict[string,number]":
return (
isinstance(value, dict)
@@ -140,4 +167,17 @@ def validate_instance(kind: str, instance: dict[str, Any]) -> list[str]:
warnings.append(
f"{kind}.{field} type mismatch: expected {spec}, got {type(value).__name__}"
)
# SP2a advisory soft-warns for the pick range (not type errors).
if kind == "wildcard":
pmin = instance.get("pick_min")
pmax = instance.get("pick_max")
nums = (int, float)
if (isinstance(pmin, nums) and not isinstance(pmin, bool)
and isinstance(pmax, nums) and not isinstance(pmax, bool)):
if pmin == 0 and pmax == 0:
warnings.append("wildcard pick range 0..0 — always empty (resolves to nothing)")
elif pmax < pmin:
warnings.append(
f"wildcard.pick_max ({pmax}) is below pick_min ({pmin}) — clamped to pick_max"
)
return warnings
+235 -143
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import logging
from engine.syntax.subcat_filter import matches as _subcat_matches
from engine.syntax.subcat_filter import parse as _parse_subcat
from engine.syntax.tokenize import tokenize_text
from engine.syntax.types import (
CycleDetectedError,
@@ -17,6 +19,7 @@ from engine.syntax.types import (
Token,
TokenKind,
UnknownRefError,
deref_var_value,
)
logger = logging.getLogger(__name__)
@@ -114,7 +117,12 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
message=f"Unknown variable ${name}",
)
return ""
return str(value)
# SP2a list accessor: `$name` joins a ListVar with its separator;
# `$name.K` indexes (0-based, out-of-range -> ""). A plain string behaves
# as a 1-element list so `$str.0` == `$str` and `$str.1` == "". The
# accessor + ListVar-fold contract lives in deref_var_value
# (engine/syntax/types.py); the derivation + converter reads share it.
return deref_var_value(value, tok.meta.get("index"))
def _push_warning(ctx: ResolveContext, **fields) -> None:
@@ -209,53 +217,120 @@ def _weighted_pick_index(weights: list[float], rng) -> int:
return len(weights) - 1
def pick_k_unique(items, weights, k, rng):
"""Pick ``k`` items from ``items`` without replacement, weighted by the
parallel ``weights`` list, returned in pick order. ``k`` is clamped to
``len(items)``; ``k <= 0`` -> ``[]``.
Shared by the inline ``{N$$sep$$}`` block (``_resolve_multi_pick``) and by
multi-select wildcards (``wildcard_handler``). Draws from ``rng`` in the
same order the inline block always has, so seed behavior is unchanged.
"""
n = min(max(0, k), len(items))
available_idx = list(range(len(items)))
available_w = list(weights)
chosen = []
for _ in range(n):
if not available_idx:
break
j = _weighted_pick_index(available_w, rng)
chosen.append(items[available_idx.pop(j)])
available_w.pop(j)
return chosen
def _resolve_multi_pick(
tok: Token,
ctx: ResolveContext,
depth: int,
visited: tuple[str, ...],
) -> str:
"""Pick `count` branches without replacement, weighted, joined by `sep`.
"""Resolve a multi-pick block, joined by `sep`.
Each chosen branch is recursively resolved.
SP2b: `count` is a range `[min, max]` (a fixed `{N$$}` has min==max), and
`independent` (the `~` flag) toggles unique-without-replacement (default)
vs N draws WITH replacement. A lone nested-ref body in unique mode draws N
DISTINCT options from the ref's filtered pool (a per-site SP2a). Each chosen
value is recursively resolved.
"""
# Seedless assembler surface — see _resolve_inline_pick. Render verbatim
# rather than freezing a deterministic seed-0 multi-pick.
if ctx.surface == "assembler":
return tok.raw
count: int = tok.meta.get("count", 0)
cmin: int = tok.meta.get("min", tok.meta.get("count", 0))
cmax: int = tok.meta.get("max", tok.meta.get("count", 0))
independent: bool = bool(tok.meta.get("independent", False))
sep: str = tok.meta.get("sep", "")
branches: list[str] = list(tok.meta.get("branches", []))
# Range count: a fixed N (min==max) does NOT draw from rng, so a `{N$$}`
# reproduces the pre-SP2b seed sequence byte-for-byte. A true range draws.
count = cmin if cmin == cmax else ctx.rng.randint(cmin, cmax)
if count <= 0:
return ""
# Clamp count to available branches (without-replacement constraint)
# Lone nested-ref body, unique mode, count>=2, wildcard surface → pick N
# DISTINCT options from the ref's filtered pool (per-site SP2a multi-select).
# count==1 stays on the legacy branch path below so its seed is unchanged.
if not independent and count >= 2 and len(branches) == 1 and ctx.surface == "wildcard":
bt = tokenize_text(branches[0])
if len(bt) == 1 and bt[0].kind == TokenKind.REF:
ref_tok = bt[0]
ref_uuid = ref_tok.meta.get("uuid", "")
pool = ref_option_pool(ref_tok, ctx)
if not pool:
return ""
values = [str(o.get("value", "")) for o in pool]
weights = [float(o.get("weight", 1) or 1) for o in pool]
chosen = pick_k_unique(values, weights, count, ctx.rng)
# The multi-picked ref is the carrier of any `@{X}` inside the
# values it resolves. We pick on VALUES (not option dicts) so a
# specific option id isn't tracked here — set carrier to
# (ref_uuid, None) for the recursion. option_id None can't match
# a real `pick` entry, so a deeper `@{X}` falls back to
# positional first/next/all (design: multi-pick children aren't
# individually pickable). Restore the enclosing carrier after.
get_carrier = getattr(ctx, "get_carrier", None)
set_carrier = getattr(ctx, "set_carrier", None)
saved_carrier = (
get_carrier() if callable(get_carrier) else None
)
if callable(set_carrier):
set_carrier(ref_uuid, None)
try:
return sep.join(
_resolve_tokens(
tokenize_text(v), ctx, depth=depth + 1,
visited=visited + (ref_uuid,),
) if v else ""
for v in chosen
)
finally:
if callable(set_carrier) and saved_carrier is not None:
set_carrier(*saved_carrier)
weights = [_parse_branch_weight(b) for b in branches]
branch_values = [_strip_branch_weight(b) for b in branches]
resolved: list[str] = []
if independent:
# N draws WITH replacement over the branches; each resolved
# independently (a lone ref re-rolls every time → variety from nesting).
for _ in range(count):
j = _weighted_pick_index(weights, ctx.rng)
v = branch_values[j]
resolved.append(
_resolve_tokens(tokenize_text(v), ctx, depth=depth, visited=visited)
if v else ""
)
return sep.join(resolved)
# Unique over branches (legacy path, now range-aware): N distinct branches.
n_picks = min(count, len(branches))
if n_picks == 0:
return ""
# Weighted without-replacement: branch weights default to 1; if a branch
# uses the `N::value` form (a feature of the inline-pick weight micro-syntax),
# the weight is parsed from the prefix. For simplicity, this implementation
# treats all branches as weight=1 unless they begin with `<int>::`.
weights = [_parse_branch_weight(b) for b in branches]
branch_values = [_strip_branch_weight(b) for b in branches]
chosen_indices: list[int] = []
available_indices = list(range(len(branches)))
available_weights = list(weights)
for _ in range(n_picks):
if not available_indices:
break
idx = _weighted_pick_index(available_weights, ctx.rng)
chosen_indices.append(available_indices.pop(idx))
available_weights.pop(idx)
resolved: list[str] = []
for idx in chosen_indices:
branch_text = branch_values[idx]
chosen_values = pick_k_unique(branch_values, weights, n_picks, ctx.rng)
for branch_text in chosen_values:
if not branch_text:
resolved.append("")
continue
@@ -265,6 +340,111 @@ def _resolve_multi_pick(
return sep.join(resolved)
def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
"""Filtered + constrained option pool a ``@{uuid[#name][:expr][!null]}`` ref
resolves against — catalog lookup, the ``:expr``/``!null`` sub-category
filter, and chain constraints. Shared by ``_resolve_ref`` (single weighted
pick) and the SP2b lone-ref multi-pick path.
Returns ``[]`` (pushing the same ``unknown_ref`` /
``ref_subcategory_empty_pool`` warnings, and raising ``UnknownRefError`` in
strict mode for an unknown ref) when there is nothing to pick. Does NOT
apply the depth / cycle / surface guards — those stay in ``_resolve_ref``
and the multi-pick caller.
"""
uuid = tok.meta.get("uuid", "")
module = ctx.get_module(uuid)
if module is None:
if ctx.strict:
raise UnknownRefError(uuid)
cached_name = tok.meta.get("name") if hasattr(tok, "meta") else None
_push_warning(
ctx,
type="unknown_ref",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"uuid": uuid, "name": cached_name},
message=f"Unknown wildcard ref @{{{uuid}{'#' + cached_name if cached_name else ''}}}",
)
return []
payload_dict = module.get("payload")
if isinstance(payload_dict, dict):
options = list(payload_dict.get("options", []))
else:
options = list(module.get("options", []))
filter_expr = tok.meta.get("filter_expr")
exclude_null = bool(tok.meta.get("exclude_null"))
if (isinstance(filter_expr, str) and filter_expr.strip()) or exclude_null:
ast = _parse_subcat(filter_expr) if isinstance(filter_expr, str) else None
options = [
o for o in options
if isinstance(o, dict)
and (
(o.get("is_null") and not exclude_null)
or (
not o.get("is_null")
and _subcat_matches(ast, set(o.get("sub_categories") or []))
)
)
]
if not options:
_push_warning(
ctx,
type="ref_subcategory_empty_pool",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={
"uuid": uuid,
"filter_expr": filter_expr,
"exclude_null": exclude_null,
},
message=(
f"@{{{uuid}:{filter_expr or ''}"
f"{'!null' if exclude_null else ''}}} matched no options"
),
)
return []
get_constraints = getattr(ctx, "get_constraints", None)
get_picks = getattr(ctx, "get_picks", None)
if callable(get_constraints) and callable(get_picks):
constraints = get_constraints()
if constraints:
from engine.modules._constraints import (
apply_constraints_for_target,
warn_excludes_all,
)
# SP3: share the ctx-resident hit counter so a nested-ref
# target occurrence counts toward the same first/next
# coverage as direct top-level instances. `carrier_ctx` is
# the identity of the carrier wildcard whose chosen option
# value textually contains THIS `@{uuid}` ref — set on the
# resolve frame by the enclosing wildcard handler (top-level
# carrier) or by `_resolve_ref` (deeper carrier). A `pick`
# selector's `nested` occurrence matches on it; first / next
# / all ignore it. None when no carrier is set (a ref at the
# surface root) — then `pick` simply won't cover.
get_hits = getattr(ctx, "get_constraint_hits", None)
hits = get_hits() if callable(get_hits) else {}
get_carrier_ctx = getattr(ctx, "get_carrier_ctx", None)
carrier_ctx = get_carrier_ctx() if callable(get_carrier_ctx) else None
options, any_applied = apply_constraints_for_target(
options, uuid, constraints, get_picks(), ctx.warnings,
hits=hits, firing_uid=None, carrier_ctx=carrier_ctx,
)
if any_applied:
warn_excludes_all(options, uuid, ctx.warnings)
return options
def _resolve_ref(
tok: Token,
ctx: ResolveContext,
@@ -312,8 +492,10 @@ def _resolve_ref(
)
return ""
# Surface check
if ctx.surface != "wildcard":
# Surface check — wildcard options AND derivation action values may host
# `@{}` refs (both run resolve_text inside a seeded Context with the chain
# constraint bucket threaded). combine + assembler stay gated.
if ctx.surface not in ("wildcard", "derivation"):
if ctx.strict:
raise RefOutOfSurfaceError(uuid, ctx.surface)
# Try to resolve module name for the warning detail (best-effort)
@@ -332,121 +514,13 @@ def _resolve_ref(
)
return ""
# Catalog lookup
module = ctx.get_module(uuid)
if module is None:
if ctx.strict:
raise UnknownRefError(uuid)
# Surface the cached `#name` from the ref token if the original
# syntax carried one (`@{uuid#name}`). Lets WP_Debug + RichTextPreview
# render a friendly label even when the target wildcard has been
# deleted from the library — same fallback chain RichTextPreview
# uses for unresolved chips elsewhere.
cached_name = tok.meta.get("name") if hasattr(tok, "meta") else None
_push_warning(
ctx,
type="unknown_ref",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"uuid": uuid, "name": cached_name},
message=f"Unknown wildcard ref @{{{uuid}{'#' + cached_name if cached_name else ''}}}",
)
# Catalog lookup + `:expr`/`!null` filter + chain constraints, factored
# into ref_option_pool (shared with the SP2b lone-ref multi-pick path).
# The strict-unknown raise + the unknown / empty-pool warnings live there.
options = ref_option_pool(tok, ctx)
if not options:
return ""
# Pick weighted option.
# Support both SnapshotEntry shape (payload.options, spec §2.4) and the
# legacy flat shape used by existing unit tests (options at top level).
payload_dict = module.get("payload")
if isinstance(payload_dict, dict):
options = list(payload_dict.get("options", []))
else:
options = list(module.get("options", []))
# Optional per-call sub-category filter: `@{uuid:warm,cool}` keeps
# only options whose `sub_category` is in the requested list. Same
# semantics as chain-level `instance.category_filter` but scoped to
# this specific ref, so a shared library wildcard (e.g. `@{color}`)
# can be narrowed differently at every call site without authoring
# separate library entries. Empty post-filter pool → empty string
# + warning so the user sees the unsatisfiable filter rather than
# silently falling through to the unfiltered list.
#
# Null semantics (inverted 2026-05-25): the wildcard's `is_null`
# option is INCLUDED by default — alongside whatever sub-cats the
# filter lists. The reserved keyword `"null"` in the filter list
# EXCLUDES the null option from the pool. So:
# `@{uuid}` → all options (incl. null)
# `@{uuid:warm}` → warm options + null
# `@{uuid:warm,null}` → warm options, null excluded
# `@{uuid:null}` → all non-null options (filter-only-null
# = "exclude null, no sub-cat filter")
# Sub-category names called literally `"null"` are forbidden by
# WildcardHandler.validate_payload so the keyword can never clash
# with a real sub-cat.
sub_filter = tok.meta.get("sub_categories")
if isinstance(sub_filter, list) and sub_filter:
allowed_subs = set(sub_filter)
exclude_null = "null" in allowed_subs
allowed_subs.discard("null")
options = [
o for o in options
if isinstance(o, dict)
and (
(o.get("is_null") and not exclude_null)
or (
not o.get("is_null")
and (not allowed_subs or o.get("sub_category") in allowed_subs)
)
)
]
if not options:
_push_warning(
ctx,
type="ref_subcategory_empty_pool",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"uuid": uuid, "sub_categories": list(sub_filter)},
message=(
f"@{{{uuid}:{','.join(sub_filter)}}} matched no options"
),
)
return ""
# Apply chain-level constraints whose `target_wildcard_id` matches
# this nested ref's uuid. Pre-2026-05 only top-level wildcards saw
# constraints — nested `@{B}` from inside another wildcard's
# option value silently bypassed every rule, defeating composed
# pipelines. ResolveContext exposes `get_constraints` / `get_picks`
# for this path; both default to empty for hand-built resolve
# contexts in tests so the legacy fast path keeps working.
get_constraints = getattr(ctx, "get_constraints", None)
get_picks = getattr(ctx, "get_picks", None)
if callable(get_constraints) and callable(get_picks):
constraints = get_constraints()
if constraints:
from engine.modules._constraints import (
apply_constraints_for_target,
warn_excludes_all,
)
# First-instance one-shot semantic: thread the consumed
# set so this nested-ref resolve path participates in the
# same one-shot bookkeeping as top-level wildcard rolls.
# See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
get_consumed = getattr(ctx, "get_consumed_constraints", None)
consumed = get_consumed() if callable(get_consumed) else set()
options, any_applied = apply_constraints_for_target(
options, uuid, constraints, get_picks(), ctx.warnings,
consumed=consumed,
)
if any_applied:
warn_excludes_all(options, uuid, ctx.warnings)
chosen = _pick_weighted(options, ctx.rng)
if chosen is None:
return ""
@@ -455,6 +529,24 @@ def _resolve_ref(
if not chosen_value:
return ""
# Recursive resolve with depth+1, visited extended
# Recursive resolve with depth+1, visited extended. This ref'd
# wildcard is the carrier of any `@{X}` inside its OWN chosen option
# value, so stamp (its uuid, the chosen option's id) on the frame for
# the duration of the recursion, then restore the prior carrier. The
# carrier_uid here is the library uuid (a ref'd wildcard has no row
# `_uid`); a `pick` keyed on a top-level row uid won't match it —
# that's the expected deeper-than-one-hop behaviour (positional
# first/next/all cover deeper nesting). Mirrors the rng save/restore.
nested_tokens = tokenize_text(chosen_value)
get_carrier = getattr(ctx, "get_carrier", None)
set_carrier = getattr(ctx, "set_carrier", None)
if callable(get_carrier) and callable(set_carrier):
saved_carrier = get_carrier()
set_carrier(uuid, chosen.get("id"))
try:
return _resolve_tokens(
nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,)
)
finally:
set_carrier(*saved_carrier)
return _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,))
+176
View File
@@ -0,0 +1,176 @@
"""Boolean sub-category filter — parser, matcher, normalizer, validators.
Grammar (precedence not > and > or; or/and are n-ary, flattened):
expr := or
or := and ( ("or" | ",") and )*
and := unary ( "and" unary )*
unary := "not" unary | atom
atom := SUBCAT | "(" or ")"
Mirror: src/manager/parsing/subcatFilter.ts — keep behavior identical
(shared fixture in tests/fixtures/subcat_filter_cases.json).
"""
from __future__ import annotations
import re
from typing import Any
RESERVED = {"and", "or", "not", "null"}
# A sub-category NAME is one safe token (see validate_subcat_name).
_NAME_RE = re.compile(r"^[^\s()!,#:}@{$]+$")
_TOKEN_RE = re.compile(r"\s*(\(|\)|,|[^\s()!,#:}@{$]+)")
class ParseError(ValueError):
pass
def _tokenize(s: str) -> list[str]:
out: list[str] = []
i = 0
while i < len(s):
m = _TOKEN_RE.match(s, i)
if not m:
if s[i:].strip() == "": # only trailing whitespace left
break
raise ParseError(f"unexpected character {s[i]!r}")
out.append(m.group(1))
i = m.end()
return out
def parse(s: str) -> dict[str, Any] | None:
"""Parse an expression string to an AST, or None when empty."""
toks = _tokenize(s)
if not toks:
return None
pos = 0
def peek() -> str | None:
return toks[pos] if pos < len(toks) else None
def eat() -> str:
nonlocal pos
t = toks[pos]
pos += 1
return t
def p_or() -> dict[str, Any]:
kids = [p_and()]
while peek() in ("or", ","):
eat()
kids.append(p_and())
return kids[0] if len(kids) == 1 else {"op": "or", "kids": kids}
def p_and() -> dict[str, Any]:
kids = [p_unary()]
while peek() == "and":
eat()
kids.append(p_unary())
return kids[0] if len(kids) == 1 else {"op": "and", "kids": kids}
def p_unary() -> dict[str, Any]:
if peek() == "not":
eat()
return {"op": "not", "x": p_unary()}
return p_atom()
def p_atom() -> dict[str, Any]:
t = peek()
if t == "(":
eat()
inner = p_or()
if peek() != ")":
raise ParseError("missing closing paren )")
eat()
return inner
if t is None:
raise ParseError("expression incomplete (missing a term)")
if t in ("and", "or", "not", ")", ","):
raise ParseError(f"unexpected token {t!r} (missing an operator/term?)")
eat()
return {"tag": t}
ast = p_or()
if pos < len(toks):
raise ParseError(f"unexpected token {toks[pos]!r} (missing an operator?)")
return ast
def matches(ast: dict[str, Any] | None, tags: set[str]) -> bool:
"""True iff the option's `tags` satisfy the expression. Empty expr => True."""
if ast is None:
return True
if "tag" in ast:
return ast["tag"] in tags
op = ast["op"]
if op == "not":
return not matches(ast["x"], tags)
if op == "and":
return all(matches(k, tags) for k in ast["kids"])
return any(matches(k, tags) for k in ast["kids"]) # or
def reads_as(ast: dict[str, Any] | None) -> str:
"""Canonical pretty-print: flatten same-op chains; parenthesize only a
child whose operator differs from the parent's."""
if ast is None:
return ""
if "tag" in ast:
return ast["tag"]
op = ast["op"]
if op == "not":
c = ast["x"]
inner = reads_as(c)
return f"not ({inner})" if ("op" in c and c["op"] in ("and", "or")) else f"not {inner}"
parts = []
for k in ast["kids"]:
inner = reads_as(k)
if "op" in k and k["op"] in ("and", "or") and k["op"] != op:
parts.append(f"({inner})")
else:
parts.append(inner)
return f" {op} ".join(parts)
def validate_subcat_name(name: str) -> str | None:
"""Return an error string, or None when the name is a valid single token."""
if not name:
return "name is empty"
if name.lower() in RESERVED:
return f"reserved word: {name!r} (and/or/not/null are not allowed)"
if any(ch.isspace() for ch in name):
return "name must not contain whitespace"
if not _NAME_RE.match(name):
bad = next(ch for ch in name if ch in "()!,#:}@{$")
return f"disallowed character: {bad!r}"
return None
def validate_expression(s: str, known: set[str]) -> str | None:
"""Return an error string, or None when the expression is valid against `known`."""
try:
ast = parse(s)
except ParseError as e:
return str(e)
if ast is None:
return None
seen: set[str] = set()
def walk(n: dict[str, Any]) -> None:
if "tag" in n:
seen.add(n["tag"])
return
if n["op"] == "not":
walk(n["x"])
return
for k in n["kids"]:
walk(k)
walk(ast)
for t in seen:
if t.lower() in RESERVED:
return f"reserved word used as a term: {t!r}"
if t not in known:
return f"Unknown sub-category: {t!r}"
return None
+61 -39
View File
@@ -13,26 +13,34 @@ import re
from engine.syntax.types import Token, TokenKind
# `$name` — identifier starts with letter or underscore, then word chars
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
# `@{8hex}`, `@{8hex#name}`, `@{8hex:subcat[,subcat...]}`, or
# `@{8hex#name:subcat[,subcat...]}` — exactly 8 lowercase hex chars,
# optionally followed by a `#name` segment (display label cached at
# write-time so a broken ref still tells the user what the wildcard
# was called), optionally followed by a `:` + comma-separated
# sub-category filter. Filter restricts the nested wildcard's pick to
# options whose `sub_category` is in the supplied list — same
# semantics as instance.category_filter at the chain level, but
# scoped per-call so one library wildcard can be narrowed differently
# from each call site. The name segment is purely informational —
# resolver matches on uuid only.
# `$name` or `$name.K` — identifier starts with letter or underscore, then
# word chars, with an optional `.K` list-index accessor (SP2a). Group 2 = the
# index digits when present.
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?")
# 4-segment ref grammar (fixed order): `@{8hex [#name] [:expr] [!null]}`.
# group 1 — uuid: exactly 8 lowercase hex chars.
# group 2 — optional `#name`: display label cached at write-time so a
# broken ref still tells the user what the wildcard was
# called. Purely informational — the resolver matches uuid.
# group 3 — optional `:expr`: a BOOLEAN sub-category filter expression
# (`and`/`or`/`not`/parens/comma=or). Parsed + matched by the
# shared `engine.syntax.subcat_filter` matcher; narrows the
# nested pick to options whose tag set satisfies the
# expression — same matcher as instance.category_filter.
# group 4 — optional `!null`: the exclude-null marker (drops the null
# option from the nested pick). Separate from the expression.
#
# Name char-class `[^#:}@{]` mirrors the wildcard-name validator so
# names that would re-trigger this regex (or break it) are rejected at
# the editor + API boundary, not at parse time.
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}")
# Multi-pick prefix: {N$$sep$$ where N is one or more digits and sep can be empty.
_MULTI_PREFIX_RE = re.compile(r"^\{(\d+)\$\$(.*?)\$\$", flags=re.DOTALL)
# Name/expr char-classes exclude `!` (the null-marker delimiter) so the
# segments stay unambiguous; sub-category names additionally forbid the
# boolean grammar chars (see `subcat_filter.validate_subcat_name`),
# enforced at the editor + API boundary, not at parse time.
_REF_RE = re.compile(
r"@\{([0-9a-f]{8})(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
)
# Multi-pick prefix: {N$$sep$$ or {N-M~$$sep$$ — group 1 is the count (a fixed
# `N` or a `N-M` range), group 2 is the optional `~` independent flag (repeats
# allowed; absent = unique), group 3 is the separator (may be empty). SP2b.
_MULTI_PREFIX_RE = re.compile(r"^\{(\d+(?:-\d+)?)(~?)\$\$(.*?)\$\$", flags=re.DOTALL)
def _split_top_level_pipes(s: str) -> list[str]:
@@ -54,7 +62,7 @@ def _split_top_level_pipes(s: str) -> list[str]:
def _scan_brace_block(
text: str, start: int
) -> tuple[int, list[str], int | None, str | None] | None:
) -> tuple[int, list[str], tuple[int, int, bool] | None, str | None] | None:
"""Try to scan a `{...}` block starting at `text[start] == '{'`.
Returns:
@@ -97,13 +105,21 @@ def _scan_brace_block(
body_with_braces = "{" + body # match against `{N$$sep$$...`
multi_match = _MULTI_PREFIX_RE.match(body_with_braces)
if multi_match:
count = int(multi_match.group(1))
sep = multi_match.group(2)
count_raw = multi_match.group(1) # "N" or "N-M"
independent = multi_match.group(2) == "~" # SP2b: repeats allowed
sep = multi_match.group(3)
# Body remaining after the prefix is the branch list
prefix_len = multi_match.end() - 1 # subtract the leading `{` we added
rest = body[prefix_len:]
branches = _split_top_level_pipes(rest)
return (end_index, branches, count, sep)
if "-" in count_raw:
lo, hi = count_raw.split("-", 1)
cmin, cmax = int(lo), int(hi)
else:
cmin = cmax = int(count_raw)
if cmin > cmax: # normalize a swapped range
cmin, cmax = cmax, cmin
return (end_index, branches, (cmin, cmax, independent), sep)
# Single-pick: must contain at least one top-level `|`
branches = _split_top_level_pipes(body)
@@ -169,12 +185,15 @@ def tokenize_text(text: str) -> list[Token]:
m = _VAR_RE.match(text, i)
if m:
_flush_text(i)
var_meta: dict[str, object] = {"name": m.group(1)}
if m.group(2) is not None:
var_meta["index"] = int(m.group(2))
out.append(Token(
kind=TokenKind.VAR,
raw=m.group(0),
start=i,
end=m.end(),
meta={"name": m.group(1)},
meta=var_meta,
))
i = m.end()
continue
@@ -185,24 +204,21 @@ def tokenize_text(text: str) -> list[Token]:
m = _REF_RE.match(text, i)
if m:
_flush_text(i)
# Group 1: uuid. Group 2: optional display name. Group
# 3: optional sub-category filter (split on comma, strip
# whitespace, drop empties). Empty / whitespace-only
# filter is equivalent to "no filter" — keeps
# `@{xyz:}` from accidentally banning every option.
filter_raw = m.group(3)
sub_categories: list[str] = []
if filter_raw is not None:
sub_categories = [
sc for sc in (s.strip() for s in filter_raw.split(","))
if sc
]
# Group 1: uuid. Group 2: optional cached display name.
# Group 3: optional boolean sub-category filter
# expression (stored raw; the resolver parses it via the
# shared subcat_filter matcher). A whitespace-only
# `:expr` is "no filter" — keeps `@{xyz:}` from banning
# every option. Group 4: the `!null` exclude-null marker.
meta: dict = {"uuid": m.group(1)}
name_raw = m.group(2)
if name_raw:
meta["name"] = name_raw
if sub_categories:
meta["sub_categories"] = sub_categories
expr_raw = m.group(3)
if expr_raw is not None and expr_raw.strip():
meta["filter_expr"] = expr_raw
if m.group(4) == "null":
meta["exclude_null"] = True
out.append(Token(
kind=TokenKind.REF,
raw=m.group(0),
@@ -228,12 +244,18 @@ def tokenize_text(text: str) -> list[Token]:
meta={"branches": branches},
))
else:
cmin, cmax, independent = count
out.append(Token(
kind=TokenKind.DP_MULTI,
raw=text[i:end_index],
start=i,
end=end_index,
meta={"count": count, "sep": sep, "branches": branches},
# `count` kept == max for back-compat readers; min/max
# carry the range, independent carries the `~` flag (SP2b).
meta={
"min": cmin, "max": cmax, "independent": independent,
"count": cmax, "sep": sep, "branches": branches,
},
))
i = end_index
continue
+65 -2
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import enum
import random
import re
from dataclasses import dataclass, field
from typing import (
Any,
@@ -53,6 +54,66 @@ class Token:
meta: dict[str, Any] = field(default_factory=dict)
@dataclass
class ListVar:
"""A list-valued variable plus the separator used to join it for a bare
``$name`` reference (SP2a multi-select). Produced only by a multi-select
wildcard; exists only inside one resolution pass — never persisted or
published. ``$name`` renders ``sep.join(items)``; ``$name.K`` indexes
(0-based; out-of-range -> "").
"""
items: list[str]
sep: str
def __str__(self) -> str:
"""A bare ``$name`` reference renders the list joined by ``sep``.
Defining ``__str__`` (the @dataclass ``__repr__`` stays, for debug)
keeps a list value that leaks into a plain ``str()`` context rendering
as the user-facing joined string instead of ``ListVar(items=[...])``."""
return self.sep.join(self.items)
_VAR_ACCESSOR_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\.(\d+)$")
def split_var_accessor(name: str) -> tuple[str, int | None]:
"""Split a bare var reference into ``(base_name, index)`` (SP2a `.K`).
``"mood.0"`` -> ``("mood", 0)``; ``"mood"`` -> ``("mood", None)``. A name
that is not an ``ident.digits`` shape is returned whole with
``index=None`` so unusual keys pass through unchanged. Callers strip any
leading ``$`` before calling.
"""
if not isinstance(name, str):
return ("", None)
m = _VAR_ACCESSOR_RE.match(name)
if m:
return (m.group(1), int(m.group(2)))
return (name, None)
def deref_var_value(value: str | ListVar | None, index: int | None) -> str:
"""Render a resolved var value to a string, honoring an optional ``.K``
list accessor (SP2a). ListVar: bare -> ``sep.join(items)``; ``.K`` ->
``items[K]`` or ``""`` when out of range. A plain string behaves as a
1-element list (``.0`` == itself, ``.K`` > 0 == ``""``). ``None`` -> ``""``.
Single source of the accessor contract: resolve.py, derivation_handler.py
and converters.py all defer here so the read sites never drift.
"""
if value is None:
return ""
if isinstance(value, ListVar):
if index is not None:
return value.items[index] if 0 <= index < len(value.items) else ""
return value.sep.join(value.items)
s = value if isinstance(value, str) else str(value)
if index is not None:
return s if index == 0 else ""
return s
@runtime_checkable
class ResolveContext(Protocol):
"""Interface the resolver depends on. Construct via build_resolve_ctx in
@@ -67,8 +128,10 @@ class ResolveContext(Protocol):
developer_mode: bool
warnings: list[dict[str, Any]]
def get_var(self, name: str) -> str | None:
"""Return the bound value of `name`, or None if unbound."""
def get_var(self, name: str) -> str | ListVar | None:
"""Return the bound value of `name`, or None if unbound. A
multi-select wildcard binds a ``ListVar`` (SP2a); everything else
binds a ``str``."""
...
def get_module(self, uuid: str) -> dict[str, Any] | None:
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.1.0",
"version": "2.3.1",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
@@ -27,15 +27,13 @@
"prepare": "husky"
},
"dependencies": {
"pinia": "^2.2.6",
"primeicons": "^7.0.0",
"vue": "^3.5.12",
"vue-router": "^4.4.5",
"pinia": "^2.2.6",
"primeicons": "^7.0.0"
"zod": "^3.25.76"
},
"devDependencies": {
"tailwindcss": "^3.4.14",
"postcss": "^8.4.47",
"autoprefixer": "^10.4.20",
"@comfyorg/comfyui-frontend-types": "^1.42.15",
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
@@ -48,12 +46,15 @@
"@vitest/coverage-v8": "^2.1.9",
"@vue/test-utils": "^2.4.8",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.31.0",
"eslint-plugin-vuejs-accessibility": "^2.5.0",
"husky": "^9.1.6",
"jsdom": "^29.0.2",
"postcss": "^8.4.47",
"semantic-release": "^25.0.3",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-css-injected-by-js": "^3.5.2",
+3
View File
@@ -20,6 +20,9 @@ importers:
vue-router:
specifier: ^4.4.5
version: 4.6.4(vue@3.5.33(typescript@5.9.3))
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
'@comfyorg/comfyui-frontend-types':
specifier: ^1.42.15
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "2.1.0"
version = "2.3.1"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
+22 -1
View File
@@ -144,7 +144,28 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// in onUpdate/onValueRestored/microtask). Net ~0.5 KB gzip; 2 KB
// headroom buffer to avoid the "17 bytes from budget" tightness from
// the previous bump.
const TOTAL_LIMIT = 395 * 1024; // 395 KB
// Bumped 395 -> 405 KB on 2026-06-07 for SP1 Chunk G (in-graph multi-tag
// pool editor). PoolSection gains grouped quick pills + an ƒ(x) Advanced
// boolean-expression editor; OptionRow gains per-tag CATEGORY chips; and
// the shared engine-parity subcat_filter parser (parse/matches/readsAs)
// is now imported into the wildcard graph editor chunk. ~4 KB net gzip
// across the lazy ContextWidget chunk; entry stays put. Approved with the
// SP1 multi-tag + boolean-filter feature (docs/superpowers/specs/
// 2026-06-06-wildcard-multi-subcategory-boolean-filter-design.md §4.2).
// Bumped 405 -> 430 KB on 2026-06-11 for SP3 constraint reach UI headroom.
// SP3 Task 13 lands the TS combineConstraintFactor mirror (+ shared corpus
// parity test) now; the upcoming reach/constraint-matrix UI phases need the
// total budget headroom in the lazy ContextWidget chunk. Approved alongside
// the SP3 constraint feature.
// Bumped 430 -> 431 KB on 2026-06-18 — two same-day landings share the
// ceiling (both CSS/glue; the manager-only bulk UI adds nothing to js/):
// - editor robustness: RichTextInput renderEpoch crash fix + useEditorDraft
// flush-on-unmount/unload + ErrorBoundary copy + clipboard-shield wired
// into _shared.ts / main.ts (Ctrl-A/C/V/X guard on editable inputs).
// - instance-modal scroll/sticky: sticky header + runtime/footer dock +
// own-scrollbar inner lists (options/values/rules/exceptions) + compact
// exceptions trash, across all 6 edit modals.
const TOTAL_LIMIT = 431 * 1024; // 431 KB
function gzipSize(path) {
return gzipSync(readFileSync(path)).length;
+162
View File
@@ -0,0 +1,162 @@
"""Generate engine-parity fixtures for the community validator suite.
WHY THIS EXISTS
---------------
The community-web TS validators (`src/validators/`) are a hand-authored
re-implementation of the engine's payload shapes. Re-implementations
drift: fixed_values was published for weeks as `entries/{variable_name}`
when the engine has always used `values/{id,name,value}`; wildcard
options omitted the real `is_null` field; etc. Every drift surfaced as a
publish failure in front of a user.
This script makes the ENGINE the single source of truth. For each module
subtype it builds a sample payload, runs it through that subtype's own
`Handler.validate_payload` (the engine authority -- raises if the sample
is not engine-valid), and dumps the engine-row shape to a committed
fixture. The companion test `src/validators/__tests__/engine-parity.test.ts`
asserts the STRICT community validator accepts every fixture.
Result: if the engine shape changes (a handler gains/renames a field) the
sample stops validating here and this script fails; if the TS validator
drifts from the engine, the parity test fails. Drift is caught at CI time
instead of in front of a publisher.
USAGE
-----
python scripts/dump_engine_shapes.py
Run it whenever an engine module shape changes, then commit the updated
fixtures. Exit 0 = all samples engine-valid + written; exit 1 = a sample
is no longer engine-valid (fix the sample to match the new shape).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
# Make `engine` importable when run as a bare script (mirrors the
# tests/conftest.py sys.path shim).
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from engine.modules.combine_handler import CombineHandler # noqa: E402
from engine.modules.constraint_handler import ConstraintHandler # noqa: E402
from engine.modules.derivation_handler import DerivationHandler # noqa: E402
from engine.modules.fixed_values_handler import FixedValuesHandler # noqa: E402
from engine.modules.wildcard_handler import WildcardHandler # noqa: E402
# Output dir, relative to repo root (this file lives in scripts/).
_OUT_DIR = (
Path(__file__).resolve().parent.parent
/ "src" / "validators" / "fixtures" / "engine-parity"
)
# Each entry: (handler, type-specific payload). The payload must pass the
# handler's validate_payload -- keep these as the MINIMAL real shape per
# subtype, including fields that the strip-tolerant validator must
# preserve (e.g. wildcard `is_null`).
_SAMPLES: dict[str, tuple[type, dict]] = {
"wildcard": (
WildcardHandler,
{
"var_binding": "mood",
"sub_categories": ["soft", "bold"],
"options": [
{"id": "opt00001", "value": "serene", "weight": 1, "sub_category": "soft"},
# Null option: the is_null contract. Must round-trip
# through the validator or install rebuilds it as an
# invalid empty-value option.
{"id": "opt00002", "value": "", "weight": 1, "sub_category": None, "is_null": True},
],
},
),
"fixed_values": (
FixedValuesHandler,
{"values": [{"id": "val00001", "name": "style", "value": "oil painting"}]},
),
"combine": (
CombineHandler,
{"template": "$mood $subject", "output_var": "scene", "input_vars": ["mood", "subject"]},
),
"derivation": (
DerivationHandler,
{
"rules": [
{
"id": "rule0001",
"branches": [
{
"condition": {
"var": "mood", "op": "equals", "value": "dramatic",
},
"action": {
"target_var": "accent", "mode": "replace",
"value": "cinematic lighting",
},
},
],
"else": {
"action": {
"target_var": "accent", "mode": "replace",
"value": "soft lighting",
},
},
},
],
},
),
"constraint": (
ConstraintHandler,
{
"source_wildcard_id": "src00001",
"target_wildcard_id": "tgt00001",
"exceptions": [],
"matrix": {"soft": {"bold": {"mode": "boost", "factor": 3}}},
},
),
}
def _engine_row(type_id: str, payload: dict) -> dict:
"""Wrap a type-specific payload in the canonical engine-row shape
the publisher ships (see manager/import-export/single-row-publish.ts:
buildModulePublishable)."""
return {
"id": f"parity{type_id[:2]}",
"type": type_id,
"name": f"parity-{type_id}",
"description": "",
"category_id": None,
"tags": [],
"is_favorite": False,
"payload": payload,
}
def main() -> int:
_OUT_DIR.mkdir(parents=True, exist_ok=True)
failures: list[str] = []
for type_id, (handler, payload) in _SAMPLES.items():
try:
# Engine authority: this raises if the sample is not a valid
# payload for the CURRENT engine handler.
handler.validate_payload(payload)
except Exception as exc: # noqa: BLE001 -- report any validation failure
failures.append(f"{type_id}: sample is no longer engine-valid: {exc}")
continue
row = _engine_row(type_id, payload)
out = _OUT_DIR / f"{type_id}.json"
out.write_text(json.dumps(row, indent=2) + "\n", encoding="utf-8")
print(f"[parity] wrote {out.relative_to(_OUT_DIR.parent.parent.parent)}")
if failures:
print("\n[parity] FAILED -- update the sample(s) to match the engine:", file=sys.stderr)
for f in failures:
print(f" - {f}", file=sys.stderr)
return 1
print(f"[parity] {len(_SAMPLES)} engine-valid fixtures written to {_OUT_DIR}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+39
View File
@@ -0,0 +1,39 @@
/**
* Runtime contracts shared with the community web service.
*
* The community service is the schema authority — the sister project
* (this manager) consumes these contracts when importing/installing
* payloads downloaded from the community side. Keep these in sync
* with `web/src/api/types.ts` in the Wildcard-Pipeline-Community repo.
*
* This module deliberately holds runtime/transport contracts only.
* Manager-internal types (engine row shapes, UI state) live under
* `src/manager/api/types.ts`.
*/
/**
* One row from the community service's schema_catalog table (community
* spec §3). Returned alongside a download payload so the installer can
* plan its migration / tolerant-parse strategy without a second
* round-trip. Mirrors the Pydantic schema in
* `api/app/schemas/catalog.py` on the community side.
*
* Fields:
* - `version`: schema version this row describes.
* - `is_breaking_from_previous`: true when going from `version - 1`
* to `version` introduced a NON-additive change (field removal,
* type change, semantics change). Used by `decideInstallPath`'s
* AND-fold check.
* - `min_consumer_engine_version`: engine semver gate, or null when
* no minimum is enforced.
* - `notes`: free-form release notes for this schema bump.
* - `created_at`: ISO timestamp the row was inserted, or null on
* hand-seeded rows.
*/
export interface SchemaCatalogEntry {
version: number;
is_breaking_from_previous: boolean;
min_consumer_engine_version: string | null;
notes: string;
created_at: string | null;
}
@@ -183,6 +183,54 @@ describe("AssemblerHelper resolvedMap (#12 — boundary-disambiguation fix)", ()
});
});
describe("AssemblerHelper resolvedMap (SP2a — list-valued vars + .K accessor)", () => {
it("joins a bare $mood (ListVar) and indexes $mood.0 / $mood.1", () => {
const wrapper = mount(AssemblerHelper, {
props: {
upstreamVars: ["mood"],
templateVars: ["mood"],
template: "$mood.0, $mood.1, $mood",
resolvedMap: { mood: { items: ["sleepy", "serene", "dramatic"], sep: ", " } },
},
});
const preview = wrapper.find('[data-test="asm-preview"]');
const varSpans = preview.findAll("span.res");
expect(varSpans.length).toBe(3);
expect(varSpans[0].text()).toBe("sleepy"); // $mood.0
expect(varSpans[1].text()).toBe("serene"); // $mood.1
expect(varSpans[2].text()).toBe("sleepy, serene, dramatic"); // bare $mood → joined
// No stranded `.0` / `.1` accessor leaked into the literal spans.
expect(preview.text()).not.toContain(".0");
expect(preview.text()).not.toContain(".1");
});
it("renders an out-of-range $mood.5 as empty, not a leaked accessor", () => {
const wrapper = mount(AssemblerHelper, {
props: {
upstreamVars: ["mood"],
templateVars: ["mood"],
template: "[$mood.5]",
resolvedMap: { mood: { items: ["a"], sep: ", " } },
},
});
const preview = wrapper.find('[data-test="asm-preview"]');
expect(preview.text()).toBe("[]");
});
it("keeps an unresolved $name.K accessor visible in the chip text", () => {
const wrapper = mount(AssemblerHelper, {
props: {
upstreamVars: [],
templateVars: ["ghost"],
template: "$ghost.2",
resolvedMap: {},
},
});
const preview = wrapper.find('[data-test="asm-preview"]');
expect(preview.text()).toContain("$ghost.2");
});
});
describe("AssemblerHelper missing-chip click (#20 — autoremove regression)", () => {
it("clicking a missing chip calls onRemoveVar with the var name", async () => {
let removed = "";
+14 -5
View File
@@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue";
import { varColorClass } from "../shared/var-color";
import ContextMenu, { type ContextMenuItem } from "../shared/ContextMenu.vue";
import { kindIcon, type WpKind } from "../shared/kind-icons";
import { applyVarAccessor, type ResolvedValue } from "../../widgets/richTokenize";
const props = defineProps<{
/** Flat array of upstream variable names. Primary prop shape. */
@@ -15,7 +16,7 @@ const props = defineProps<{
* fragile parallel-walk over the resolved string. When present, takes
* precedence over `resolved`.
*/
resolvedMap?: Record<string, string>;
resolvedMap?: Record<string, ResolvedValue>;
/**
* Pre-resolved string — template with $var replaced by resolved values.
* Legacy fallback when `resolvedMap` not provided. Suffers from
@@ -81,7 +82,10 @@ const upstreamNames = computed(() => props.upstreamVars ?? []);
// doubled, it can't appear in real prompts.
const ESCAPE_PLACEHOLDER = "��";
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
const TEMPLATE_VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
// SP2a: capture an optional `.K` list accessor (group 2) so `$mood.0` consumes
// the whole token — `m[0].length` then advances past `.K` (no stranded ".0"
// literal) and group 2 drives applyVarAccessor in the substitution below.
const TEMPLATE_VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/g;
/** Variable names referenced in the template. */
const templateVarsInternal = computed(() => {
@@ -142,11 +146,16 @@ const previewTokens = computed<PreviewToken[]>(() => {
const idx = m.index ?? 0;
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
const name = m[1];
const idxStr = m[2];
const index = idxStr != null ? parseInt(idxStr, 10) : undefined;
const has = Object.prototype.hasOwnProperty.call(props.resolvedMap, name);
// Unresolved var → keep `$name` so user sees what's missing.
// Resolved → join (bare $name) or index (.K) via the shared accessor;
// unresolved → keep the raw `$name.K` so the user sees what's missing.
tokens.push({
kind: "var",
text: has ? props.resolvedMap[name] : `$${name}`,
text: has
? applyVarAccessor(props.resolvedMap[name], index)
: `$${name}${idxStr != null ? "." + idxStr : ""}`,
varName: name,
});
last = idx + m[0].length;
@@ -165,7 +174,7 @@ const previewTokens = computed<PreviewToken[]>(() => {
for (const m of props.template.matchAll(TEMPLATE_VAR_RE)) {
const idx = m.index ?? 0;
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
tokens.push({ kind: "var", text: `$${m[1]}`, varName: m[1] });
tokens.push({ kind: "var", text: m[0], varName: m[1] });
last = idx + m[0].length;
}
if (last < props.template.length) tokens.push({ kind: "literal", text: props.template.slice(last) });
+211 -6
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import ContextWidget from "./ContextWidget.vue";
import { baseCodename } from "../../extension/node-codename";
import { _resetForTests as resetDriftStore } from "./drift-store";
beforeEach(() => {
@@ -171,7 +172,7 @@ describe("ContextWidget drift dot", () => {
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
// Live hash differs from embedded → drift.
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-LIVE" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -215,7 +216,7 @@ describe("ContextWidget bulk refresh", () => {
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-A", bbbbbbbb: "h-B" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-A" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-B" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -251,7 +252,7 @@ describe("ContextWidget bulk refresh", () => {
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
// Both embedded hashes differ from live → both drifted.
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A", bbbbbbbb: "live-B" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" }, bbbbbbbb: { type: "wildcard", payload_hash: "live-B" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -292,7 +293,7 @@ describe("ContextWidget bulk refresh", () => {
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" } } }), { status: 200 });
}
if (typeof url === "string" && url.endsWith("/embed-bundle")) {
const body = JSON.parse(String(init?.body ?? "{}")) as { uuids: string[] };
@@ -402,7 +403,7 @@ describe("ContextWidget bulk refresh", () => {
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -1120,7 +1121,7 @@ describe("ContextWidget status badges", () => {
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -1184,6 +1185,49 @@ describe("ContextWidget status badges", () => {
});
wrapper.unmount();
});
it("renders 'clash' badge when the library row at this id is a different kind", async () => {
resetDriftStore();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
// Same id, DIFFERENT kind in library (wildcard) than the embedded
// constraint → type-conflict, not drift/missing.
return new Response(
JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live" } } }),
{ status: 200 },
);
}
return new Response("{}", { status: 200 });
}),
);
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: {
nodeId: 802,
initialJson: JSON.stringify({
version: 1,
modules: [
{ id: "aaaaaaaa", type: "constraint", enabled: true, meta: { name: "c" }, entries: [], payload: {}, payload_hash: "embedded" },
],
}),
upstreamVars: [],
onChange: () => {},
},
});
await vi.waitFor(() => {
const badge = wrapper.find(".wp-mod-badge--clash");
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe("clash");
});
// type-conflict is mutually exclusive with drift + missing.
expect(wrapper.find(".wp-mod-badge--drift").exists()).toBe(false);
expect(wrapper.find(".wp-mod-badge--missing").exists()).toBe(false);
wrapper.unmount();
});
});
// ── B3: kind chip next to module name (mockup v5 lines 681, 696, 711, 722, 733) ─
@@ -2104,3 +2148,164 @@ describe("ContextWidget bundle MOD detection", () => {
bundleHashes.value = null; // restore
});
});
// ── Node codename header chip ───────────────────────────────────────────────
// The node header shows a FIXED, read-only codename — a stable
// adjective-noun derived from the litegraph node id (node-codename.ts) — so
// cross-node UI (constraint reach pick-list, pair popovers) can name WHICH
// node a target instance lives in. POV-independent + unique on the canvas,
// and NOT editable (replaced the old editable label + walk-position letter).
describe("ContextWidget node codename chip", () => {
it("renders [data-test=node-label] showing the node's stable codename", () => {
const wrapper = mount(ContextWidget, {
props: {
nodeId: 7000,
initialJson: '{"version":1,"modules":[]}',
upstreamVars: [],
onChange: () => {},
},
});
const chip = wrapper.find('[data-test="node-label"]');
expect(chip.exists()).toBe(true);
expect(chip.text()).toContain(baseCodename(7000));
wrapper.unmount();
});
it("is read-only — exposes no edit input", () => {
const wrapper = mount(ContextWidget, {
props: {
nodeId: 7001,
initialJson: '{"version":1,"modules":[]}',
upstreamVars: [],
onChange: () => {},
},
});
expect(wrapper.find('[data-test="node-label-input"]').exists()).toBe(false);
wrapper.unmount();
});
it("derives the codename from the node id, ignoring chainModules / position", () => {
// The core fix for the old A/B/C scheme: the chip is purely id-derived
// now, so it never depends on walk position or any chainModules label.
const wrapper = mount(ContextWidget, {
props: {
nodeId: 7002,
initialJson: '{"version":1,"modules":[]}',
upstreamVars: [],
chainModules: [
{ id: "own", rowKey: "7002#own-uid", type: "wildcard", payload: {}, nodeLabel: "ignored-label" },
],
onChange: () => {},
},
});
const chip = wrapper.find('[data-test="node-label"]');
expect(chip.text()).toContain(baseCodename(7002));
expect(chip.text()).not.toContain("ignored-label");
wrapper.unmount();
});
});
// ── Bulk toolbar: bundle-aware collapse / expand / toggle-all ────────────────
// Toggle-all must operate on TOP-LEVEL units — standalone modules + bundle
// MASTER toggles — never reach inside a bundle to flip child modules (the
// master gates them; effective = master AND child). Collapse/expand-all must
// flip bundle FRAMES too, not just module cards. The three buttons match the
// small row-icon size.
describe("ContextWidget bulk toolbar — bundle-aware", () => {
function mountMixed(opts: { bundleEnabled?: boolean; stdEnabled?: boolean } = {}) {
const bundleEnabled = opts.bundleEnabled ?? true;
const stdEnabled = opts.stdEnabled ?? true;
const modules = [
{
id: "ch0aaaaa", _uid: "u0", type: "wildcard", enabled: true, collapsed: false,
meta: { name: "ch0" }, entries: [], payload: { var_binding: "v0", options: [] },
payload_hash: "ph0", bundle_origin: "bUID",
},
{
id: "ch1aaaaa", _uid: "u1", type: "wildcard", enabled: true, collapsed: false,
meta: { name: "ch1" }, entries: [], payload: { var_binding: "v1", options: [] },
payload_hash: "ph1", bundle_origin: "bUID",
},
{
id: "stdaaaaa", _uid: "u2", type: "wildcard", enabled: stdEnabled, collapsed: false,
meta: { name: "std" }, entries: [], payload: { var_binding: "vs", options: [] },
payload_hash: "phs",
},
];
const bundles = [{
_uid: "bUID", library_id: "lib_b", start_idx: 0, end_idx: 1,
enabled: bundleEnabled, collapsed: false, name: "Group", color: null,
}];
const onChange = vi.fn();
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: {
nodeId: 9400,
initialJson: JSON.stringify({ version: 1, modules, bundles }),
upstreamVars: [],
onChange,
},
});
return { wrapper, onChange };
}
function lastParsed(onChange: ReturnType<typeof vi.fn>) {
const calls = onChange.mock.calls;
return JSON.parse(calls[calls.length - 1][0] as string) as {
modules: Array<{ _uid: string; enabled: boolean; collapsed?: boolean }>;
bundles: Array<{ _uid: string; enabled: boolean; collapsed: boolean }>;
};
}
const findMod = (p: ReturnType<typeof lastParsed>, uid: string) =>
p.modules.find((m) => m._uid === uid)!;
it("toggle-all flips the bundle MASTER + standalone module, leaving bundle children untouched", async () => {
const { wrapper, onChange } = mountMixed();
onChange.mockClear();
await wrapper.find('[aria-label="Toggle all enabled"]').trigger("click");
const p = lastParsed(onChange);
expect(p.bundles[0].enabled).toBe(false); // master flipped off
expect(findMod(p, "u2").enabled).toBe(false); // standalone flipped off
// Children NOT reached — gated by the master, restore-safe.
expect(findMod(p, "u0").enabled).toBe(true);
expect(findMod(p, "u1").enabled).toBe(true);
wrapper.unmount();
});
it("toggle-all re-enables when every top-level unit is already off", async () => {
const { wrapper, onChange } = mountMixed({ bundleEnabled: false, stdEnabled: false });
onChange.mockClear();
await wrapper.find('[aria-label="Toggle all enabled"]').trigger("click");
const p = lastParsed(onChange);
expect(p.bundles[0].enabled).toBe(true);
expect(findMod(p, "u2").enabled).toBe(true);
wrapper.unmount();
});
it("collapse-all collapses bundle FRAMES + module cards; expand-all expands both", async () => {
const { wrapper, onChange } = mountMixed();
onChange.mockClear();
await wrapper.find('[aria-label="Collapse all modules"]').trigger("click");
let p = lastParsed(onChange);
expect(p.bundles[0].collapsed).toBe(true);
expect(p.modules.every((m) => m.collapsed === true)).toBe(true);
onChange.mockClear();
await wrapper.find('[aria-label="Expand all modules"]').trigger("click");
p = lastParsed(onChange);
expect(p.bundles[0].collapsed).toBe(false);
expect(p.modules.every((m) => m.collapsed === false)).toBe(true);
wrapper.unmount();
});
it("the three bulk buttons use the small row-icon size class", () => {
const { wrapper } = mountMixed();
for (const label of ["Collapse all modules", "Expand all modules", "Toggle all enabled"]) {
const btn = wrapper.find(`[aria-label="${label}"]`);
expect(btn.exists()).toBe(true);
expect(btn.classes()).toContain("wp-btn--icon-sm");
expect(btn.classes()).not.toContain("wp-btn--icon");
}
wrapper.unmount();
});
});
+173 -33
View File
@@ -7,8 +7,10 @@ import {
emptyContextValue, newModuleId, newRowUid,
type ContextWidgetValue, type ModuleEntry,
} from "../../widgets/_shared";
import { type ResolvedValue } from "../../widgets/richTokenize";
import { scanConflicts, labelFor as conflictLabelFor, shortConflictLabel, type Conflict } from "../../extension/conflicts";
import type { PairingBadge, RowPairings } from "../../extension/constraint-pairs";
import type { ChainModule, PairingBadge, RowPairings } from "../../extension/constraint-pairs";
import { baseCodename } from "../../extension/node-codename";
import {
getCollapseMode,
getCollapsedByDefault,
@@ -84,6 +86,7 @@ import {
subscribe as subscribeDrift,
unsubscribe as unsubscribeDrift,
} from "./drift-store";
import { classifyOne, type CollisionState } from "../../manager/import-export/collision";
import {
getBundleCollapsedByDefault,
getConfirmDestructiveBundle,
@@ -98,13 +101,13 @@ const props = withDefaults(defineProps<{
* Drives the combine modal's live-preview pane so users see the
* template with vars substituted (e.g. `red portrait` instead of
* `$style portrait`) at edit time. Optional for headless mounts. */
upstreamResolved?: Record<string, string>;
upstreamResolved?: Record<string, ResolvedValue>;
/** Per-module resolved reader — given a moduleId, returns the map of
* vars visible from that module's perspective (upstream chain +
* earlier siblings, excluding the module itself). Modal uses this
* for the combine live preview because static `upstreamResolved`
* alone misses sibling bindings produced in the same node. */
localResolvedReader?: (moduleId?: string) => Record<string, string>;
localResolvedReader?: (moduleId?: string) => Record<string, ResolvedValue>;
/** Wildcard module uuids reachable upstream of this node. Used by
* the constraint-ordering scanner to validate constraint
* source/target references — sources in the upstream chain are
@@ -133,6 +136,13 @@ const props = withDefaults(defineProps<{
* via `pairingFor(module._uid)` in `moduleRowCtx`. Optional for
* headless mounts that don't simulate a graph. */
pairings?: Map<string, RowPairings>;
/** Flattened cross-node module chain (upstream + own + downstream
* WP_Context nodes), computed at the mount layer alongside
* `pairings`. Forwarded to ModuleEditModal → ConstraintInstanceModal
* so the constraint modal can resolve a source/target wildcard that
* lives in another Context node — same flatten the pair badges use.
* Optional for headless mounts. */
chainModules?: ChainModule[];
/** Litegraph node mode: 0 ALWAYS, 2 NEVER (mute), 4 BYPASS.
* Used to dim the body when the host node is muted/bypassed so
* the runtime-skipped state is visually obvious. Other modes are
@@ -672,11 +682,15 @@ const editingModule = computed<ModuleEntry | null>(() =>
editingIdx.value != null ? (value.value.modules[editingIdx.value] ?? null) : null,
);
/** Per-option pair lookup for the currently-edited wildcard. Map keys
* are option ids; values are pair badges whose `via.optionIds` include
* that option. Drives the trailing `↪#N` chip inside the wildcard
* modal's options table. Empty for non-wildcard modules and for
* wildcards that aren't a constraint carrier. */
/** Per-occurrence pair lookup for the currently-edited CARRIER module.
* Map keys are the carrier's `via.optionIds` — wildcard option ids for a
* wildcard carrier, engine branch keys (`${rule_id}:${bi}` / `:else`) for
* a derivation carrier. Values are the pair badges whose nested `@{uuid}`
* ref this occurrence hosts. Drives the trailing `↪#N` chip inside the
* wildcard modal's options table AND the derivation modal's rule summary
* rows. Empty for non-carrier modules. Carrier-type-agnostic: it reads
* `viaInboundFor`, which `computePairingsFull` populates for every
* CARRIER_TYPE. */
const editingModuleViaOptionPairs = computed<Map<string, PairingBadge[]>>(() => {
const m = editingModule.value;
if (!m) return new Map();
@@ -700,7 +714,7 @@ const editingModuleViaOptionPairs = computed<Map<string, PairingBadge[]>>(() =>
* prop when the per-module reader isn't wired (legacy mounts /
* headless tests). Recomputed on every editingIdx change so the
* preview pane reflects the right perspective. */
const resolvedForEditing = computed<Record<string, string>>(() => {
const resolvedForEditing = computed<Record<string, ResolvedValue>>(() => {
const m = editingModule.value;
if (props.localResolvedReader && m) {
return props.localResolvedReader(m.id);
@@ -2053,6 +2067,17 @@ function viaInboundFor(rowUid: string): PairingBadge[] {
return props.pairings?.get(key)?.viaInbound ?? [];
}
/** SP3 contributor cluster — every constraint whose reach covers this
* target-instance row, in per-target `#N` order. Reads the same
* cross-node `pairings` prop as `pairingFor` / `viaInboundFor`,
* prefixing the local `_uid` with our `nodeId`. ModuleRow renders these
* BEFORE the module name: ≤2 as individual `#N` chips, ≥3 as one
* collapsed `↥×N` chip. Empty for rows no constraint covers. */
function contributorsFor(rowUid: string): PairingBadge[] {
const key = `${props.nodeId}#${rowUid}`;
return props.pairings?.get(key)?.contributors ?? [];
}
function severityFor(id: string): "error" | "warning" | "info" | null {
const list = conflictsByModule.value[id];
if (!list?.length) return null;
@@ -2140,25 +2165,47 @@ function conflictTooltip(id: string): string {
* Returns false until `libraryHashes` first loads so we don't flash
* "missing" everywhere while the initial fetch is in flight.
*/
/**
* Identity verdict of an embedded row against the live library — or null
* until the first poll lands / for local-only rows (no payload_hash).
* Single source for the card dots. Content key is `payload_hash`, so
* drift semantics match the historical behavior (content-only); the
* `type` gate is the new cross-kind clash detector. Shared verdict logic
* lives in `classifyOne` (import-export/collision).
*/
function matchStateOf(m: ModuleEntry): CollisionState | null {
if (!m.payload_hash) return null;
if (libraryHashes.value === null) return null;
const live = libraryHashes.value[m.id];
return classifyOne(
m.type,
m.payload_hash,
live ? { type: live.type, contentKey: live.payload_hash } : undefined,
);
}
function isMissingFromLibrary(m: ModuleEntry): boolean {
if (!m.payload_hash) return false;
if (libraryHashes.value === null) return false;
return !(m.id in libraryHashes.value);
return matchStateOf(m) === "no-collision";
}
/**
* Drift = library still has this uuid, but the live `payload_hash`
* differs from what was embedded into the workflow at pick time.
* Independent of `isModified` (user overrides) and `isMissingFromLibrary`
* (uuid gone). Reads `false` until the store's first fetch lands so we
* don't flash drift before we know the truth.
* Drift = library still has this uuid (SAME kind), but the live
* `payload_hash` differs from what was embedded at pick time. Independent
* of `isModified` (user overrides). Mutually exclusive with the clash
* state below — a different-kind row is `type-conflict`, not drift.
*/
function isDrifted(m: ModuleEntry): boolean {
if (!m.payload_hash) return false;
if (libraryHashes.value === null) return false;
const live = libraryHashes.value[m.id];
if (live === undefined) return false; // covered by isMissingFromLibrary
return live !== m.payload_hash;
return matchStateOf(m) === "conflict";
}
/**
* Type clash = the live library row at this uuid is a DIFFERENT kind (the
* 8-hex id-space is shared across all 5 kinds). Refresh / Push-update
* would clobber an unrelated item, so the card offers neither — only
* "Push to library…" as a fresh entry.
*/
function isTypeConflict(m: ModuleEntry): boolean {
return matchStateOf(m) === "type-conflict";
}
/** Per-card refresh — replace one drifted entry with the live snapshot.
@@ -2964,8 +3011,8 @@ function modifiedTooltip(m: ModuleEntry): string {
case "wildcard":
if (inst.mode === "pinned") bits.push("pinned");
else if (inst.mode === "subcategory") bits.push("subset");
if (nonEmptyArr(inst.category_filter)) {
bits.push(`cats: ${(inst.category_filter as string[]).join(", ")}`);
if (inst.category_filter) {
bits.push(`cats: ${inst.category_filter}`);
}
if (Array.isArray(inst.enabled_options)) {
bits.push(`${inst.enabled_options.length} option(s) enabled`);
@@ -3640,7 +3687,12 @@ function toggleCollapsed(idx: number) {
* cards already match the target state (the deep watcher will
* diff-eq and skip the onChange emit). */
function setAllCollapsed(collapsed: boolean) {
commitModules(value.value.modules.map((m) => ({ ...m, collapsed })));
const nextModules = value.value.modules.map((m) => ({ ...m, collapsed }));
// Bundle FRAMES carry their own `collapsed` flag (separate from the child
// module cards). Collapse/expand-all flips both so the action visibly folds
// bundle frames, not just loose modules.
const nextBundles = (value.value.bundles ?? []).map((b) => ({ ...b, collapsed }));
commitModules(nextModules, nextBundles);
}
/** Toolbar counts — total modules + how many are effectively enabled
@@ -3651,13 +3703,42 @@ const enabledCount = computed(() => {
return value.value.modules.filter((m) => isModuleEffectivelyEnabled(m, bundleEnabled)).length;
});
// ── Node codename chip ──────────────────────────────────────────────────────
// A fixed, read-only CODENAME identifies THIS WP_Context node: a stable
// `adjective-noun` derived from the litegraph node id (node-codename.ts).
// POV-independent + unique on the canvas, and NOT user-editable — cross-node
// UI (constraint reach pick-list, pair popovers) shows the SAME codename to
// name WHICH node a target instance lives in. Replaced the old editable label
// + walk-position A/B/C letter, which shifted by viewer (so two chain heads
// both showed "A") and was editable (so not a stable identifier).
const nodeCodename = computed<string>(() => baseCodename(props.nodeId));
/** Toolbar bulk actions — wrappers so the toolbar template stays compact. */
function collapseAll(): void { setAllCollapsed(true); }
function expandAll(): void { setAllCollapsed(false); }
function toggleAllEnabled(): void {
const anyEnabled = value.value.modules.some((m) => m.enabled);
// If any are enabled, disable all; otherwise enable all.
commitModules(value.value.modules.map((m) => ({ ...m, enabled: !anyEnabled })));
// Operate at the TOP LEVEL only. A standalone module is one unit (its own
// `enabled`); a bundle is one unit (its MASTER `enabled` gate). Children
// inside a bundle are governed by the master (effective = master AND
// child), so toggle-all NEVER reaches in to flip them — disabling a bundle
// goes through its master, leaving each child's own `enabled` intact for a
// restore-safe re-enable. Nested bundles (parent_uid set) follow their
// parent master via the AND chain, so only top-level masters are flipped.
const isChild = (m: ModuleEntry): boolean =>
!!(m as ModuleEntry & { bundle_origin?: string | null }).bundle_origin;
const bundles = value.value.bundles ?? [];
const topModules = value.value.modules.filter((m) => !isChild(m));
const topBundles = bundles.filter((b) => !b.parent_uid);
// Direction: if ANY top-level unit is on, turn everything off; else on.
const anyOn =
topModules.some((m) => m.enabled !== false)
|| topBundles.some((b) => b.enabled !== false);
const next = !anyOn;
const nextModules = value.value.modules.map((m) =>
isChild(m) ? m : { ...m, enabled: next },
);
const nextBundles = bundles.map((b) => (b.parent_uid ? b : { ...b, enabled: next }));
commitModules(nextModules, nextBundles);
}
/** Open the SPA dashboard in a new tab. Mirrors `extension/topbar.ts`'s
@@ -4593,7 +4674,7 @@ const moduleRowCtx: ModuleRowCtx = {
KIND_TITLE,
kindIcon, kindChipModifier, varColorClass,
isCollapsed, isLocked, isInternal, isSeedLockable,
isModified, isDrifted, isMissingFromLibrary,
isModified, isDrifted, isMissingFromLibrary, isTypeConflict,
severityFor, conflictTooltip, conflictBadgeText,
modifiedTooltip, summaryFor, summaryTokens, siblingInfo,
rowGap, draggingModuleUid, recentDropUids, pulseDelayFor,
@@ -4602,6 +4683,7 @@ const moduleRowCtx: ModuleRowCtx = {
onDragStart, onDragEnd, openContextMenu, onCardKeydown,
pairingFor,
viaInboundFor,
contributorsFor,
};
provide(ModuleRowCtxKey, moduleRowCtx);
@@ -4636,6 +4718,23 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
:data-mode-label="isMuted ? muteLabel : undefined"
@dragleave="onContainerLeave"
>
<!-- Node identity strip. A fixed, read-only CODENAME naming THIS
WP_Context node — a stable `adjective-noun` derived from the
litegraph node id (node-codename.ts). POV-independent + unique on
the canvas, NOT editable. Cross-node UI (constraint reach pick-list,
pair popovers) shows the same codename to say WHICH node a target
lives in. Sits above the toolbar/hero so it shows in every state. -->
<div class="wp-node-id">
<span class="wp-node-id__tag">node</span>
<span
class="wp-node-id__chip wp-node-id__chip--fixed"
data-test="node-label"
:title="`Stable id for this Context node — referenced by cross-node constraint UI (${nodeCodename})`"
>
<span class="wp-node-id__text">{{ nodeCodename }}</span>
</span>
</div>
<!-- Corrupt-workflow recovery panel (5.6). Surfaces when JSON parse fails
or returns a non-object. View raw exposes the bad payload so users
can copy it out before resetting. -->
@@ -4699,21 +4798,21 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
<span class="wp-w-toolbar-spacer" />
<button
type="button"
class="wp-btn wp-btn--icon"
class="wp-btn--icon-sm"
title="Collapse all"
aria-label="Collapse all modules"
@click="collapseAll"
><i class="pi pi-chevron-up" /></button>
<button
type="button"
class="wp-btn wp-btn--icon"
class="wp-btn--icon-sm"
title="Expand all"
aria-label="Expand all modules"
@click="expandAll"
><i class="pi pi-chevron-down" /></button>
<button
type="button"
class="wp-btn wp-btn--icon"
class="wp-btn--icon-sm"
title="Toggle all enabled"
aria-label="Toggle all enabled"
@click="toggleAllEnabled"
@@ -4721,8 +4820,8 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
</div>
<div
class="wp-modules-frame"
ref="modulesContainer"
class="wp-modules-frame"
>
<div
class="wp-modules"
@@ -4848,6 +4947,7 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
:upstream-resolved="resolvedForEditing"
:sibling-vars="siblingNodeVars"
:sibling-modules="value.modules"
:chain-modules="chainModules"
:via-option-pairs="editingModuleViaOptionPairs"
:last-used-seed-reader="lastUsedSeedReader"
@save="saveEditedModule"
@@ -5700,6 +5800,12 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
background: color-mix(in oklab, var(--wp-danger) 14%, transparent);
border-color: var(--wp-danger);
}
/* clash → --wp-accent (violet): id reused by a DIFFERENT kind. Distinct
* from modified/drift/missing so a cross-kind collision reads at a glance. */
.wp-mod-dot--clash {
background: color-mix(in oklab, var(--wp-accent) 14%, transparent);
border-color: var(--wp-accent);
}
/* ── Sibling badge ──────────────────────────────────────────────────────
* Rendered when the same uuid appears more than once in this Context.
@@ -5776,6 +5882,40 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
.wp-w-count { font: 500 11px/1 var(--wp-font-mono); color: var(--wp-text-dim, var(--wp-text3)); padding: 2px 6px; background: var(--wp-bg-deep, var(--wp-bg)); border-radius: var(--wp-radius, 4px); }
.wp-w-toolbar-spacer { flex: 1; }
/* ── Node identity chip (SP3 P7) ────────────────────────────────────────
* A small, unobtrusive header strip naming this WP_Context node. Amber
* accent (matches the design proposal); sits above the toolbar/hero in a
* compact row so it never disturbs their layout. */
.wp-node-id { display: flex; align-items: center; gap: 6px; padding: 2px 0 4px; }
.wp-node-id__tag {
font: 500 10px/1 var(--wp-font-sans, sans-serif);
color: var(--wp-text-dim, var(--wp-text3));
text-transform: uppercase;
letter-spacing: 0.06em;
}
.wp-node-id__chip {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 220px;
padding: 2px 8px;
font: 600 11px/1.2 var(--wp-font-sans, sans-serif);
/* Neutral — a fixed codename is an identity, not an attention cue (was
amber). Muted text + a faint text-tinted wash + standard border, the
same neutral idiom used elsewhere in the widget; reads on light + dark. */
color: var(--wp-text-muted, var(--wp-text2));
background: color-mix(in srgb, var(--wp-text) 6%, transparent);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm, 4px);
}
/* Fixed codename — a read-only identity, not an interactive control. */
.wp-node-id__chip--fixed { cursor: default; }
.wp-node-id__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Generic button system (Task 10) ────────────────────────────────────
* .wp-btn--icon-sm (Task 9) already exists for inline card actions;
* these are the larger toolbar/footer variants. */
+74 -2
View File
@@ -1,10 +1,24 @@
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import ModuleEditModal from "./ModuleEditModal.vue";
import type { ModuleEntry } from "../../widgets/_shared";
import { _resetForTests } from "../../extension/preview-resolver";
// The v2 DerivationInstanceModal fetches /wp/api/modules on open to build
// its `@{}` ref-data. Stub fetch so that round-trip settles harmlessly in
// the derivation dispatcher tests below (shape mirrors ModulePickerModal).
function stubEmptyModulesFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0 }),
})) as unknown as typeof fetch,
);
}
// ModalShell uses <Teleport to="body">. VTU's `find` only walks the
// component's own subtree, so disable teleport globally for these tests
// — content lands inline where wrapper.find can reach it.
@@ -523,7 +537,11 @@ describe("ModuleEditModal — combine v2 dispatcher", () => {
// ── Derivation v2 dispatcher branch ────────────────────────────────────
describe("ModuleEditModal — derivation v2 dispatcher", () => {
beforeEach(() => _resetForTests());
beforeEach(() => {
_resetForTests();
stubEmptyModulesFetch();
});
afterEach(() => vi.unstubAllGlobals());
it("renders DerivationInstanceModal (no tab strip) for derivation kind", async () => {
const wrapper = mount(ModuleEditModal, {
@@ -549,6 +567,27 @@ describe("ModuleEditModal — derivation v2 dispatcher", () => {
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
expect(saved.instance?.disabled_rule_ids).toEqual(["r1"]);
});
// Bug parity: the shell must thread upstream + sibling vars into the
// derivation modal so its action/condition override fields can offer
// `$var` autocomplete — the SAME props the wildcard + combine modals
// already receive. The `@{}` source is the LIBRARY (fetched inside the
// modal), so sibling/chain MODULES are intentionally NOT forwarded.
it("forwards upstream-vars + sibling-vars to DerivationInstanceModal", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: {
visible: true,
module: makeDerivation(),
upstreamVars: ["age", "season"],
siblingVars: ["mood"],
},
});
await nextTick();
const dvm = wrapper.findComponent({ name: "DerivationInstanceModal" });
expect(dvm.props("upstreamVars")).toEqual(["age", "season"]);
expect(dvm.props("siblingVars")).toEqual(["mood"]);
});
});
// ── Constraint v2 dispatcher branch ────────────────────────────────────
@@ -583,6 +622,39 @@ describe("ModuleEditModal — constraint v2 dispatcher", () => {
expect(saved.instance?.cell_mode_overrides).toEqual({ '["a","b"]': "exclude" });
});
// Cross-node chain (SP3 P6 T16): the mount layer threads the flattened
// chain as `chainModules`; the shell must forward it verbatim to the
// constraint modal so a target wildcard in another Context node still
// resolves. Resolution itself is covered in ConstraintInstanceModal.test.ts.
it("forwards chainModules prop through to ConstraintInstanceModal", async () => {
const chain = [
{
id: "bbbbbbbb",
rowKey: "99#bbbbbbbb",
type: "wildcard",
displayName: "outfit",
payload: { sub_categories: ["tee", "gown"], options: [] },
},
];
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeConstraint(), chainModules: chain },
});
await nextTick();
const cnm = wrapper.findComponent({ name: "ConstraintInstanceModal" });
expect(cnm.props("chainModules")).toEqual(chain);
});
it("leaves the child chainModules undefined when the prop is omitted", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeConstraint() },
});
await nextTick();
const cnm = wrapper.findComponent({ name: "ConstraintInstanceModal" });
expect(cnm.props("chainModules")).toBeUndefined();
});
it("Reset overrides clears all override fields, preserves Identity", async () => {
const m = {
...makeConstraint(),
+20 -4
View File
@@ -2,6 +2,7 @@
import { ref, computed, watch, onBeforeUnmount } from "vue";
import ModalShell from "../shared/ModalShell.vue";
import type { ModuleEntry, ModuleEntryKind } from "../../widgets/_shared";
import { type ResolvedValue } from "../../widgets/richTokenize";
import {
INSTANCE_FIELDS_PER_KIND,
type InstanceFieldKey,
@@ -15,7 +16,7 @@ import FixedValuesInstanceModal from "./editors/fixed-values/FixedValuesInstance
import CombineInstanceModal from "./editors/combine/CombineInstanceModal.vue";
import DerivationInstanceModal from "./editors/derivation/DerivationInstanceModal.vue";
import ConstraintInstanceModal from "./editors/constraint/ConstraintInstanceModal.vue";
import type { PairingBadge } from "../../extension/constraint-pairs";
import type { ChainModule, PairingBadge } from "../../extension/constraint-pairs";
const props = defineProps<{
@@ -25,13 +26,18 @@ const props = defineProps<{
upstreamVars?: string[];
/** Resolved upstream `$name → value` map. Combine modal uses this
* to render a live preview pane with vars substituted. */
upstreamResolved?: Record<string, string>;
upstreamResolved?: Record<string, ResolvedValue>;
/** Variable names defined by OTHER modules in the same node. */
siblingVars?: string[];
/** Other modules in the same WP_Context node — used by the constraint
* preview to resolve source/target uuids back to var-binding names.
* May include the module being edited; lookups by uuid skip self. */
siblingModules?: ModuleEntry[];
/** Flattened cross-node module chain (upstream + own + downstream
* WP_Context nodes). Forwarded to the constraint modal so it can
* resolve a source/target wildcard that lives in another Context
* node, not just `siblingModules` (same-node only). */
chainModules?: ChainModule[];
/** Per-option pair badges for the currently-edited wildcard when it
* acts as a constraint carrier (target reached via nested `@{uuid}`).
* Keyed by option id. Empty for non-wildcard or non-carrier rows. */
@@ -141,7 +147,10 @@ function onConfirmDialogCancel(): void {
* it's still on v1 and doesn't separate runtime from overrides.
*/
const RESET_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly InstanceFieldKey[]> = {
wildcard: ["enabled_options", "option_weights", "category_filter"],
wildcard: [
"enabled_options", "option_weights", "category_filter", "exclude_null",
"pick_min", "pick_max", "pick_separator",
],
fixed_values: ["values_overrides", "enabled_options"],
combine: ["template_override", "variable_binding"],
derivation: [
@@ -422,11 +431,17 @@ function cancel() {
<!-- v2 derivation branch — single-pane tailored modal. Library
(rule conditions / branches / actions) stays in SPA; modal
exposes only display-name override + per-rule disable
toggles via instance.disabled_rule_ids. -->
toggles via instance.disabled_rule_ids. upstream/sibling vars
feed the rule override fields' `$var` autocomplete; the `@{}`
nested-ref source is the LIBRARY (fetched inside the modal), so
sibling/chain MODULES are intentionally NOT forwarded here. -->
<DerivationInstanceModal
v-else-if="draft && draft.type === 'derivation'"
:module="draft"
:is-modified="instanceModified"
:upstream-vars="upstreamVars"
:sibling-vars="siblingVars"
:via-option-pairs="viaOptionPairs"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -444,6 +459,7 @@ function cancel() {
:module="draft"
:is-modified="instanceModified"
:sibling-modules="siblingModules"
:chain-modules="chainModules"
@update="onUpdate"
@save="save"
@cancel="cancel"
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import { ref } from "vue";
import ModuleRow from "./ModuleRow.vue";
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
import type { ModuleEntry } from "../../widgets/_shared";
import type { PairingBadge } from "../../extension/constraint-pairs";
/** Build a ModuleRowCtx whose pairing accessors return canned data and
* whose ~30 helpers are inert stubs — ModuleRow only renders, so the
* helpers just need to exist + be callable without throwing. */
function makeCtx(over: Partial<ModuleRowCtx> = {}): ModuleRowCtx {
const noop = (): void => {};
return {
KIND_TITLE: { wildcard: "Wildcard", constraint: "Constraint" },
kindIcon: () => "pi pi-box",
kindChipModifier: (t: string) => t,
varColorClass: () => "var-1",
isCollapsed: () => true,
isLocked: () => false,
isInternal: () => false,
isSeedLockable: () => false,
isModified: () => false,
isDrifted: () => false,
isMissingFromLibrary: () => false,
isTypeConflict: () => false,
severityFor: () => null,
conflictTooltip: () => "",
conflictBadgeText: () => null,
modifiedTooltip: () => "",
summaryFor: () => "",
summaryTokens: () => [],
siblingInfo: () => null,
rowGap: () => null,
draggingModuleUid: ref<string | null>(null),
recentDropUids: ref(new Set<string>()),
pulseDelayFor: () => "0ms",
toggleCollapsed: noop,
toggleEnabled: noop,
removeModule: noop,
toggleLockOnCard: noop,
toggleInternalOnCard: noop,
onDragStart: noop,
onDragEnd: noop,
openContextMenu: noop,
onCardKeydown: noop,
pairingFor: () => null,
viaInboundFor: () => [],
contributorsFor: () => [],
...over,
};
}
function module(over: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "m1",
_uid: "uid1",
type: "wildcard",
enabled: true,
meta: { name: "subject" },
entries: [],
...over,
};
}
function mountRow(ctx: ModuleRowCtx, mod: ModuleEntry) {
return mount(ModuleRow, {
props: { module: mod, idx: 0 },
global: { provide: { [ModuleRowCtxKey as symbol]: ctx } },
});
}
const contrib = (n: number, colorIndex = n): PairingBadge => ({
number: n,
targetUuid: "aaaaaaaa",
colorIndex,
isOrphan: false,
});
describe("ModuleRow.vue — badge before name", () => {
it("renders the pair badge BEFORE the module name in DOM order", () => {
const ctx = makeCtx({ contributorsFor: () => [contrib(1)] });
const wrapper = mountRow(ctx, module());
const html = wrapper.html();
const badgeIdx = html.indexOf("wp-pair-badge");
const nameIdx = html.indexOf("wp-module-name");
expect(badgeIdx).toBeGreaterThanOrEqual(0);
expect(nameIdx).toBeGreaterThanOrEqual(0);
expect(badgeIdx).toBeLessThan(nameIdx);
});
it("renders the sender badge before the name for a constraint row", () => {
const senderBadge: PairingBadge = {
number: 1,
targetUuid: "aaaaaaaa",
colorIndex: 2,
isOrphan: false,
reach: { mode: "all" },
};
const ctx = makeCtx({ pairingFor: () => senderBadge });
const wrapper = mountRow(ctx, module({ type: "constraint", meta: { name: "no red" } }));
const html = wrapper.html();
expect(html.indexOf("wp-pair-badge")).toBeLessThan(html.indexOf("wp-module-name"));
// Sender carries its reach suffix.
expect(wrapper.text()).toContain("·all");
});
});
describe("ModuleRow.vue — contributor cluster", () => {
it("renders one chip per contributor when there are 2 or fewer", () => {
const ctx = makeCtx({ contributorsFor: () => [contrib(1), contrib(2)] });
const wrapper = mountRow(ctx, module());
expect(wrapper.findAll(".wp-pair-badge")).toHaveLength(2);
expect(wrapper.find(".wp-pair-badge--collapse").exists()).toBe(false);
});
it("renders exactly ONE collapse chip ↥×N when there are 3 or more contributors", () => {
const ctx = makeCtx({ contributorsFor: () => [contrib(1), contrib(2), contrib(3)] });
const wrapper = mountRow(ctx, module());
const collapse = wrapper.findAll(".wp-pair-badge--collapse");
expect(collapse).toHaveLength(1);
expect(collapse[0].text()).toContain("↥×3");
// The collapse chip REPLACES the individual chips — no per-contributor
// direct chips alongside it.
expect(wrapper.findAll(".wp-pair-badge")).toHaveLength(1);
});
it("renders no contributor chip on a row no constraint covers", () => {
const ctx = makeCtx({ contributorsFor: () => [] });
const wrapper = mountRow(ctx, module());
expect(wrapper.find(".wp-pair-badge").exists()).toBe(false);
});
});
+33 -3
View File
@@ -14,7 +14,7 @@ if (!ctx) throw new Error("ModuleRow: missing moduleRowCtx provider");
const {
KIND_TITLE, kindIcon, kindChipModifier, varColorClass,
isCollapsed, isLocked, isInternal, isSeedLockable,
isModified, isDrifted, isMissingFromLibrary,
isModified, isDrifted, isMissingFromLibrary, isTypeConflict,
severityFor, conflictTooltip, conflictBadgeText,
modifiedTooltip, summaryFor, summaryTokens, siblingInfo,
rowGap,
@@ -23,6 +23,7 @@ const {
onDragStart, onDragEnd, openContextMenu, onCardKeydown,
pairingFor,
viaInboundFor,
contributorsFor,
} = ctx;
</script>
@@ -84,11 +85,36 @@ const {
class="wp-kind-chip" :class="`wp-kind-chip--${kindChipModifier(module.type)}`">
{{ KIND_TITLE[module.type] ?? module.type }}
</span>
<!-- Constraint (SENDER) row: the constraint's own `#N` chip,
carrying its reach selector (`·all` / `·first` / `·n2` /
`·pick`) + the ↪ via-glyph when its target is reached through
a nested ref. One chip per constraint row. -->
<PairBadge
v-if="pairingFor(module._uid ?? module.id)"
v-if="module.type === 'constraint' && pairingFor(module._uid ?? module.id)"
:pair="pairingFor(module._uid ?? module.id)!"
:variant="module.type === 'constraint' ? 'sender' : 'direct'"
variant="sender"
/>
<!-- Target (WILDCARD) row: the CONTRIBUTOR CLUSTER — every
constraint whose reach covers this instance. ≤2 contributors
render as individual `#N` chips; ≥3 collapse into ONE solid
`↥×N` chip (popover lists them all) so a heavily-constrained
row never sprawls into 3+ chips. -->
<template v-else-if="module.type !== 'constraint' && contributorsFor(module._uid ?? module.id).length > 0">
<template v-if="contributorsFor(module._uid ?? module.id).length <= 2">
<PairBadge
v-for="(c, ci) in contributorsFor(module._uid ?? module.id)"
:key="`contrib-${c.number}-${c.targetUuid}-${ci}`"
:pair="c"
variant="direct"
/>
</template>
<PairBadge
v-else
:pairs="contributorsFor(module._uid ?? module.id)"
:count="contributorsFor(module._uid ?? module.id).length"
variant="collapse"
/>
</template>
<!-- Carrier-side: ONE collapsed `↪×N` chip aggregating every
constraint whose target lives in one of THIS wildcard's
nested @{} refs. Solid background; multi-color fallback when
@@ -109,6 +135,10 @@ const {
title="Drifted — library has a newer version. Right-click → Refresh from library." aria-hidden="true"></span>
<span v-if="isDrifted(module)" class="wp-mod-badge wp-mod-badge--drift"
title="Drifted — library has a newer version. Right-click → Refresh from library.">drift</span>
<span v-if="isTypeConflict(module)" class="wp-mod-dot wp-mod-dot--clash"
title="Id clash — a different kind of item uses this id in your library. Right-click → Push to library to add this one as a new entry." aria-hidden="true"></span>
<span v-if="isTypeConflict(module)" class="wp-mod-badge wp-mod-badge--clash"
title="Id clash — a different kind of item uses this id in your library. Right-click → Push to library to add this one as a new entry.">clash</span>
<span v-if="isMissingFromLibrary(module)" class="wp-mod-dot wp-mod-dot--missing"
title="Not in library — right-click → Save to library to add it" aria-hidden="true"></span>
<span v-if="isMissingFromLibrary(module)" class="wp-mod-badge wp-mod-badge--missing"
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect, afterEach } from "vitest";
import { mount } from "@vue/test-utils";
import PairBadge from "./PairBadge.vue";
import type { PairingBadge } from "../../extension/constraint-pairs";
afterEach(() => {
// Popover teleports to <body> — strip leftover overlays between tests.
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
});
/** Minimal sender badge with a given reach selector. */
function senderPair(reach: PairingBadge["reach"], over: Partial<PairingBadge> = {}): PairingBadge {
return {
number: 1,
targetUuid: "aaaaaaaa",
colorIndex: 3,
isOrphan: false,
reach,
...over,
};
}
describe("PairBadge.vue — sender reach suffix", () => {
it("renders ·all for the default all reach", () => {
const wrapper = mount(PairBadge, {
props: { pair: senderPair({ mode: "all" }), variant: "sender" },
});
expect(wrapper.text()).toContain("#1");
expect(wrapper.text()).toContain("·all");
});
it("renders ·first for first reach", () => {
const wrapper = mount(PairBadge, {
props: { pair: senderPair({ mode: "first" }), variant: "sender" },
});
expect(wrapper.text()).toContain("·first");
});
it("renders ·n{count} for next reach", () => {
const wrapper = mount(PairBadge, {
props: { pair: senderPair({ mode: "next", count: 2 }), variant: "sender" },
});
expect(wrapper.text()).toContain("·n2");
});
it("renders ·pick for pick reach", () => {
const wrapper = mount(PairBadge, {
props: { pair: senderPair({ mode: "pick", picks: [] }), variant: "sender" },
});
expect(wrapper.text()).toContain("·pick");
});
it("keeps the via ↪ glyph alongside the reach suffix", () => {
const wrapper = mount(PairBadge, {
props: {
pair: senderPair(
{ mode: "all" },
{ via: { carrierRowKey: "c1", carrierName: "backdrop", optionIds: ["o1"], routeChain: ["aaaaaaaa"] } },
),
variant: "sender",
},
});
expect(wrapper.text()).toContain("↪");
expect(wrapper.text()).toContain("·all");
});
it("does not append a reach suffix to a direct (target) chip", () => {
const wrapper = mount(PairBadge, {
props: { pair: { number: 1, targetUuid: "aaaaaaaa", colorIndex: 3, isOrphan: false }, variant: "direct" },
});
expect(wrapper.text()).toContain("#1");
expect(wrapper.text()).not.toContain("·");
});
});
describe("PairBadge.vue — collapse variant", () => {
const pairs: PairingBadge[] = [
{ number: 1, targetUuid: "aaaaaaaa", colorIndex: 1, isOrphan: false },
{ number: 2, targetUuid: "aaaaaaaa", colorIndex: 2, isOrphan: false },
{ number: 3, targetUuid: "aaaaaaaa", colorIndex: 3, isOrphan: false },
];
it("renders ↥×N from the pairs length", () => {
const wrapper = mount(PairBadge, {
props: { pairs, variant: "collapse" },
});
expect(wrapper.text()).toContain("↥×3");
});
it("renders ↥×N from an explicit count prop", () => {
const wrapper = mount(PairBadge, {
props: { count: 4, pairs, variant: "collapse" },
});
expect(wrapper.text()).toContain("↥×4");
});
it("carries the .wp-pair-badge--collapse class and not the carrier multi-color class", () => {
const wrapper = mount(PairBadge, {
props: { pairs, variant: "collapse" },
});
const chip = wrapper.find(".wp-pair-badge");
expect(chip.exists()).toBe(true);
expect(chip.classes()).toContain("wp-pair-badge--collapse");
// Solid collapse chip is NOT the dim multi-color carrier surface.
expect(chip.classes()).not.toContain("wp-pair-badge--via-multi");
});
it("opens a popover listing every collapsed pair", async () => {
const wrapper = mount(PairBadge, {
attachTo: document.body,
props: { pairs, variant: "collapse" },
});
await wrapper.find(".wp-pair-badge").trigger("click");
await wrapper.vm.$nextTick();
const pop = document.querySelector(".wp-pair-pop");
expect(pop).not.toBeNull();
// Each numbered entry should be present.
expect(pop?.textContent).toContain("#1");
expect(pop?.textContent).toContain("#2");
expect(pop?.textContent).toContain("#3");
});
});
describe("PairBadge.vue — popover REACH + PATH rows", () => {
it("renders a REACH row from the primary pair's reach", async () => {
const wrapper = mount(PairBadge, {
attachTo: document.body,
props: { pair: senderPair({ mode: "next", count: 2 }), variant: "sender" },
});
await wrapper.find(".wp-pair-badge").trigger("click");
await wrapper.vm.$nextTick();
const pop = document.querySelector(".wp-pair-pop");
expect(pop).not.toBeNull();
expect(pop?.textContent?.toLowerCase()).toContain("reach");
// "next 2" is the human label for a next/count reach.
expect(pop?.textContent).toContain("next 2");
});
it("renders a PATH row when via.routeChain is present", async () => {
const wrapper = mount(PairBadge, {
attachTo: document.body,
props: {
pair: senderPair(
{ mode: "all" },
{
via: {
carrierRowKey: "c1",
carrierName: "backdrop",
optionIds: ["o1"],
routeChain: ["bbbbbbbb", "aaaaaaaa"],
},
},
),
variant: "sender",
},
});
await wrapper.find(".wp-pair-badge").trigger("click");
await wrapper.vm.$nextTick();
const pop = document.querySelector(".wp-pair-pop");
expect(pop).not.toBeNull();
expect(pop?.textContent?.toLowerCase()).toContain("path");
// Hops rendered (uuids resolve to themselves when no name cached).
expect(pop?.textContent).toContain("bbbbbbbb");
expect(pop?.textContent).toContain("aaaaaaaa");
});
});
+149 -11
View File
@@ -35,16 +35,21 @@ import { computed, nextTick, onBeforeUnmount, ref } from "vue";
import type { PairingBadge } from "../../extension/constraint-pairs";
import { cacheVersion, ensure, lookup } from "../../extension/preview-resolver";
type Variant = "sender" | "direct" | "carrier" | "option";
type Variant = "sender" | "direct" | "carrier" | "option" | "collapse";
const props = defineProps<{
/** Single-pair variants (sender / direct / option / carrier-with-1) — the
* one pair this chip represents. Carrier variant accepts the array
* form via `pairs` instead. */
* one pair this chip represents. Carrier + collapse variants accept the
* array form via `pairs` instead. */
pair?: PairingBadge;
/** Multi-pair carrier variant — aggregate inbound via-pairs collapsed
* into one `↪×N` chip. Ignored for non-carrier variants. */
/** Multi-pair carrier / collapse variant — aggregate the pairs this chip
* stands in for, collapsed into one chip. Ignored for single-pair
* variants. */
pairs?: readonly PairingBadge[];
/** Optional explicit count for the `collapse` variant. When omitted the
* chip falls back to `pairs.length`. Lets a caller render `↥×N` from a
* total even if it hands a partial list to the popover. */
count?: number;
variant: Variant;
}>();
@@ -52,7 +57,7 @@ const props = defineProps<{
* a list (collapsed) or a single (legacy / single-inbound). Other
* variants always single. */
const pairList = computed<readonly PairingBadge[]>(() => {
if (props.variant === "carrier") {
if (props.variant === "carrier" || props.variant === "collapse") {
if (props.pairs && props.pairs.length > 0) return props.pairs;
if (props.pair) return [props.pair];
return [];
@@ -170,8 +175,77 @@ function routeLabel(p: PairingBadge): string {
return "Via this wildcard's nested ref";
}
/** Compact reach suffix for the SENDER chip, appended after `#N`.
* ALWAYS shown — the default `all` reads `·all` so the chip never
* hides which instances a constraint covers:
* - all → `·all`
* - first → `·first`
* - next → `·n{count}` (count coerced, default 1 → `·n1`)
* - pick → `·pick`
* Returns "" for any non-sender variant (only the originating
* constraint carries its own reach). */
const reachSuffix = computed<string>(() => {
if (props.variant !== "sender") return "";
const reach = primaryPair.value?.reach;
if (!reach) return "";
switch (reach.mode) {
case "first":
return "·first";
case "next": {
const cnt = Number.isFinite(Number(reach.count)) ? Number(reach.count) : 1;
return `·n${cnt}`;
}
case "pick":
return "·pick";
default:
return "·all";
}
});
/** Human-readable reach label for the popover REACH row. Mirrors
* `reachSuffix` but spelled out: `all` / `first` / `next {count}` /
* `pick`. Null when there's no reach to show (non-sender pairs). */
function reachLabel(p: PairingBadge): string | null {
const reach = p.reach;
if (!reach) return null;
switch (reach.mode) {
case "first":
return "first";
case "next": {
const cnt = Number.isFinite(Number(reach.count)) ? Number(reach.count) : 1;
return `next ${cnt}`;
}
case "pick":
return "pick";
default:
return "all";
}
}
/** Hop labels for the popover PATH row, target LAST. Resolves each uuid
* in `via.routeChain` to its display name via the preview-resolver
* cache (rendered `@name`), falling back to the bare uuid when no name
* is known yet. Empty when the pair has no routeChain. */
function pathHops(p: PairingBadge): string[] {
const chain = p.via?.routeChain;
if (!chain || chain.length === 0) return [];
return chain.map((uuid) => {
const name = nameForUuid(uuid);
return name ? `@${name}` : uuid;
});
}
const chipClasses = computed<string[]>(() => {
const cls = ["wp-pair-badge"];
// Collapse chip is its OWN solid surface (var(--wp-accent-300) fill via
// the modifier) — no per-target color class, no multi-color fallback,
// no carrier styling. Same base geometry (pad / font / radius) as a
// direct pair badge, just a different paint. Short-circuit so none of
// the color/carrier/orphan branches below apply.
if (props.variant === "collapse") {
cls.push("wp-pair-badge--collapse");
return cls;
}
if (isMultiColor.value) {
cls.push("wp-pair-badge--via-multi");
} else if (primaryPair.value) {
@@ -201,14 +275,43 @@ const chipClasses = computed<string[]>(() => {
* against `×`, and keeps the glyph readable at chip-size. */
const carrierText = computed<string>(() => `↪ ×${pairList.value.length}`);
/** Collapse chip count — explicit `count` prop wins, else the popover
* list length. Drives the `↥×N` glyph that REPLACES the individual
* contributor chips on a target row with 3+ contributors. */
const collapseCount = computed<number>(() =>
typeof props.count === "number" ? props.count : pairList.value.length,
);
const collapseText = computed<string>(() => `↥×${collapseCount.value}`);
const popoverAriaLabel = computed<string>(() => {
if (props.variant === "carrier") {
return `${pairList.value.length} constraint pair${pairList.value.length === 1 ? "" : "s"} via nested refs`;
}
if (props.variant === "collapse") {
return `${collapseCount.value} constraint contributor${collapseCount.value === 1 ? "" : "s"}`;
}
return primaryPair.value
? `Constraint pair ${primaryPair.value.number}`
: "Constraint pair";
});
/** True when the popover lists MORE THAN ONE pair (collapsed carrier or
* the new collapse contributor cluster). Drives the `×N` head, the
* per-entry `#N` head, and the multi-entry title/hint copy. */
const isMultiEntry = computed<boolean>(
() =>
(props.variant === "carrier" || props.variant === "collapse")
&& pairList.value.length > 1,
);
/** Head title for the multi-entry popover. Collapse (contributor
* cluster) reads "Contributing constraints"; the carrier route chip
* keeps its "Routed constraint pairs" wording. */
const multiEntryTitle = computed<string>(() =>
props.variant === "collapse"
? "Contributing constraints"
: "Routed constraint pairs",
);
</script>
<template>
@@ -230,12 +333,15 @@ const popoverAriaLabel = computed<string>(() => {
>
<template v-if="variant === 'sender' || variant === 'direct'">
<span v-if="primaryPair?.isOrphan" class="wp-pair-badge__warn">!</span>
<template v-if="variant === 'sender' && primaryPair?.via">#{{ primaryPair.number }} ↪</template>
<template v-else>#{{ primaryPair?.number }}</template>
<template v-if="variant === 'sender' && primaryPair?.via">#{{ primaryPair.number }} ↪{{ reachSuffix }}</template>
<template v-else>#{{ primaryPair?.number }}{{ reachSuffix }}</template>
</template>
<template v-else-if="variant === 'carrier'">
{{ carrierText }}
</template>
<template v-else-if="variant === 'collapse'">
{{ collapseText }}
</template>
<template v-else>
↪#{{ primaryPair?.number }}
</template>
@@ -252,10 +358,10 @@ const popoverAriaLabel = computed<string>(() => {
@mouseleave="onLeave"
>
<div class="wp-pair-pop__head">
<span v-if="variant === 'carrier' && pairList.length > 1" class="wp-pair-pop__num">×{{ pairList.length }}</span>
<span v-if="isMultiEntry" class="wp-pair-pop__num">×{{ pairList.length }}</span>
<span v-else-if="primaryPair" class="wp-pair-pop__num">#{{ primaryPair.number }}</span>
<span class="wp-pair-pop__title">
{{ variant === "carrier" && pairList.length > 1 ? "Routed constraint pairs" : "Constraint pair" }}
{{ isMultiEntry ? multiEntryTitle : "Constraint pair" }}
</span>
</div>
<div
@@ -264,13 +370,25 @@ const popoverAriaLabel = computed<string>(() => {
class="wp-pair-pop__entry"
:class="`wp-pair-pop__entry--c${p.colorIndex}`"
>
<div v-if="variant === 'carrier' && pairList.length > 1" class="wp-pair-pop__entry-head">
<div v-if="isMultiEntry" class="wp-pair-pop__entry-head">
<span class="wp-pair-pop__entry-num">#{{ p.number }}</span>
</div>
<div class="wp-pair-pop__row">
<span class="wp-pair-pop__lbl">Route</span>
<span class="wp-pair-pop__val">{{ routeLabel(p) }}</span>
</div>
<div v-if="reachLabel(p)" class="wp-pair-pop__row wp-pair-pop__row--reach">
<span class="wp-pair-pop__lbl">Reach</span>
<span class="wp-pair-pop__val wp-pair-pop__reach">{{ reachLabel(p) }}</span>
</div>
<div v-if="pathHops(p).length > 0" class="wp-pair-pop__row wp-pair-pop__row--path">
<span class="wp-pair-pop__lbl">Path</span>
<span class="wp-pair-pop__val wp-pair-pop__path">
<template v-for="(hop, hi) in pathHops(p)" :key="`${hi}-${hop}`"><span
class="wp-pair-pop__hop">{{ hop }}</span><span
v-if="hi < pathHops(p).length - 1" class="wp-pair-pop__hop-arrow"> → </span></template>
</span>
</div>
<div class="wp-pair-pop__row">
<span class="wp-pair-pop__lbl">Target</span>
<span class="wp-pair-pop__val">
@@ -296,6 +414,9 @@ const popoverAriaLabel = computed<string>(() => {
The constraint claims the next downstream wildcard instance with this uuid.
</template>
</div>
<div v-else-if="variant === 'collapse'" class="wp-pair-pop__hint">
Every constraint above covers this target instance. Each applies its own reach + weighting when it fires.
</div>
<div v-else class="wp-pair-pop__hint">
Each constraint fires when this wildcard rolls an option carrying its target ref.
</div>
@@ -410,6 +531,23 @@ const popoverAriaLabel = computed<string>(() => {
color: var(--wp-text-dim, var(--wp-text-muted, #8a8d99));
line-height: 1.4;
}
/* Reach row — green so the constraint's coverage selector reads as a
* distinct, positive fact in the card (mirrors the green reach accent
* the reach-selector UI uses elsewhere). */
.wp-pair-pop__reach {
color: var(--wp-green, #6bc96f);
font-weight: 600;
}
/* Path row — the transitive hop chain a constraint travels to reach a
* nested target, target last. Wraps + uses a dim arrow between hops. */
.wp-pair-pop__path {
display: inline;
word-break: break-word;
}
.wp-pair-pop__hop-arrow {
color: var(--wp-text-dim, #7a7d88);
white-space: pre;
}
/* Color-band the head badge color to match the chip. */
.wp-pair-pop--c1 .wp-pair-pop__num { color: var(--wp-var-1); }
@@ -18,6 +18,7 @@ import { computed, onBeforeUnmount, ref, watch } from "vue";
import { bundleHashes } from "./drift-store";
import { api } from "../../manager/api/client";
import type { BundleInstance } from "../../widgets/_shared";
import WpCheck from "@/components/shared/WpCheck.vue";
interface ChildPreview {
name: string;
@@ -393,11 +394,11 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
class="wp-ptl-cascade"
data-test="pbtl-cascade"
>
<label class="wp-ptl-cascade__toggle">
<input
<span class="wp-ptl-cascade__toggle">
<WpCheck
v-model="cascadeEnabled"
type="checkbox"
data-test="pbtl-cascade-toggle"
aria-label="Restore missing references first"
/>
<span>
Restore
@@ -408,7 +409,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
: "s"
}} first
</span>
</label>
</span>
<div class="wp-ptl-cascade__hint">
Cascade-creates fresh library entries for every missing
child (bottom-up — modules first, then inner bundles)
@@ -253,9 +253,9 @@ describe("PushToLibraryModal", () => {
pushResponse({ payload_hash: "h", bundles_updated: [], name: "test_w" });
const wrap = await openModal(makeDraft());
const cb = document.querySelector<HTMLInputElement>('[data-test="ptl-propagate"]')!;
cb.checked = false;
cb.dispatchEvent(new Event("change"));
// propagate defaults on; one click toggles it off (wp-check span).
const cb = document.querySelector<HTMLElement>('[data-test="ptl-propagate"]')!;
cb.click();
await flushPromises();
document.querySelector<HTMLButtonElement>('[data-test="ptl-update"]')!.click();
@@ -29,6 +29,7 @@ import { forkModule } from "./duplicates/fork";
import { hashes as libraryHashes } from "./drift-store";
import { app } from "#comfyui/app";
import type { ModuleEntry } from "../../widgets/_shared";
import WpCheck from "@/components/shared/WpCheck.vue";
interface Bundle {
id: string;
@@ -395,14 +396,14 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
<ul class="wp-ptl-bundles__list">
<li v-for="b in bundles" :key="b.id">{{ b.name }}</li>
</ul>
<label class="wp-ptl-checkbox">
<input
<span class="wp-ptl-checkbox">
<WpCheck
v-model="propagate"
type="checkbox"
data-test="ptl-propagate"
aria-label="Propagate update to those bundles"
/>
Propagate update to those bundles
</label>
</span>
</div>
<div v-if="siblings > 1" class="wp-ptl-note wp-ptl-note--warn" data-test="ptl-siblings">
@@ -0,0 +1,751 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { cascadeRestoreForBundle, rewriteChildId } from "./cascade-restore";
import {
bundleSnapshotModified,
computeBundleFingerprint,
} from "./bundle-fingerprint";
import { api } from "../../../manager/api/client";
import type { BundleRow, ModuleRow } from "../../../manager/api/types";
import {
emptyBundleInstance,
type BundleInstance,
type ModuleEntry,
} from "../../../widgets/_shared";
/**
* #2 regression: cascade-restore must remap references INSIDE a child's
* payload (constraint source/target, @{} refs), not just the child's own id.
* When Phase 1 re-creates a missing module under a fresh uuid, a sibling
* constraint that points at the old uuid must be re-pointed too, or the
* pushed bundle ships a dead source/target.
*/
describe("cascade-restore rewriteChildId — payload ref remap (#2)", () => {
it("remaps a constraint child's source/target uuids to the restored module ids", () => {
const moduleMap = new Map([
["aaaaaaaa", "bbbbbbbb"],
["cccccccc", "dddddddd"],
]);
const child = {
id: "cn000001",
type: "constraint",
payload: { source_wildcard_id: "aaaaaaaa", target_wildcard_id: "cccccccc", matrix: {} },
};
const out = rewriteChildId(child, moduleMap, new Map());
const p = out.payload as Record<string, unknown>;
expect(p.source_wildcard_id).toBe("bbbbbbbb");
expect(p.target_wildcard_id).toBe("dddddddd");
});
it("remaps embedded @{uuid} refs in a child's payload (preserving #name)", () => {
const moduleMap = new Map([["aabbccdd", "11223344"]]);
const child = {
id: "wc000001",
type: "wildcard",
payload: { options: [{ id: "o1", value: "see @{aabbccdd#color} here" }] },
};
const out = rewriteChildId(child, moduleMap, new Map());
expect(JSON.stringify(out.payload)).toContain("@{11223344#color}");
});
it("remaps refs found in a child's instance overrides too", () => {
const moduleMap = new Map([["aaaaaaaa", "bbbbbbbb"]]);
const child = {
id: "cn000002",
type: "constraint",
payload: { source_wildcard_id: "aaaaaaaa" },
instance: { target_select: { picks: [{ carrier_uid: "aaaaaaaa", option_id: "r1:0" }] } },
};
const out = rewriteChildId(child, moduleMap, new Map());
expect(JSON.stringify(out.instance)).toContain("bbbbbbbb");
expect(JSON.stringify(out.instance)).not.toContain("aaaaaaaa");
});
it("leaves refs to non-restored (external) uuids untouched", () => {
const child = {
id: "cn000003",
type: "constraint",
payload: { source_wildcard_id: "eeeeeeee", target_wildcard_id: "ffffffff" },
};
const out = rewriteChildId(child, new Map([["aaaaaaaa", "bbbbbbbb"]]), new Map());
const p = out.payload as Record<string, unknown>;
expect(p.source_wildcard_id).toBe("eeeeeeee");
expect(p.target_wildcard_id).toBe("ffffffff");
});
it("still rewrites the child's own top-level id (module + bundle)", () => {
const moduleMap = new Map([["aaaaaaaa", "bbbbbbbb"]]);
const innerBundleMap = new Map([["bndl1111", "bndl2222"]]);
const mod = rewriteChildId({ id: "aaaaaaaa", type: "wildcard", payload: {} }, moduleMap, innerBundleMap);
expect(mod.id).toBe("bbbbbbbb");
const bun = rewriteChildId({ id: "bndl1111", type: "bundle" }, moduleMap, innerBundleMap);
expect(bun.id).toBe("bndl2222");
});
});
/**
* #2 end-to-end: the headline claim is a CHAIN — push → cascade restore
* re-creates the missing wildcard under a fresh uuid → a sibling
* constraint pointing at the dead uuid is repointed in the children the
* outer ships. The pure `rewriteChildId` tests above lock the rewrite
* primitive; this exercises `cascadeRestoreForBundle` itself (Phase 1
* POST → Phase 3 children rebuild → Phase 4 workflow rebind) with the
* module POST mocked. Also re-asserts the #2 invariant inside the live
* flow: an embedded `@{uuid}` ref in the constraint follows the repoint
* too, not just the bare source/target fields.
*/
describe("cascadeRestoreForBundle — end-to-end ref repoint (#2)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
function mkModule(
over: Partial<ModuleEntry> & { bundle_origin?: string },
): ModuleEntry & { bundle_origin?: string } {
return {
id: "00000000",
type: "wildcard",
enabled: true,
collapsed: false,
meta: { name: "" },
entries: [],
payload: {},
instance: {},
payload_hash: "h",
_uid: "u",
...over,
} as ModuleEntry & { bundle_origin?: string };
}
it("restores a missing wildcard and repoints its sibling constraint's source + embedded @{} ref", async () => {
const fetchMock = vi.fn(
async () =>
({
ok: true,
json: async () => ({ id: "beef0001" }),
text: async () => "",
}) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
// deadbeef = the wildcard whose library row was deleted upstream.
const wildcard = mkModule({
id: "deadbeef",
type: "wildcard",
_uid: "u-w",
meta: { name: "Palette" },
payload: { options: [] },
bundle_origin: "outer-uid",
});
// A sibling constraint sources from deadbeef + carries an embedded
// @{deadbeef#palette} ref inside an exception value. facade00 is an
// EXTERNAL target (not restored) — it must survive untouched.
const constraint = mkModule({
id: "ca000001",
type: "constraint",
_uid: "u-c",
meta: { name: "Pairing" },
payload: {
source_wildcard_id: "deadbeef",
target_wildcard_id: "facade00",
matrix: {},
exceptions: [{ source_value: "see @{deadbeef#palette}", target_value: "sky" }],
},
bundle_origin: "outer-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("bndllib0"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 1,
name: "Kit",
};
const modules = [wildcard, constraint];
const result = await cascadeRestoreForBundle({
outer,
modules,
bundles: [outer],
isModuleMissing: (m) => m.id === "deadbeef",
isBundleMissing: () => false,
});
// Only the wildcard was missing → exactly one module POST.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.restoredModuleCount).toBe(1);
const cn = result.rewrittenChildren.find((c) => c.type === "constraint");
const p = cn?.payload as Record<string, unknown>;
expect(p.source_wildcard_id).toBe("beef0001"); // repointed to the restored id
expect(p.target_wildcard_id).toBe("facade00"); // external ref left alone
// #2 invariant re-asserted in the live flow: embedded @{} ref follows too,
// preserving its #name segment.
expect(JSON.stringify(cn?.payload)).toContain("@{beef0001#palette}");
expect(JSON.stringify(cn?.payload)).not.toContain("deadbeef");
// Workflow rebind swaps the dead id while preserving the per-instance _uid.
const w = result.newModules.find((m) => m._uid === "u-w");
expect(w?.id).toBe("beef0001");
});
});
/**
* QA bug (2026-06-12): bundle push-with-cascade reattached the constraint's
* TARGET but not its SOURCE — in the WORKFLOW. Root cause: Phase 4's
* `newModules` swapped each module's own `id` but never rewrote payload refs,
* so a restored constraint's source/target stayed pointed at the dead uuids
* on the canvas (the pushed library children, Phase 3, were already correct).
* This locks the workflow rebind: a restored module's INTERNAL refs follow
* the restore too, both endpoints.
*/
describe("cascadeRestoreForBundle — Phase 4 workflow rebind rewrites payload refs", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
function mkMod(
over: Partial<ModuleEntry> & { bundle_origin?: string },
): ModuleEntry & { bundle_origin?: string } {
return {
id: "00000000",
type: "wildcard",
enabled: true,
collapsed: false,
meta: { name: "" },
entries: [],
payload: {},
instance: {},
payload_hash: "h",
_uid: "u",
...over,
} as ModuleEntry & { bundle_origin?: string };
}
it("repoints a restored constraint's source AND target in newModules, not just rewrittenChildren", async () => {
// Distinct id per POST, in outer-range order: subject, mood, constraint.
const minted = ["beef0001", "beef0002", "ca110011"];
let n = 0;
const fetchMock = vi.fn(
async () =>
({
ok: true,
// `headers` present because Phase 1 Pass 2's corrective PUT for the
// restored constraint routes through `api.modules.update` →
// `request()` → `checkStartupId(resp)`, which reads `resp.headers`.
headers: { get: () => null },
json: async () => ({ id: minted[n++] }),
text: async () => "",
}) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
const subject = mkMod({
id: "deadbeef",
type: "wildcard",
_uid: "u-s",
meta: { name: "subject" },
bundle_origin: "outer-uid",
});
const mood = mkMod({
id: "facade00",
type: "wildcard",
_uid: "u-m",
meta: { name: "mood" },
bundle_origin: "outer-uid",
});
const constraint = mkMod({
id: "ca000001",
type: "constraint",
_uid: "u-c",
meta: { name: "pairing" },
payload: { source_wildcard_id: "deadbeef", target_wildcard_id: "facade00", matrix: {} },
bundle_origin: "outer-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("bndllib1"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 2,
name: "Kit",
};
const modules = [subject, mood, constraint];
const result = await cascadeRestoreForBundle({
outer,
modules,
bundles: [outer],
isModuleMissing: (m) => ["deadbeef", "facade00", "ca000001"].includes(m.id),
isBundleMissing: () => false,
});
// All three restored → ids swapped in the workflow rebind.
const s = result.newModules.find((m) => m._uid === "u-s");
const t = result.newModules.find((m) => m._uid === "u-m");
const c = result.newModules.find((m) => m._uid === "u-c");
expect(s?.id).toBe("beef0001");
expect(t?.id).toBe("beef0002");
// The CANVAS constraint must repoint BOTH endpoints (the SRC-MISSING bug
// was: only target followed the restore, source stayed dead).
const p = c?.payload as Record<string, unknown>;
expect(p.source_wildcard_id).toBe("beef0001");
expect(p.target_wildcard_id).toBe("beef0002");
});
});
/**
* Phase 1 third-output bug (forward-ref): each missing module is POSTed
* RAW to mint a fresh library id (Pass 1). For a restored constraint, that
* standalone library entry ships DANGLING source/target — they still point
* at the OLD (now-deleted) uuids, because the constraint is POSTed BEFORE
* its target wildcard mints a new id (moduleMap is incomplete mid-loop).
* Phase 3 (pushed bundle children) + Phase 4 (workflow rebind) both already
* rewrite refs; Phase 1's standalone library entries did not.
*
* The fix is a two-pass Phase 1: Pass 1 mints ids (raw POST); Pass 2 runs
* AFTER the loop (moduleMap COMPLETE), walkRemaps each restored module's
* payload and, when refs actually changed, corrects the freshly-created
* library entry via `api.modules.update`. A pure wildcard with no internal
* refs must NOT trigger a corrective PUT.
*/
describe("cascadeRestoreForBundle — Phase 1 Pass 2 corrects standalone library refs", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function mkMod(
over: Partial<ModuleEntry> & { bundle_origin?: string },
): ModuleEntry & { bundle_origin?: string } {
return {
id: "00000000",
type: "wildcard",
enabled: true,
collapsed: false,
meta: { name: "" },
entries: [],
payload: {},
instance: {},
payload_hash: "h",
_uid: "u",
...over,
} as ModuleEntry & { bundle_origin?: string };
}
it("repoints a forward-ref constraint's source + target via api.modules.update, and skips a ref-free wildcard", async () => {
// Outer range order is FORWARD-REF: the constraint sits BEFORE its
// target wildcard, so moduleMap is incomplete when the constraint is
// POSTed — only a post-loop Pass 2 can repoint target_wildcard_id.
// [ subject (deadbeef), constraint (ca000001 → src deadbeef / tgt facade00), mood (facade00) ]
// POSTs mint, in order: beef0001 (subject), ca110011 (constraint), beef0002 (mood).
const minted = ["beef0001", "ca110011", "beef0002"];
let n = 0;
const fetchMock = vi.fn(
async () =>
({
ok: true,
json: async () => ({ id: minted[n++] }),
text: async () => "",
}) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
// Capture the corrective PUT(s) without hitting the network.
const updateSpy = vi
.spyOn(api.modules, "update")
.mockImplementation(async (id: string) => ({ id }) as unknown as ModuleRow);
const subject = mkMod({
id: "deadbeef",
type: "wildcard",
_uid: "u-s",
meta: { name: "subject" },
payload: { options: [] }, // no internal refs → must NOT trigger a PUT
bundle_origin: "outer-uid",
});
const constraint = mkMod({
id: "ca000001",
type: "constraint",
_uid: "u-c",
meta: { name: "pairing" },
payload: {
source_wildcard_id: "deadbeef",
target_wildcard_id: "facade00",
matrix: {},
},
bundle_origin: "outer-uid",
});
const mood = mkMod({
id: "facade00",
type: "wildcard",
_uid: "u-m",
meta: { name: "mood" },
payload: { options: [] }, // no internal refs → must NOT trigger a PUT
bundle_origin: "outer-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("bndllib2"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 2,
name: "Kit",
};
const modules = [subject, constraint, mood];
await cascadeRestoreForBundle({
outer,
modules,
bundles: [outer],
isModuleMissing: (m) => ["deadbeef", "ca000001", "facade00"].includes(m.id),
isBundleMissing: () => false,
});
// Exactly ONE corrective PUT — only the constraint carried sibling refs.
expect(updateSpy).toHaveBeenCalledTimes(1);
const [calledId, calledBody] = updateSpy.mock.calls[0];
// Corrective PUT targets the constraint's freshly-minted id.
expect(calledId).toBe("ca110011");
const p = (calledBody as { payload?: Record<string, unknown> }).payload;
expect(p).toBeDefined();
// BOTH endpoints repointed despite the forward-ref POST order.
expect(p?.source_wildcard_id).toBe("beef0001"); // subject's new id
expect(p?.target_wildcard_id).toBe("beef0002"); // mood's new id (forward ref!)
// The two ref-free wildcards (beef0001, beef0002) were NOT corrected.
const correctedIds = updateSpy.mock.calls.map((c) => c[0]);
expect(correctedIds).not.toContain("beef0001");
expect(correctedIds).not.toContain("beef0002");
});
});
/**
* QA bug (2026-06-12): after a cascade restore the canvas flashed DRIFT on a
* restored constraint. Root cause — Phase 4 rebound the workflow row's id +
* payload (refs repointed) but kept the STALE frozen-snapshot payload_hash,
* which no longer matched the corrected library entry's hash (a ref-free
* wildcard didn't drift because its payload, hence hash, was unchanged).
* Phase 1 now captures the library hash (POST + corrective update) and Phase 4
* stamps it, mirroring the bundle inserted_at_hash sync.
*/
describe("cascadeRestoreForBundle — Phase 4 syncs payload_hash to the library entry", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function mkMod(
over: Partial<ModuleEntry> & { bundle_origin?: string },
): ModuleEntry & { bundle_origin?: string } {
return {
id: "00000000",
type: "wildcard",
enabled: true,
collapsed: false,
meta: { name: "" },
entries: [],
payload: {},
instance: {},
payload_hash: "stale-frozen-hash",
_uid: "u",
...over,
} as ModuleEntry & { bundle_origin?: string };
}
it("stamps the corrected library hash on a restored constraint (no DRIFT), and the POST hash on a ref-free wildcard", async () => {
// POST mints {id, payload_hash} per restored module, in range order:
// subject (deadbeef) → beef0001 / "wh-post" (ref-free)
// constraint (ca000001, src deadbeef) → ca110011 / "cn-post"
const minted = [
{ id: "beef0001", payload_hash: "wh-post" },
{ id: "ca110011", payload_hash: "cn-post" },
];
let n = 0;
const fetchMock = vi.fn(
async () =>
({
ok: true,
json: async () => minted[n++],
text: async () => "",
}) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
// The corrective PUT returns the CORRECTED hash (the constraint's payload moved).
vi.spyOn(api.modules, "update").mockImplementation(
async (id: string) => ({ id, payload_hash: "cn-corrected" }) as unknown as ModuleRow,
);
const subject = mkMod({
id: "deadbeef",
type: "wildcard",
_uid: "u-s",
meta: { name: "subject" },
payload: { options: [] }, // ref-free → no corrective PUT → POST hash
bundle_origin: "outer-uid",
});
const constraint = mkMod({
id: "ca000001",
type: "constraint",
_uid: "u-c",
meta: { name: "pairing" },
payload: { source_wildcard_id: "deadbeef", target_wildcard_id: "facade00", matrix: {} },
bundle_origin: "outer-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("bndllib3"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 1,
name: "Kit",
};
const result = await cascadeRestoreForBundle({
outer,
modules: [subject, constraint],
bundles: [outer],
isModuleMissing: (m) => ["deadbeef", "ca000001"].includes(m.id),
isBundleMissing: () => false,
});
const s = result.newModules.find((m) => m._uid === "u-s");
const c = result.newModules.find((m) => m._uid === "u-c");
// Ref-free wildcard → its POST hash.
expect(s?.payload_hash).toBe("wh-post");
// Corrected constraint → the corrective-update hash, NOT the stale frozen
// hash (which is what produced the DRIFT badge).
expect(c?.payload_hash).toBe("cn-corrected");
expect(c?.payload_hash).not.toBe("stale-frozen-hash");
// Gap #1 (holistic audit 2026-06-13): the PUSHED bundle children (Phase 3)
// must carry the SAME live hashes. `toChildSnapshot` froze the stale
// pre-cascade hash, so without the rewriteChildId hash-stamp the pushed
// bundle ships a child whose payload was corrected but whose payload_hash
// is stale → RE-INSERTING the bundle shows spurious DRIFT against the live
// library entry.
const childWild = result.rewrittenChildren.find((c2) => c2.type === "wildcard");
const childCon = result.rewrittenChildren.find((c2) => c2.type === "constraint");
expect(childWild?.payload_hash).toBe("wh-post");
expect(childCon?.payload_hash).toBe("cn-corrected");
expect(childCon?.payload_hash).not.toBe("stale-frozen-hash");
});
});
/**
* QA bug (2026-06-15): pushing an outer bundle with the cascade "Restore N
* missing references first" option re-creates a missing INNER bundle, but the
* inner then flashes a false MODIFIED drift badge — even though it was just
* freshly created and matches the library. Root cause — Phase 4's `newBundles`
* rebinds each restored inner's `library_id` + `inserted_at_hash` but kept the
* STALE `snapshot_fingerprint` captured before the restore. The drift detector
* `bundleSnapshotModified` compares the LIVE fingerprint against that stale
* stored value → they differ → false MODIFIED. (`onBundlePushSaved` only
* recomputes the OUTER's fingerprint, never the inner ones.)
*
* The fix is the symmetric sibling of the inserted_at_hash sync: Phase 4 also
* reconciles each RESTORED inner's `snapshot_fingerprint` to
* `computeBundleFingerprint(rebound, newModules)` so the freshly-restored
* content matches its stored baseline (no drift). Only restored inners are
* touched — a genuinely-modified, non-restored bundle keeps its own
* fingerprint so real drift still surfaces.
*/
describe("cascadeRestoreForBundle — Phase 4 reconciles restored inner-bundle fingerprint", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function mkMod(
over: Partial<ModuleEntry> & { bundle_origin?: string },
): ModuleEntry & { bundle_origin?: string } {
return {
id: "00000000",
type: "wildcard",
enabled: true,
collapsed: false,
meta: { name: "" },
entries: [],
payload: {},
instance: {},
payload_hash: "h",
_uid: "u",
...over,
} as ModuleEntry & { bundle_origin?: string };
}
it("clears false drift on a restored inner bundle carrying a stale snapshot_fingerprint", async () => {
// No modules are missing → fetch (module POST) is never called; only the
// inner bundle's library row was deleted upstream.
const fetchMock = vi.fn(
async () =>
({ ok: true, json: async () => ({}), text: async () => "" }) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
// The restored inner bundle's fresh library entry.
const createSpy = vi
.spyOn(api.bundles, "create")
.mockResolvedValue({ id: "innernew", payload_hash: "inner-fresh-hash" } as BundleRow);
// Outer (range 0-2): a top-level wildcard at idx 0, then an inner bundle
// covering idx 1-2 (two wildcards owned by the inner).
const topWild = mkMod({
id: "top00001",
type: "wildcard",
_uid: "u-top",
meta: { name: "top" },
bundle_origin: "outer-uid",
});
const innerA = mkMod({
id: "inna0001",
type: "wildcard",
_uid: "u-ia",
meta: { name: "innerA" },
payload_hash: "ha",
bundle_origin: "inner-uid",
});
const innerB = mkMod({
id: "innb0001",
type: "wildcard",
_uid: "u-ib",
meta: { name: "innerB" },
payload_hash: "hb",
bundle_origin: "inner-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("outerlib"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 2,
name: "Kit",
};
// Missing inner bundle carrying a STALE fingerprint that won't match the
// live content — this is what produces the false MODIFIED badge today.
const inner: BundleInstance = {
...emptyBundleInstance("innerlibOLD"),
_uid: "inner-uid",
parent_uid: "outer-uid",
start_idx: 1,
end_idx: 2,
name: "Inner",
snapshot_fingerprint: "v2:stale",
};
const modules = [topWild, innerA, innerB];
const bundles = [outer, inner];
const result = await cascadeRestoreForBundle({
outer,
modules,
bundles,
isModuleMissing: () => false,
isBundleMissing: (b) => b._uid === "inner-uid",
});
// The inner bundle was restored (Phase 2 POST) and rebound (Phase 4).
expect(createSpy).toHaveBeenCalledTimes(1);
expect(result.restoredBundleCount).toBe(1);
const reboundInner = result.newBundles.find((b) => b._uid === "inner-uid")!;
// Sanity: rebind swapped the dead library_id + synced inserted_at_hash.
expect(reboundInner.library_id).toBe("innernew");
expect(reboundInner.inserted_at_hash).toBe("inner-fresh-hash");
// The headline fix: the rebound inner's snapshot_fingerprint matches the
// freshly-restored content → NO false drift. (Stale "v2:stale" would fail.)
expect(reboundInner.snapshot_fingerprint).toBe(
computeBundleFingerprint(reboundInner, result.newModules),
);
expect(bundleSnapshotModified(reboundInner, result.newModules)).toBe(false);
});
it("does NOT touch a non-restored, genuinely-modified bundle's fingerprint", async () => {
const fetchMock = vi.fn(
async () =>
({ ok: true, json: async () => ({}), text: async () => "" }) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
const createSpy = vi
.spyOn(api.bundles, "create")
.mockResolvedValue({ id: "innernew", payload_hash: "inner-fresh-hash" } as BundleRow);
// Outer (range 0-3): top-level wildcard, a MISSING inner (idx 1), and a
// SIBLING inner (idx 2-3) that is NOT missing but genuinely drifted.
const topWild = mkMod({
id: "top00001",
type: "wildcard",
_uid: "u-top",
meta: { name: "top" },
bundle_origin: "outer-uid",
});
const missingChild = mkMod({
id: "miss0001",
type: "wildcard",
_uid: "u-miss",
meta: { name: "missingChild" },
payload_hash: "hm",
bundle_origin: "missing-uid",
});
const siblingA = mkMod({
id: "sibl0001",
type: "wildcard",
_uid: "u-sa",
meta: { name: "siblingA" },
payload_hash: "hs",
bundle_origin: "sibling-uid",
});
const siblingB = mkMod({
id: "sibl0002",
type: "wildcard",
_uid: "u-sb",
meta: { name: "siblingB" },
payload_hash: "hs2",
bundle_origin: "sibling-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("outerlib"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 3,
name: "Kit",
};
const missingInner: BundleInstance = {
...emptyBundleInstance("missinglibOLD"),
_uid: "missing-uid",
parent_uid: "outer-uid",
start_idx: 1,
end_idx: 1,
name: "Missing",
snapshot_fingerprint: "v2:stale",
};
// A genuine pre-existing drift baseline: the sibling's stored fingerprint
// intentionally does NOT match its live content. Cascade must NOT erase it.
const driftedSibling: BundleInstance = {
...emptyBundleInstance("siblinglib"),
_uid: "sibling-uid",
parent_uid: "outer-uid",
start_idx: 2,
end_idx: 3,
name: "Sibling",
snapshot_fingerprint: "v2:genuinedrift",
};
const modules = [topWild, missingChild, siblingA, siblingB];
const bundles = [outer, missingInner, driftedSibling];
const result = await cascadeRestoreForBundle({
outer,
modules,
bundles,
isModuleMissing: () => false,
isBundleMissing: (b) => b._uid === "missing-uid",
});
expect(createSpy).toHaveBeenCalledTimes(1);
// The non-restored sibling keeps its stored fingerprint untouched → its
// genuine drift still surfaces. Blanket-recompute would have erased it.
const reboundSibling = result.newBundles.find((b) => b._uid === "sibling-uid")!;
expect(reboundSibling.snapshot_fingerprint).toBe("v2:genuinedrift");
expect(bundleSnapshotModified(reboundSibling, result.newModules)).toBe(true);
// The restored inner is still clean (fix applies only to it).
const reboundMissing = result.newBundles.find((b) => b._uid === "missing-uid")!;
expect(bundleSnapshotModified(reboundMissing, result.newModules)).toBe(false);
});
});
+140 -18
View File
@@ -40,6 +40,8 @@ import {
buildLibraryChildrenWithIntegrity,
toChildSnapshot,
} from "./save";
import { computeBundleFingerprint } from "./bundle-fingerprint";
import { walkRemap } from "./uuid-remap";
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
export interface CascadeRestoreInputs {
@@ -91,7 +93,28 @@ export async function cascadeRestoreForBundle(
// because inner-bundle children are still inside the outer's range —
// they need their uuids in the moduleMap before Phase 2 rebuilds the
// inner's children list.
//
// Two passes:
// Pass 1 — POST each missing module RAW to MINT a fresh id. The raw
// payload is just a seed; it may carry refs to SIBLING restored
// modules that haven't minted their new ids yet (forward refs),
// so the standalone library entry can ship dangling source/target
// / `@{}` refs at this point.
// Pass 2 — runs AFTER the loop, when `moduleMap` is COMPLETE. For
// each restored module it walkRemaps the payload against the full
// map; if any ref actually changed, it corrects the freshly-created
// library entry via `api.modules.update`. This is the same rewrite
// Phase 3 (pushed children) and Phase 4 (workflow rebind) apply —
// the standalone library entries are the third output that needs it.
// Ref-free modules (most wildcards) don't differ → no update.
const moduleMap = new Map<string, string>();
// Library payload_hash per restored module (keyed by OLD id). Phase 4 stamps
// it on the workflow row so the canvas doesn't flash DRIFT against the
// freshly-created/corrected library entry — mirror of the bundle
// inserted_at_hash sync below. A corrected constraint is the case that bites:
// its stale frozen-snapshot hash != the new entry's hash (its payload moved).
const moduleHashMap = new Map<string, string>();
const restored: { newId: string; module: ModuleEntry }[] = [];
for (let i = outer.start_idx; i <= outer.end_idx; i++) {
const m = modules[i];
if (!m) continue;
@@ -110,9 +133,32 @@ export async function cascadeRestoreForBundle(
const text = await res.text().catch(() => "");
throw new Error(`Module restore failed for "${baseName}": HTTP ${res.status} ${text}`);
}
const body = (await res.json()) as { id?: string };
const body = (await res.json()) as { id?: string; payload_hash?: string };
if (!body.id) throw new Error(`Module restore for "${baseName}" returned no id`);
moduleMap.set(m.id, body.id);
if (body.payload_hash) moduleHashMap.set(m.id, body.payload_hash);
restored.push({ newId: body.id, module: m });
}
// Pass 2: correct standalone library entries whose payload referenced a
// sibling restored module. `moduleMap` is complete now, so forward refs
// (a constraint POSTed before its target wildcard minted its id) resolve.
const restoreTable: Record<string, string> = {};
for (const [k, v] of moduleMap) restoreTable[k] = v;
if (Object.keys(restoreTable).length > 0) {
for (const { newId, module: m } of restored) {
const original = m.payload ?? {};
const rewrittenPayload = walkRemap(original, restoreTable) as Record<string, unknown>;
// Only PUT when a ref actually moved — ref-free modules are unchanged.
if (JSON.stringify(rewrittenPayload) === JSON.stringify(original)) continue;
const updated = await api.modules.update(newId, {
name: pickModuleName(m),
payload: rewrittenPayload,
});
// The corrected payload changes the hash — capture it so Phase 4 stamps
// the workflow row with the LIVE library hash (overrides the POST hash).
moduleHashMap.set(m.id, updated.payload_hash);
}
}
// ── Phase 2: restore missing inner bundles ──────────────────────────
@@ -130,7 +176,9 @@ export async function cascadeRestoreForBundle(
if (inner.parent_uid !== outer._uid) continue;
if (!isBundleMissing(inner)) continue;
const integrity = buildLibraryChildrenWithIntegrity(inner, modules, bundles);
const rewritten = integrity.children.map((c) => rewriteChildId(c, moduleMap, innerBundleMap));
const rewritten = integrity.children.map((c) =>
rewriteChildId(c, moduleMap, innerBundleMap, moduleHashMap),
);
const created = await api.bundles.create({
name: inner.name || "bundle",
color: inner.color ?? null,
@@ -143,7 +191,7 @@ export async function cascadeRestoreForBundle(
// ── Phase 3: build the outer's children with both rewrites applied ──
const outerIntegrity = buildLibraryChildrenWithIntegrity(outer, modules, bundles);
const rewrittenChildren = outerIntegrity.children.map((c) =>
rewriteChildId(c, moduleMap, innerBundleMap),
rewriteChildId(c, moduleMap, innerBundleMap, moduleHashMap),
);
// ── Phase 4: rebind workflow state so MISSING badges clear ──────────
@@ -152,18 +200,55 @@ export async function cascadeRestoreForBundle(
// since-deleted library entry at insert time) wouldn't match the new
// entry's hash and the BundleHeader would render LIBRARY UPDATED
// until the user manually reset.
const newModules = modules.map((m) =>
moduleMap.has(m.id) ? { ...m, id: moduleMap.get(m.id)! } : m,
);
// Rewrite table shared with Phase 3's child rewrite. The workflow modules
// need their INTERNAL refs repointed too — a restored constraint's
// source/target + any embedded `@{}` ref — not just their own `id`.
// Without this, after a cascade restore the canvas keeps a SRC/TGT-MISSING
// constraint even though the pushed library entry (rewrittenChildren) is
// correct: Phase 4 swapped ids but left payloads pointing at the dead uuids.
const refTable: Record<string, string> = {};
for (const [k, v] of moduleMap) refTable[k] = v;
for (const [k, v] of innerBundleMap) refTable[k] = v;
const hasRefs = Object.keys(refTable).length > 0;
const newModules = modules.map((m) => {
let next = moduleMap.has(m.id) ? { ...m, id: moduleMap.get(m.id)! } : m;
if (hasRefs) {
if (next.payload && typeof next.payload === "object") {
next = { ...next, payload: walkRemap(next.payload, refTable) as ModuleEntry["payload"] };
}
if (next.instance && typeof next.instance === "object") {
next = { ...next, instance: walkRemap(next.instance, refTable) as ModuleEntry["instance"] };
}
}
// Sync payload_hash to the restored library entry so the canvas doesn't
// flash DRIFT against it (mirror of newBundles' inserted_at_hash below).
// moduleHashMap only holds restored modules, so a non-restored row keeps
// its own hash.
const newHash = moduleHashMap.get(m.id);
if (newHash) next = { ...next, payload_hash: newHash };
return next;
});
const newBundles = bundles.map((b) => {
const next = innerBundleMap.get(b.library_id);
if (!next) return b;
const hash = innerHashMap.get(b.library_id);
return {
const rebound: BundleInstance = {
...b,
library_id: next,
...(hash ? { inserted_at_hash: hash } : {}),
};
// Reconcile the restored inner's snapshot_fingerprint to its freshly-
// restored content. Without this the rebound instance keeps the STALE
// fingerprint captured before the restore, so `bundleSnapshotModified`
// (live vs stored) flips true and the canvas flashes a false MODIFIED
// badge on a bundle that was just re-created to match the library.
// Mirror of the inserted_at_hash sync above. ONLY restored inners are
// reconciled (this branch) — a non-restored bundle returns early and
// keeps its own fingerprint, so genuine drift still surfaces.
return {
...rebound,
snapshot_fingerprint: computeBundleFingerprint(rebound, newModules),
};
});
return {
@@ -175,24 +260,61 @@ export async function cascadeRestoreForBundle(
};
}
/** Apply id rewrites to a single child entry. Bundle-typed children
* look up in `innerBundleMap`; module-typed children look up in
* `moduleMap`. Returns a NEW object only when a rewrite fires; otherwise
* reuses the original reference to avoid churn for downstream diff
* consumers. */
/** Apply id rewrites to a single child entry. Two layers:
*
* 1. The child's OWN top-level `id` — bundle-typed children look up in
* `innerBundleMap`; module-typed children in `moduleMap`.
* 2. References INSIDE the child's `payload` + `instance` that point at
* any restored module/inner-bundle — a constraint's
* `source_wildcard_id` / `target_wildcard_id`, or an `@{uuid}` ref in
* a wildcard/derivation value. Without (2), restoring a missing
* wildcard re-pointed its own child id but left a sibling
* constraint pointing at the dead uuid → the pushed bundle shipped a
* broken source/target (#2). The deep rewrite reuses `walkRemap`.
*
* Returns a NEW object only when a rewrite fires; otherwise reuses the
* original reference to avoid churn for downstream diff consumers. */
function rewriteChildId(
c: Record<string, unknown>,
moduleMap: Map<string, string>,
innerBundleMap: Map<string, string>,
moduleHashMap?: ReadonlyMap<string, string>,
): Record<string, unknown> {
let next: Record<string, unknown> = c;
// (1) Own id.
const id = typeof c.id === "string" ? c.id : "";
if (!id) return c;
if (c.type === "bundle") {
const next = innerBundleMap.get(id);
return next ? { ...c, id: next } : c;
if (id) {
const newId = c.type === "bundle" ? innerBundleMap.get(id) : moduleMap.get(id);
if (newId) next = { ...next, id: newId };
// (1b) Sync the child snapshot's `payload_hash` to the restored library
// entry's LIVE hash. `toChildSnapshot` froze the stale pre-cascade hash;
// for a restored module whose refs were corrected (Phase 1 Pass-2) the
// payload moved but the frozen hash didn't, so the pushed bundle would
// ship a child whose payload ≠ its payload_hash → RE-INSERTING the bundle
// shows spurious DRIFT against the live library entry. `moduleHashMap` is
// keyed by OLD id and holds only restored modules (POST hash, overridden
// by the corrective-update hash), so non-restored + bundle children are
// left untouched.
const freshHash = moduleHashMap?.get(id);
if (freshHash) next = { ...next, payload_hash: freshHash };
}
const next = moduleMap.get(id);
return next ? { ...c, id: next } : c;
// (2) Inner references (constraint source/target, @{} refs) → restored ids.
const table: Record<string, string> = {};
for (const [k, v] of moduleMap) table[k] = v;
for (const [k, v] of innerBundleMap) table[k] = v;
if (Object.keys(table).length > 0) {
if (next.payload && typeof next.payload === "object") {
next = { ...next, payload: walkRemap(next.payload, table) as Record<string, unknown> };
}
if (next.instance && typeof next.instance === "object") {
next = { ...next, instance: walkRemap(next.instance, table) as Record<string, unknown> };
}
}
return next;
}
/** Pre-scan to count what cascade would heal — used by the modal to
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { extractBundleChildren, type ExtractableChild } from "./extract-to-library";
// Deterministic id minter for tests: a1→n1, etc., in call order.
function seqMinter(): () => string {
let i = 0;
return () => `new${++i}`;
}
const wc = (id: string, value: string): ExtractableChild => ({
id, type: "wildcard", meta: { name: `wc-${id}` },
payload: { options: [{ id: "o1", value }] },
});
describe("extractBundleChildren", () => {
it("mints fresh ids and remaps an intra-bundle constraint's source/target", () => {
const children: ExtractableChild[] = [
wc("aaaa1111", "plain"),
wc("bbbb2222", "plain"),
{ id: "cccc3333", type: "constraint", meta: { name: "pair" },
payload: { source_wildcard_id: "aaaa1111", target_wildcard_id: "bbbb2222", matrix: {}, exceptions: [] } },
];
const { modules, remap, skipped } = extractBundleChildren(children, seqMinter());
expect(skipped).toBe(0);
expect(modules).toHaveLength(3);
expect(remap).toEqual({ aaaa1111: "new1", bbbb2222: "new2", cccc3333: "new3" });
const con = modules.find((m) => m.type === "constraint")!;
expect(con.id).toBe("new3");
expect(con.payload.source_wildcard_id).toBe("new1");
expect(con.payload.target_wildcard_id).toBe("new2");
expect(con.name).toBe("pair");
});
it("remaps a nested @{} ref pointing at a sibling child", () => {
const children: ExtractableChild[] = [
wc("aaaa1111", "plain"),
wc("bbbb2222", "see @{aaaa1111} here"),
];
const { modules } = extractBundleChildren(children, seqMinter());
const b = modules.find((m) => m.name === "wc-bbbb2222")!;
expect(b.payload.options).toEqual([{ id: "o1", value: "see @{new1} here" }]);
});
it("leaves refs pointing OUTSIDE the bundle verbatim", () => {
const children: ExtractableChild[] = [
{ id: "cccc3333", type: "constraint", meta: { name: "pair" },
payload: { source_wildcard_id: "zzzz9999", target_wildcard_id: "zzzz9999", matrix: {}, exceptions: [] } },
];
const { modules } = extractBundleChildren(children, seqMinter());
expect(modules[0].payload.source_wildcard_id).toBe("zzzz9999");
});
it("skips bundle-typed children (counts them) and extracts only leaf modules", () => {
const children: ExtractableChild[] = [
wc("aaaa1111", "plain"),
{ id: "dddd4444", type: "bundle", meta: { name: "nested" }, payload: {} },
];
const { modules, skipped } = extractBundleChildren(children, seqMinter());
expect(skipped).toBe(1);
expect(modules).toHaveLength(1);
expect(modules[0].type).toBe("wildcard");
});
it("carries name from meta.name, falling back to the id", () => {
const children: ExtractableChild[] = [
{ id: "aaaa1111", type: "wildcard", payload: { options: [] } },
];
const { modules } = extractBundleChildren(children, seqMinter());
expect(modules[0].name).toBe("aaaa1111");
});
});
@@ -0,0 +1,75 @@
/**
* Bundle "extract to library" (Feature 4).
*
* Pure transform: take a bundle's frozen child snapshots and produce
* standalone library-module entities with FRESH ids, remapping every
* intra-bundle reference (constraint source/target ids + nested `@{uuid}`)
* to the new ids. References pointing OUTSIDE the extracted set stay
* verbatim (they'll resolve against whatever lives in the target library,
* or become ordinary danglers eligible for reattach).
*
* Bundle-typed children are skipped (a nested bundle isn't a leaf module);
* the count is returned so the caller can surface "N skipped". Per-instance
* overrides are intentionally NOT extracted — a library module is the
* definition (payload), instance overrides are graph-specific.
*
* No I/O. The id minter is injected (defaults to `newShortId`) so tests can
* assert deterministic remaps.
*/
import { newShortId } from "../../../manager/utils/ids";
import { walkRemap } from "./uuid-remap";
/** Minimal child-snapshot shape this reads. Bundle children carry more
* (`instance`, `enabled`, `payload_hash`…) — ignored here. */
export interface ExtractableChild {
id: string;
type: string;
payload?: Record<string, unknown>;
meta?: { name?: string; description?: string; tags?: string[] };
}
/** A standalone library-module entity ready to install (engine-row shape). */
export interface ExtractedModule {
id: string;
type: string;
name: string;
description?: string;
tags?: string[];
payload: Record<string, unknown>;
}
export interface ExtractResult {
modules: ExtractedModule[];
/** oldChildId → freshId, for every extracted leaf. */
remap: Record<string, string>;
/** count of bundle-typed children skipped. */
skipped: number;
}
export function extractBundleChildren(
children: ExtractableChild[],
mintId: () => string = newShortId,
): ExtractResult {
const leaves = children.filter((c) => c.type !== "bundle");
const skipped = children.length - leaves.length;
// Pass 1: mint a fresh id for every leaf so intra-bundle refs can resolve
// against the COMPLETE table (a constraint's sibling may come later).
const remap: Record<string, string> = {};
for (const c of leaves) remap[c.id] = mintId();
// Pass 2: walkRemap each payload with the full table + lift meta → fields.
const modules: ExtractedModule[] = leaves.map((c) => {
const mod: ExtractedModule = {
id: remap[c.id],
type: c.type,
name: c.meta?.name ?? c.id,
payload: walkRemap(c.payload ?? {}, remap) as Record<string, unknown>,
};
if (c.meta?.description) mod.description = c.meta.description;
if (c.meta?.tags && c.meta.tags.length > 0) mod.tags = c.meta.tags;
return mod;
});
return { modules, remap, skipped };
}
+26 -168
View File
@@ -1,182 +1,40 @@
import { describe, it, expect } from "vitest";
import { remapBundleUuids, type ChildSnapshot } from "./uuid-remap";
import { walkRemap } from "./uuid-remap";
describe("remapBundleUuids — id regeneration", () => {
it("regenerates every child id to a fresh 12-char hex", () => {
const children: ChildSnapshot[] = [
{ id: "aaaa1111", type: "wildcard" },
{ id: "bbbb2222", type: "constraint" },
];
const result = remapBundleUuids(children);
expect(result.children).toHaveLength(2);
expect(result.children[0].id).not.toBe("aaaa1111");
expect(result.children[1].id).not.toBe("bbbb2222");
expect(result.children[0].id).toMatch(/^[0-9a-f]{12}$/);
expect(result.children[1].id).toMatch(/^[0-9a-f]{12}$/);
describe("walkRemap — partial remap table", () => {
it("rewrites a whole-string field whose value is a remapped id", () => {
const out = walkRemap("src11111", { src11111: "newsrc01" });
expect(out).toBe("newsrc01");
});
it("remap table maps every old id to its fresh id", () => {
const children: ChildSnapshot[] = [
{ id: "aaaa1111", type: "wildcard" },
{ id: "bbbb2222", type: "constraint" },
];
const result = remapBundleUuids(children);
expect(result.remap.aaaa1111).toBe(result.children[0].id);
expect(result.remap.bbbb2222).toBe(result.children[1].id);
expect(Object.keys(result.remap)).toHaveLength(2);
it("leaves a whole-string field untouched when not in the remap table", () => {
const out = walkRemap("other999", { src11111: "newsrc01" });
expect(out).toBe("other999");
});
it("does not mutate input children", () => {
const children: ChildSnapshot[] = [
{ id: "aaaa1111", type: "wildcard", payload: { var_binding: "x" } },
];
const snapshot = JSON.parse(JSON.stringify(children));
remapBundleUuids(children);
expect(children).toEqual(snapshot);
it("rewrites an embedded @{uuid} ref when the uuid is in the table", () => {
const out = walkRemap("see @{bbbb2222} here", { bbbb2222: "newbbbb1" });
expect(out).toBe("see @{newbbbb1} here");
});
});
describe("remapBundleUuids — @{uuid} text refs", () => {
it("rewrites @{uuid} in nested payload string values when uuid is in remap", () => {
const children: ChildSnapshot[] = [
{
id: "aaaa1111",
type: "wildcard",
payload: { options: [{ value: "see @{bbbb2222} here" }] },
},
{ id: "bbbb2222", type: "wildcard" },
];
const result = remapBundleUuids(children);
const newBbbb = result.remap.bbbb2222;
const opt = (result.children[0].payload as Record<string, unknown>).options as Array<{ value: string }>;
expect(opt[0].value).toBe(`see @{${newBbbb}} here`);
it("preserves the #name and :subcat segments of an @{} ref", () => {
const out = walkRemap("@{bbbb2222#Hair:long}", { bbbb2222: "newbbbb1" });
expect(out).toBe("@{newbbbb1#Hair:long}");
});
it("rewrites multiple @{uuid} refs in the same string", () => {
const children: ChildSnapshot[] = [
{
id: "aaaa1111",
type: "combine",
payload: { template: "$a @{bbbb2222} $b @{cccc3333} $c" },
},
{ id: "bbbb2222", type: "wildcard" },
{ id: "cccc3333", type: "wildcard" },
];
const result = remapBundleUuids(children);
const tpl = (result.children[0].payload as Record<string, string>).template;
expect(tpl).toBe(`$a @{${result.remap.bbbb2222}} $b @{${result.remap.cccc3333}} $c`);
it("leaves @{uuid} refs to non-remapped uuids verbatim", () => {
const out = walkRemap("see @{xxxx9999} here", { bbbb2222: "newbbbb1" });
expect(out).toBe("see @{xxxx9999} here");
});
it("leaves @{uuid} refs to non-bundle uuids untouched", () => {
const children: ChildSnapshot[] = [
{
id: "aaaa1111",
type: "wildcard",
payload: { options: [{ value: "see @{xxxx9999} here" }] },
},
];
const result = remapBundleUuids(children);
const opt = (result.children[0].payload as Record<string, unknown>).options as Array<{ value: string }>;
expect(opt[0].value).toBe("see @{xxxx9999} here");
});
});
describe("remapBundleUuids — constraint refs", () => {
it("rewrites source_wildcard_id + target_wildcard_id when both are in bundle", () => {
const children: ChildSnapshot[] = [
{ id: "aabbcc11", type: "wildcard" },
{ id: "ddeeff22", type: "wildcard" },
{
id: "11223344",
type: "constraint",
payload: {
source_wildcard_id: "aabbcc11",
target_wildcard_id: "ddeeff22",
matrix: {},
},
},
];
const result = remapBundleUuids(children);
const con = result.children[2].payload as Record<string, string>;
expect(con.source_wildcard_id).toBe(result.remap.aabbcc11);
expect(con.target_wildcard_id).toBe(result.remap.ddeeff22);
});
it("leaves source/target unchanged when they reference modules outside the bundle", () => {
const children: ChildSnapshot[] = [
{
id: "11223344",
type: "constraint",
payload: {
source_wildcard_id: "99887766",
target_wildcard_id: "55443322",
matrix: {},
},
},
];
const result = remapBundleUuids(children);
const con = result.children[0].payload as Record<string, string>;
expect(con.source_wildcard_id).toBe("99887766");
expect(con.target_wildcard_id).toBe("55443322");
});
});
describe("remapBundleUuids — instance fields", () => {
it("rewrites uuid-valued instance fields too", () => {
const children: ChildSnapshot[] = [
{ id: "aabbcc11", type: "wildcard" },
{
id: "11223344",
type: "constraint",
instance: {
// Bare-string uuid reference — gets rewritten via remap
// table lookup (full-string match).
pinned_source: "aabbcc11",
// Text containing `@{uuid}` — gets rewritten via the
// embedded-ref regex pass.
tooltip: "Pins to @{aabbcc11}",
},
},
];
const result = remapBundleUuids(children);
const inst = result.children[1].instance as Record<string, string>;
expect(inst.pinned_source).toBe(result.remap.aabbcc11);
expect(inst.tooltip).toBe(`Pins to @{${result.remap.aabbcc11}}`);
});
});
describe("remapBundleUuids — edge cases", () => {
it("handles empty children list", () => {
const result = remapBundleUuids([]);
expect(result.children).toEqual([]);
expect(result.remap).toEqual({});
});
it("handles children with no payload or instance", () => {
const children: ChildSnapshot[] = [
{ id: "aaaa1111", type: "wildcard" },
];
const result = remapBundleUuids(children);
expect(result.children[0].payload).toBeUndefined();
expect(result.children[0].instance).toBeUndefined();
expect(result.children[0].id).toMatch(/^[0-9a-f]{12}$/);
});
it("recurses into arrays of arrays of objects", () => {
const children: ChildSnapshot[] = [
{ id: "abcdef01", type: "x" },
{
id: "abcdef02",
type: "wildcard",
payload: {
deeply: [[{ ref: "abcdef01", text: "see @{abcdef01}" }]],
},
},
];
const result = remapBundleUuids(children);
const newId = result.remap.abcdef01;
const deeply = (result.children[1].payload as Record<string, unknown>).deeply as Array<Array<{ ref: string; text: string }>>;
expect(deeply[0][0].ref).toBe(newId);
expect(deeply[0][0].text).toBe(`see @{${newId}}`);
it("recurses into nested arrays + objects without mutating input", () => {
const input = { deeply: [[{ ref: "abcdef01", text: "see @{abcdef01}" }]] };
const snapshot = JSON.parse(JSON.stringify(input));
const out = walkRemap(input, { abcdef01: "newid001" }) as {
deeply: Array<Array<{ ref: string; text: string }>>;
};
expect(out.deeply[0][0].ref).toBe("newid001");
expect(out.deeply[0][0].text).toBe("see @{newid001}");
expect(input).toEqual(snapshot);
});
});
+4 -26
View File
@@ -18,8 +18,6 @@
* Pure-TS, no engine imports. Safe to import from frontend bundle code.
*/
import { newRowUid } from "../../../widgets/_shared";
export interface ChildSnapshot {
id: string;
type: string;
@@ -46,8 +44,10 @@ export interface RemapResult {
const REF_RE = /@\{([0-9a-f]{6,16})(#[^#:}@{]*)?(:[^}]*)?\}/gi;
/** Deep walks an unknown value, applying the remap. Returns a new
* object — never mutates input. */
function walkRemap(value: unknown, remap: Record<string, string>): unknown {
* object — never mutates input. Exported so the cascade-restore path
* can re-point constraint source/target + `@{}` refs at restored module
* uuids using a partial remap table (not a full id regen). */
export function walkRemap(value: unknown, remap: Record<string, string>): unknown {
if (typeof value === "string") {
let out = value;
// Whole-string match — e.g. constraint.source_wildcard_id = "src11111"
@@ -77,25 +77,3 @@ function walkRemap(value: unknown, remap: Record<string, string>): unknown {
}
return value;
}
/** Regenerate every child uuid in the snapshot list, then deep-walk
* every child's `payload` + `instance` rewriting any references to
* match. Constraint source/target ids + `@{uuid}` text refs all flow
* through `walkRemap` since they live inside `payload` / `instance`. */
export function remapBundleUuids(children: ChildSnapshot[]): RemapResult {
const remap: Record<string, string> = {};
for (const c of children) {
remap[c.id] = newRowUid();
}
const remapped: ChildSnapshot[] = children.map((c) => {
const next: ChildSnapshot = { ...c, id: remap[c.id] };
if (c.payload) {
next.payload = walkRemap(c.payload, remap) as Record<string, unknown>;
}
if (c.instance) {
next.instance = walkRemap(c.instance, remap) as Record<string, unknown>;
}
return next;
});
return { children: remapped, remap };
}
+43 -5
View File
@@ -14,7 +14,7 @@ beforeEach(() => {
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-a", bbbbbbbb: "h-b" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-a" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-b" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -32,7 +32,7 @@ describe("drift-store: subscribe/unsubscribe", () => {
expect(hashes.value).toBeNull();
subscribe();
await vi.advanceTimersByTimeAsync(0);
expect(hashes.value).toEqual({ aaaaaaaa: "h-a", bbbbbbbb: "h-b" });
expect(hashes.value).toEqual({ aaaaaaaa: { type: "wildcard", payload_hash: "h-a" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-b" } });
unsubscribe();
});
});
@@ -82,7 +82,7 @@ describe("drift-store: forceRefresh", () => {
import type { ModuleEntry } from "../../widgets/_shared";
import type { SnapshotEntry } from "./drift-store";
import { refreshMany } from "./drift-store";
import { refreshMany, mergeRefresh } from "./drift-store";
function mkEntry(over: Partial<ModuleEntry> = {}): ModuleEntry {
return {
@@ -250,7 +250,8 @@ describe("setLibraryHash — direct hash update without re-fetch", () => {
setLibraryHash("abc123", "newhash456");
expect(hashes.value).not.toBeNull();
if (hashes.value) {
expect(hashes.value["abc123"]).toBe("newhash456");
// Optimistic entry stores { payload_hash } (type filled by next poll).
expect(hashes.value["abc123"]).toEqual({ payload_hash: "newhash456" });
}
});
@@ -258,6 +259,43 @@ describe("setLibraryHash — direct hash update without re-fetch", () => {
setLibraryHash("a", "ha");
setLibraryHash("b", "hb");
setLibraryHash("a", "ha-updated");
expect(hashes.value).toEqual({ a: "ha-updated", b: "hb" });
expect(hashes.value).toEqual({
a: { payload_hash: "ha-updated" },
b: { payload_hash: "hb" },
});
});
});
describe("drift-store: mergeRefresh type-guard", () => {
it("throws when the live snapshot is a DIFFERENT kind than the embedded row", () => {
const embedded = mkEntry({ id: "aabbccdd", type: "constraint" });
const live: SnapshotEntry = {
snapshot_version: 1,
uuid: "aabbccdd",
type: "wildcard",
name: "w",
payload: {},
payload_hash: "h2",
source: { kind: "user" },
};
// A same-id, different-kind library row must never merge over the
// embedded item — it would silently change the item's kind.
expect(() => mergeRefresh(embedded, live)).toThrow(/type mismatch/i);
});
it("merges normally when the kinds match", () => {
const embedded = mkEntry({ id: "aabbccdd", type: "wildcard" });
const live: SnapshotEntry = {
snapshot_version: 1,
uuid: "aabbccdd",
type: "wildcard",
name: "w2",
payload: { options: [] },
payload_hash: "h2",
source: { kind: "user" },
};
const merged = mergeRefresh(embedded, live);
expect(merged.type).toBe("wildcard");
expect(merged.payload_hash).toBe("h2");
});
});
+48 -11
View File
@@ -20,16 +20,18 @@ export interface RefreshResult {
failed: { id: string; reason: string }[];
}
/** Live-library hashes keyed by uuid. `null` while the first fetch is in
/** Live-library identity map keyed by uuid: each value carries the row's
* `{type, payload_hash}`. `type` enables cross-kind id-clash detection;
* `payload_hash` is the drift signal. `null` while the first fetch is in
* flight — predicates downstream use this to avoid flashing "missing" /
* "drift" before we know the truth.
*
* Invariant: values are always non-empty strings once the map is non-null.
* Consumers may assume `live === undefined` is equivalent to `!(uuid in
* hashes.value)` — the API never emits explicit-undefined entries. If this
* ever changes, both `isMissingFromLibrary` and `isDrifted` in
* `ContextWidget.vue` need to be tightened together. */
export const hashes: Ref<Record<string, string> | null> = ref(null);
* `type` is optional because the optimistic `setLibraryHash` writes a
* payload_hash-only entry (the 5s poll fills `type` shortly after).
* Consumers run `classifyOne` (import-export/collision) against these
* entries: `live === undefined` ≡ `!(uuid in hashes.value)` — the API
* never emits explicit-undefined entries. */
export const hashes: Ref<Record<string, { type?: string; payload_hash: string }> | null> = ref(null);
/** Same shape as `hashes` but keyed by bundle library uuid. Polled
* in parallel with module hashes so bundle drift detection
@@ -94,13 +96,35 @@ function sameHashes(
return true;
}
/** Object-aware sibling of `sameHashes` for the module map, whose values
* are `{type, payload_hash}` rather than flat strings. Same purpose: gate
* `hashes.value` reassignment so the 5s poll doesn't mint a new ref (and
* fire reactivity) when nothing changed. */
function sameModuleHashes(
a: Record<string, { type?: string; payload_hash: string }> | null,
b: Record<string, { type?: string; payload_hash: string }>,
): boolean {
if (a === null) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const k of keysA) {
const va = a[k];
const vb = b[k];
if (!vb || va.type !== vb.type || va.payload_hash !== vb.payload_hash) return false;
}
return true;
}
async function fetchModuleHashes(): Promise<void> {
try {
const res = await fetch("/wp/api/modules/hashes");
if (!res.ok) return;
const body = (await res.json()) as { hashes?: Record<string, string> };
const body = (await res.json()) as {
hashes?: Record<string, { type?: string; payload_hash: string }>;
};
if (body && body.hashes && typeof body.hashes === "object") {
if (!sameHashes(hashes.value, body.hashes)) {
if (!sameModuleHashes(hashes.value, body.hashes)) {
hashes.value = body.hashes;
}
}
@@ -189,6 +213,15 @@ export async function refreshMany(modules: ModuleEntry[]): Promise<RefreshResult
* actually move.
*/
export function mergeRefresh(m: ModuleEntry, live: SnapshotEntry): ModuleEntry {
if (m.type !== live.type) {
// Defense-in-depth: a same-id, DIFFERENT-kind library row must never be
// merged over an embedded item (it would silently change the item's
// kind). The UI suppresses Refresh for type-conflict rows; this guards
// the path even if a caller bypasses that.
throw new Error(
`refresh type mismatch: embedded ${m.type} vs library ${live.type} at id ${m.id}`,
);
}
const merged: ModuleEntry & { bundle_origin?: string } = {
type: live.type,
meta: {
@@ -220,8 +253,12 @@ export function mergeRefresh(m: ModuleEntry, live: SnapshotEntry): ModuleEntry {
*
* See: docs/superpowers/specs/2026-05-07-instance-overrides-modal-design.md §8.5
*/
export function setLibraryHash(id: string, hash: string): void {
hashes.value = { ...(hashes.value ?? {}), [id]: hash };
export function setLibraryHash(id: string, payloadHash: string): void {
// Optimistic entry — `type` omitted (the 5s poll fills it). Enough to
// clear the missing/drift dot immediately: classifyOne sees the id
// present + payload_hash match → silent-skip. A type-conflict can't
// arise here (you're saving your own entry), so omitting type is safe.
hashes.value = { ...(hashes.value ?? {}), [id]: { payload_hash: payloadHash } };
}
/** Test-only — reset internal counters between cases. */
@@ -20,6 +20,12 @@
.wp-wcm__head { --modal-accent: var(--wp-kind-wildcard); }
.wp-cbm__head, .wp-cnm__head, .wp-dvm__head, .wp-fvm__head, .wp-wcm__head {
/* Pinned to the top of the scrolling modal body so the title + close
* stay visible while the content scrolls. Opaque (gradient bottoms out
* at --wp-bg2) so scrolled content doesn't bleed through. */
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 10px;
@@ -31,6 +31,17 @@ describe("preview-tokens", () => {
expect(tokenize("$x", "derivation")[0]).toMatchObject({ kind: "var", invalid: false });
expect(tokenize("$x", "assembler")[0]).toMatchObject({ kind: "var", invalid: false });
});
it("tokenizes a $mood.0 list accessor as ONE var token with index (SP2a)", () => {
const toks = tokenize("$mood.0", "combine");
expect(toks).toHaveLength(1);
expect(toks[0]).toMatchObject({ kind: "var", varName: "mood", index: 0, raw: "$mood.0" });
});
it("bare $mood has no index; $mood.12 captures a multi-digit index (SP2a)", () => {
expect(tokenize("$mood", "combine")[0].index).toBeUndefined();
expect(tokenize("$mood.12", "combine")[0]).toMatchObject({ varName: "mood", index: 12 });
});
});
describe("REF token", () => {
@@ -27,6 +27,8 @@ export interface PreviewToken {
raw: string;
invalid?: boolean;
varName?: string;
/** SP2a `.K` list accessor on a var token (`$mood.0` -> index 0). */
index?: number;
refUuid?: string;
branches?: string[];
count?: number;
@@ -37,7 +39,10 @@ export interface PreviewToken {
const VAR_VALID_SURFACES = new Set<Surface>(["combine", "derivation", "assembler"]);
const REF_VALID_SURFACES = new Set<Surface>(["wildcard"]);
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/y;
// Group 2 = optional `.K` list accessor (SP2a) so `$mood.0` is ONE var token
// (base name in m[1], index in m[2]) — mirrors engine tokenize.py +
// richTokenize.ts, so the `.K` never strands as a separate text token.
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/y;
// Optional `:subcat[,subcat]` per-call sub-category filter — empty
// filter is equivalent to no filter, sub-categories are stripped of
// whitespace + comma-separated.
@@ -45,7 +50,11 @@ const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/y;
// filter. Consumers below only use m[1] (uuid) — name + subcat are
// preserved verbatim in m[0] for display.
const REF_RE = /@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}/y;
const DP_MULTI_RE = /\{(\d+)\$\$([^$]*)\$\$([^}]*)\}/y;
// SP2b: accept a range count `N-M` and the `~` independent flag in the
// multi-pick prefix so the canvas preview colours `{2-4~$$sep$$…}` too.
// `count` parses the leading integer (the min) — enough for preview colouring
// + the deterministic combine resolve.
const DP_MULTI_RE = /\{(\d+(?:-\d+)?~?)\$\$([^$]*)\$\$([^}]*)\}/y;
const DP_BRACE_RE = /\{([^{}]*\|[^{}]*)\}/y;
function pushText(out: PreviewToken[], buf: string): void {
@@ -80,6 +89,7 @@ export function tokenize(text: string, surface: Surface): PreviewToken[] {
kind: "var",
raw: m[0],
varName: m[1],
index: m[2] !== undefined ? Number.parseInt(m[2], 10) : undefined,
invalid: !VAR_VALID_SURFACES.has(surface),
});
i += m[0].length;
@@ -7,7 +7,10 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
// v2 dropped `mode` + `pinned_option_id` — see
// docs/superpowers/specs/2026-05-08-instance-overrides-v2-design.md §6.1.
"variable_binding", "enabled_options",
"option_weights", "category_filter", "locked_seed", "internal",
"option_weights", "category_filter", "exclude_null", "locked_seed", "internal",
// SP2a multi-select (2026-06-08): per-instance count range + separator.
// SP2c: pick_independent (allow repeats / with replacement).
"pick_min", "pick_max", "pick_separator", "pick_independent",
]);
expect(INSTANCE_FIELDS_PER_KIND.fixed_values).toEqual([
// `locked_seed` added in the combine + fixed_values syntax-parity
@@ -46,6 +49,8 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
"exception_mode_overrides",
"exception_factor_overrides",
"extra_exceptions",
// SP3 reach selector override (parity with engine INSTANCE_SCHEMAS).
"target_select",
]);
});
});
+13 -2
View File
@@ -12,6 +12,8 @@ export const KIND_TITLE: Record<string, string> = {
export type InstanceFieldKey =
| "variable_binding" | "enabled_options" | "option_weights" | "category_filter"
| "exclude_null"
| "pick_min" | "pick_max" | "pick_separator" | "pick_independent"
| "mode" | "pinned_option_id" | "locked_seed" | "internal" | "values_overrides"
| "template_override"
| "disabled_rule_ids"
@@ -26,7 +28,9 @@ export type InstanceFieldKey =
| "cell_factor_overrides"
| "exception_mode_overrides"
| "exception_factor_overrides"
| "extra_exceptions";
| "extra_exceptions"
// SP3 reach selector override (shape validated engine-side).
| "target_select";
/** Single source of truth for which instance fields each kind exposes.
* Drives:
@@ -42,7 +46,10 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
// implicit in pool state. Engine handler still reads them on
// legacy snapshots; UI never writes them.
"variable_binding", "enabled_options",
"option_weights", "category_filter", "locked_seed", "internal",
"option_weights", "category_filter", "exclude_null", "locked_seed", "internal",
// SP2a multi-select: count range + join separator (per-instance, local).
// SP2c: pick_independent (allow repeats / with replacement).
"pick_min", "pick_max", "pick_separator", "pick_independent",
],
fixed_values: ["values_overrides", "enabled_options", "locked_seed"],
combine: ["template_override", "variable_binding", "locked_seed", "internal"],
@@ -70,5 +77,9 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
"exception_mode_overrides",
"exception_factor_overrides",
"extra_exceptions",
// SP3 reach selector override (per-instance). Interior shape
// (mode/count/picks) is validated engine-side, not by the type-only
// instance schema. Kept in parity with engine INSTANCE_SCHEMAS.
"target_select",
],
};
@@ -102,6 +102,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
:library-color="libraryColor"
@update="(patch) => emit('update', patch)"
/>
<div class="wp-bdm__dock">
<RuntimeSection
:lock-state="lockState"
:internal-state="internalState"
@@ -157,12 +158,13 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
Save
</button>
</footer>
</div>
</div>
</template>
<style scoped>
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
.wp-bdm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow-y: auto; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
.wp-bdm__head { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
.wp-bdm__head-icon { color: var(--modal-accent, var(--wp-accent)); font-size: 18px; width: 24px; text-align: center; }
.wp-bdm__title-block { flex: 1; min-width: 0; }
.wp-bdm__title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@@ -175,6 +177,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
.wp-bdm__close { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 4px; }
.wp-bdm__close:hover { color: var(--wp-text); }
.wp-bdm__close .pi { font-size: 12px; }
.wp-bdm__dock { position: sticky; bottom: 0; z-index: 2; background: var(--wp-bg2); }
.wp-bdm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
.wp-bdm__spa-link { display: inline-flex; align-items: center; gap: 5px; font: 10px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); text-decoration: none; }
.wp-bdm__spa-link:hover { color: var(--wp-accent-text, var(--wp-text)); }
@@ -12,6 +12,7 @@
* override, locked seed, hide-from-prompt.
*/
import { computed } from "vue";
import { type ResolvedValue } from "../../../../widgets/richTokenize";
import type { ModuleEntry } from "../../../../widgets/_shared";
import IdentitySection from "./sections/IdentitySection.vue";
import TemplateSection from "./sections/TemplateSection.vue";
@@ -30,7 +31,7 @@ const props = withDefaults(
upstreamVars?: string[];
/** Resolved upstream-var snapshot — drives the TemplateSection's
* live preview pane with substituted values. */
upstreamResolved?: Record<string, string>;
upstreamResolved?: Record<string, ResolvedValue>;
/** Sibling var names produced by other modules in this same Context
* node. Combined with upstreamVars to populate the dropdown. */
siblingVars?: string[];
@@ -108,6 +109,7 @@ function onSpaClick(): void {
:sibling-vars="siblingVars"
@update="onUpdate"
/>
<div class="wp-cbm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<footer class="wp-cbm__foot">
@@ -146,6 +148,7 @@ function onSpaClick(): void {
<button type="button" class="wp-cbm__btn" data-test="cbm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-cbm__btn wp-cbm__btn--primary" data-test="cbm-save" @click="emit('save')">Save</button>
</footer>
</div>
</div>
</template>
@@ -159,7 +162,7 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
@@ -167,6 +170,12 @@ function onSpaClick(): void {
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). Every modal in this family shares
* the same accent-tinted gradient + chip + close button. */
.wp-cbm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-cbm__foot {
display: flex;
align-items: center;
@@ -66,6 +66,17 @@ describe("combine IdentitySection", () => {
expect(patch.instance?.variable_binding).toBe("header_text");
});
it("strips comma + punctuation from the binding on input (var names must be identifiers)", async () => {
const w = mount(IdentitySection, { props: { module: makeModule() } });
const input = w.find<HTMLInputElement>('[data-test="id-binding"]');
input.element.value = "head,er;text!";
await input.trigger("input");
const updates = w.emitted("update")!;
const patch = updates[updates.length - 1][0] as Partial<ModuleEntry>;
expect(patch.instance?.variable_binding).toBe("headertext");
expect(input.element.value).toBe("headertext");
});
it("empty binding emits null collapse", async () => {
const w = mount(IdentitySection, {
props: { module: makeModule({ instance: { variable_binding: "header_text" } }) },
@@ -10,6 +10,7 @@
import { computed } from "vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
import { patchInstance } from "../../instance/patch";
import { stripNonIdentifierChars } from "../../../../../manager/utils/slug";
const props = withDefaults(
defineProps<{
@@ -86,7 +87,12 @@ function onNameInput(ev: Event): void {
}
function onBindingInput(ev: Event): void {
const raw = (ev.target as HTMLInputElement).value;
const el = ev.target as HTMLInputElement;
// Strip non-identifier chars on keystroke (the binding becomes a `$var`);
// force the DOM so the controlled input stays in lock-step. See wildcard
// IdentitySection for the rationale.
const raw = stripNonIdentifierChars(el.value);
if (el.value !== raw) el.value = raw;
emit("update", patchInstance(props.module, "variable_binding", raw.length > 0 ? raw : null));
}
@@ -245,7 +245,7 @@ describe("combine TemplateSection", () => {
expect(unresolved.text()).toBe("$unknown");
});
it("live preview leaves alternations + repeats raw (need RNG to resolve)", () => {
it("resolves an inline alternation deterministically (first branch) + $vars (#1)", () => {
const w = mount(TemplateSection, {
props: {
module: makeModule({
@@ -259,7 +259,62 @@ describe("combine TemplateSection", () => {
});
const pane = w.find('[data-test="tpl-preview-resolved"]');
expect(pane.exists()).toBe(true);
// Alt stays raw, $color resolves.
expect(pane.text()).toBe("{red|blue} shiny");
// Seedless preview → deterministic first branch. `$color` resolves.
expect(pane.text()).toBe("red shiny");
});
it("resolves a multi-pick to its first N branches joined by the separator (#1)", () => {
const w = mount(TemplateSection, {
props: {
module: makeModule({
payload: { output_var: "out", template: "{2$$, $$a|b|c}" },
}),
upstreamResolved: {},
},
});
const pane = w.find('[data-test="tpl-preview-resolved"]');
expect(pane.exists()).toBe(true);
expect(pane.text()).toBe("a, b");
});
it("resolves $vars nested inside an inline branch (#1)", () => {
const w = mount(TemplateSection, {
props: {
module: makeModule({
payload: { output_var: "out", template: "{$mood|calm}" },
}),
upstreamResolved: { mood: "serene" },
},
});
const pane = w.find('[data-test="tpl-preview-resolved"]');
expect(pane.text()).toBe("serene");
});
it("resolves a $color.0 list accessor without leaking the .K literal (SP2a)", () => {
const w = mount(TemplateSection, {
props: {
module: makeModule({ payload: { output_var: "out", template: "$color.0 hat" } }),
upstreamResolved: { color: "red" },
},
});
const pane = w.find('[data-test="tpl-preview-resolved"]');
expect(pane.exists()).toBe(true);
// `$color.0` resolves via the shared accessor (string = 1-element list,
// `.0` == itself); the literal ".0" must NOT survive as a text token.
expect(pane.text()).toBe("red hat");
expect(pane.find(".tpl-tok--var-resolved").text()).toBe("red");
});
it("indexes a list-valued $mood.0 in the resolved preview (SP2a)", () => {
const w = mount(TemplateSection, {
props: {
module: makeModule({ payload: { output_var: "out", template: "$mood.0 | $mood" } }),
upstreamResolved: { mood: { items: ["serene", "sleepy"], sep: ", " } },
},
});
const pane = w.find('[data-test="tpl-preview-resolved"]');
// `$mood.0` -> first item "serene"; bare `$mood` -> joined list. The whole
// list never leaks into the `.0` slot.
expect(pane.text()).toBe("serene | serene, sleepy");
});
});
@@ -15,6 +15,7 @@ import type { ModuleEntry } from "../../../../../widgets/_shared";
import { patchInstance } from "../../instance/patch";
import { varColorClass } from "../../../../shared/var-color";
import { tokenize, type PreviewToken } from "../../_shared/preview-tokens";
import { applyVarAccessor, type ResolvedValue } from "../../../../../widgets/richTokenize";
// Async-import the rich-text editor so the chunk is split into its own
// asset and only pulled in when the combine instance modal opens. Bundle
@@ -34,7 +35,7 @@ const props = withDefaults(
* with `$var` substitutions visible at edit time so users see the
* same string the assembler will read. Empty map = preview pane
* hidden (nothing to substitute). */
upstreamResolved?: Record<string, string>;
upstreamResolved?: Record<string, ResolvedValue>;
/** Vars produced by other modules in the SAME Context node. */
siblingVars?: string[];
}>(),
@@ -92,6 +93,42 @@ const previewTokens = computed<PreviewToken[]>(() =>
// preview). The result is the closest static approximation of what
// the assembler will read at runtime, so users can spot upstream-name
// typos before queue time.
/** Deterministic inline-block resolution for the seedless combine preview:
* an alternation `{a|b}` resolves to its FIRST branch; a multi-pick
* `{N$$sep$$a|b|c}` to its first `N` branches joined by the separator
* (clamped to what's available). Nested `$vars` in the chosen branch(es)
* resolve via the upstream map so the preview shows a concrete example
* string instead of the literal `{a|b}`. Deterministic (not random) because
* the preview has no seed; runtime resolution still rolls the real RNG. Refs
* stay literal — combine never recurses into nested wildcards. */
function resolveInlineText(s: string, map: Record<string, ResolvedValue>): string {
let out = "";
for (const tok of tokenize(s, "combine")) out += resolveInlineTok(tok, map);
return out;
}
function resolveInlineTok(tok: PreviewToken, map: Record<string, ResolvedValue>): string {
switch (tok.kind) {
case "var":
return !tok.invalid && tok.varName
&& Object.prototype.hasOwnProperty.call(map, tok.varName)
? applyVarAccessor(map[tok.varName], tok.index)
: tok.raw;
case "escape":
return tok.literal ?? "";
case "alt": {
const b = tok.branches ?? [];
return b.length > 0 ? resolveInlineText(b[0], map) : "";
}
case "repeat": {
const b = tok.branches ?? [];
const n = Math.max(0, Math.min(tok.count ?? 0, b.length));
return b.slice(0, n).map((br) => resolveInlineText(br, map)).join(tok.separator ?? "");
}
default:
return tok.raw; // text, ref (literal on combine)
}
}
interface ResolvedToken {
text: string;
/** Source kind so the renderer can color-code: `var-resolved` paints
@@ -110,9 +147,15 @@ const resolvedTokens = computed<ResolvedToken[]>(() => {
tokens.push({ text: tok.raw, kind: "var-unresolved", varName: tok.varName });
break;
}
const value = map[tok.varName];
if (typeof value === "string") {
tokens.push({ text: value, kind: "var-resolved", varName: tok.varName });
// SP2a: resolve via the shared accessor so `$mood.0` indexes (and a
// bare `$mood` joins a list) — the `.K` never strands as literal text.
// Presence is keyed on the base name, not the value's type.
if (Object.prototype.hasOwnProperty.call(map, tok.varName)) {
tokens.push({
text: applyVarAccessor(map[tok.varName], tok.index),
kind: "var-resolved",
varName: tok.varName,
});
} else {
tokens.push({ text: tok.raw, kind: "var-unresolved", varName: tok.varName });
}
@@ -122,10 +165,14 @@ const resolvedTokens = computed<ResolvedToken[]>(() => {
tokens.push({ text: tok.literal ?? "", kind: "literal" });
break;
case "ref":
// Combine never recurses into nested wildcards — ref stays literal.
tokens.push({ text: tok.raw, kind: "literal" });
break;
case "alt":
case "repeat":
// Leave as raw — these need RNG / chain resolution.
tokens.push({ text: tok.raw, kind: "literal" });
// Resolve inline blocks deterministically so the preview shows a
// concrete example string instead of the literal `{a|b}`.
tokens.push({ text: resolveInlineTok(tok, map), kind: "literal" });
break;
case "text":
default:
@@ -136,11 +183,13 @@ const resolvedTokens = computed<ResolvedToken[]>(() => {
return tokens;
});
/** Show the live-preview pane only when the template references at
* least one upstream-resolvable var. No vars = nothing to substitute,
* pane stays hidden so the section doesn't grow unnecessarily. */
/** Show the live-preview pane when there's something to resolve: an
* upstream-resolvable var, OR an inline block (`{a|b}` / `{N$$…}`) that the
* preview now expands deterministically. Otherwise stay hidden so the
* section doesn't grow for a plain static template. */
const hasResolvableVar = computed(() =>
resolvedTokens.value.some((t) => t.kind === "var-resolved"),
resolvedTokens.value.some((t) => t.kind === "var-resolved")
|| previewTokens.value.some((t) => t.kind === "alt" || t.kind === "repeat"),
);
// Detected variables = VAR tokens (deduped, library order preserved).
@@ -0,0 +1,193 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import ConstraintInstanceModal from "./ConstraintInstanceModal.vue";
// Preview-resolver lookups MISS so the source uuid reads as dangling.
vi.mock("../../../../extension/preview-resolver", () => ({
cacheVersion: { value: 0 },
ensure: vi.fn(),
lookup: () => undefined,
}));
function moduleWithDanglingSource() {
return {
id: "c0ffee00",
_uid: "u1",
type: "constraint" as const,
enabled: true,
collapsed: false,
entries: [],
payload_hash: "h",
meta: { name: "warm-only", description: "", tags: [] },
payload: {
source_wildcard_id: "deadbeef", // missing locally
target_wildcard_id: "facade00", // present as a sibling below
matrix: { warm: { rough: { mode: "boost", factor: 2 } } },
exceptions: [{ source_value: "@{deadbeef:warm}", target_value: "x", mode: "allow", factor: 1 }],
},
instance: {},
};
}
// Same dangling source, but a TWO-ROW matrix: `warm` survives under
// candidate beef0001 (subs ["warm","cold"]) while `cool` VANISHES, so a
// source reattach to beef0001 drops every cell in the `cool` row (2 cells).
function moduleWithDroppingSource() {
return {
id: "c0ffee01",
_uid: "u1",
type: "constraint" as const,
enabled: true,
collapsed: false,
entries: [],
payload_hash: "h",
meta: { name: "warm-only", description: "", tags: [] },
payload: {
source_wildcard_id: "deadbeef", // missing locally
target_wildcard_id: "facade00", // present as a sibling below
matrix: {
warm: { rough: { mode: "boost", factor: 2 } }, // survives → 0 dropped
cool: { rough: { mode: "ban", factor: 0 }, smooth: { mode: "boost", factor: 2 } }, // vanishes → 2 cells dropped
},
exceptions: [],
},
instance: {},
};
}
function siblings() {
return [
{
id: "facade00", _uid: "u2", type: "wildcard" as const, enabled: true, collapsed: false,
entries: [], meta: { name: "texture", description: "", tags: [] },
payload: { sub_categories: ["rough"], options: [{ id: "o1", value: "matte" }] },
instance: {},
},
{
id: "beef0001", _uid: "u3", type: "wildcard" as const, enabled: true, collapsed: false,
entries: [], meta: { name: "colour", description: "", tags: [] },
payload: { sub_categories: ["warm", "cold"], options: [{ id: "o2", value: "red" }] },
instance: {},
},
];
}
describe("ConstraintInstanceModal — dangling source reattach", () => {
beforeEach(() => vi.clearAllMocks());
it("raises the reattach banner for the dangling source only", () => {
const w = mount(ConstraintInstanceModal, {
props: { module: moduleWithDanglingSource(), siblingModules: siblings() },
});
expect(w.find("[data-test='reattach-row-source']").exists()).toBe(true);
expect(w.find("[data-test='reattach-row-target']").exists()).toBe(false);
});
it("reattach repoints source_wildcard_id + embedded @{} ref and emits save-to-library", async () => {
const w = mount(ConstraintInstanceModal, {
props: { module: moduleWithDanglingSource(), siblingModules: siblings() },
});
await w.find("[data-test='reattach-btn-source']").trigger("click");
await w.find("[data-test-id='reattach-candidate-beef0001']").trigger("click");
await w.find("[data-test='reattach-confirm-source']").trigger("click");
const updates = w.emitted("update");
expect(updates).toBeTruthy();
const patch = updates![updates!.length - 1][0] as { payload: Record<string, unknown> };
expect(patch.payload.source_wildcard_id).toBe("beef0001");
const exc = (patch.payload.exceptions as Array<{ source_value: string }>)[0];
expect(exc.source_value).toBe("@{beef0001:warm}"); // segments preserved
expect(w.emitted("save-to-library")).toBeTruthy();
});
it("previews the per-cell dropped count for a non-surviving source pick (pre-confirm)", async () => {
const w = mount(ConstraintInstanceModal, {
props: { module: moduleWithDroppingSource(), siblingModules: siblings() },
});
await w.find("[data-test='reattach-btn-source']").trigger("click");
// Select candidate beef0001 (subs ["warm","cold"]) WITHOUT confirming:
// the `cool` row (2 cells) vanishes, `warm` (1 cell) survives → 2 dropped.
await w.find("[data-test-id='reattach-candidate-beef0001']").trigger("click");
const dropped = w.find("[data-test='reattach-dropped-source']");
expect(dropped.exists()).toBe(true);
expect(dropped.text()).toContain("2 cells dropped");
});
// A dangling ref makes the whole constraint a read-only snapshot: the
// modal threads `stranded` into both the matrix + exceptions sections so
// they render their read-only treatment (muted/locked matrix, static
// colored exception chips), mirroring the SPA's stranded read-only view.
it("marks the matrix + exceptions sections stranded when a ref is dangling", () => {
const w = mount(ConstraintInstanceModal, {
props: { module: moduleWithDanglingSource(), siblingModules: siblings() },
});
expect(w.findComponent({ name: "MatrixSection" }).props("stranded")).toBe(true);
expect(w.findComponent({ name: "ExceptionsSection" }).props("stranded")).toBe(true);
// The rendered read-only affordances surface too.
expect(w.find("[data-test='mx-readonly-lock']").exists()).toBe(true);
});
});
// ── Cached source/target ref NAME (display-only, additive) ─────────
//
// The constraint payload caches the source/target wildcard display name
// (`source_wildcard_name`/`target_wildcard_name`) so the broken-reference
// banner reads "Source wildcard 'Starter subject' (deadbeef)" even after the
// wildcard is deleted (the live lookup misses → name unrecoverable otherwise).
/** Dangling source, but the payload carries the cached display name. The
* preview-resolver lookup MISSES (mock returns undefined) and the wildcard
* is not a sibling, so `wildcardName` yields "" — the banner must fall back
* to the payload's cached `source_wildcard_name`. */
function moduleWithCachedSourceName() {
return {
id: "c0ffee02",
_uid: "u1",
type: "constraint" as const,
enabled: true,
collapsed: false,
entries: [],
payload_hash: "h",
meta: { name: "warm-only", description: "", tags: [] },
payload: {
source_wildcard_id: "deadbeef", // missing locally → dangling
target_wildcard_id: "facade00", // present sibling
source_wildcard_name: "Starter subject", // cached on write
matrix: { warm: { rough: { mode: "boost", factor: 2 } } },
exceptions: [],
},
instance: {},
};
}
describe("ConstraintInstanceModal — cached source ref name", () => {
beforeEach(() => vi.clearAllMocks());
it("shows the cached source name + uuid in the dangling banner", () => {
const w = mount(ConstraintInstanceModal, {
props: { module: moduleWithCachedSourceName(), siblingModules: siblings() },
});
const row = w.find("[data-test='reattach-row-source']");
expect(row.exists()).toBe(true);
const txt = row.text();
expect(txt).toContain("Starter subject"); // cached name survives deletion
expect(txt).toContain("deadbeef"); // alongside the uuid prefix
});
it("reattach stamps source_wildcard_name from the emitted newName", async () => {
const w = mount(ConstraintInstanceModal, {
props: { module: moduleWithCachedSourceName(), siblingModules: siblings() },
});
await w.find("[data-test='reattach-btn-source']").trigger("click");
await w.find("[data-test-id='reattach-candidate-beef0001']").trigger("click");
await w.find("[data-test='reattach-confirm-source']").trigger("click");
const updates = w.emitted("update");
expect(updates).toBeTruthy();
const patch = updates![updates!.length - 1][0] as { payload: Record<string, unknown> };
expect(patch.payload.source_wildcard_id).toBe("beef0001");
// The repointed name is the picked candidate's name ("colour").
expect(patch.payload.source_wildcard_name).toBe("colour");
});
});
@@ -8,7 +8,8 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import ConstraintInstanceModal from "./ConstraintInstanceModal.vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
import TargetReachSection from "./sections/TargetReachSection.vue";
import type { ModuleEntry, TargetSelect } from "../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
@@ -268,4 +269,162 @@ describe("ConstraintInstanceModal", () => {
expect(matrix.props("sourceSubs")).toEqual(["live_warm", "live_cool"]);
_resetForTests();
});
// ── Cross-node chain resolution (SP3 P6 T16) ──
// The target wildcard lives in a DOWNSTREAM Context node, so it is
// absent from `siblingModules` (same-node only). The mount layer
// threads the flattened cross-node chain as `chainModules`; the modal
// must resolve the target's name + subcats + option values from it
// instead of degrading to raw-uuid / matrix-key fallbacks.
it("resolves target wildcard from chainModules when it lives in a downstream node", () => {
const m = makeModule({
payload: {
source_wildcard_id: "wc_color",
target_wildcard_id: "wc_fabric",
// Deliberately empty matrix so any axis output MUST come from the
// live wildcard (via chainModules), not from saved-matrix keys.
matrix: {},
exceptions: [],
},
});
// Same-node siblings hold only the source; the target is cross-node.
const sourceSibling: ModuleEntry = {
id: "wc_color",
type: "wildcard",
enabled: true,
meta: { name: "color" },
entries: [],
payload: { sub_categories: ["red", "blue"], options: [{ id: "o1", value: "red" }] },
};
// Cross-node chain entry for the target — shape is ChainModule
// (`displayName` not `meta.name`, `rowKey` graph-unique).
const targetChain = {
id: "wc_fabric",
rowKey: "99#wc_fabric",
type: "wildcard",
displayName: "fabric",
payload: {
var_binding: "fabric",
sub_categories: ["cotton", "silk"],
options: [
{ id: "f0", value: "denim" },
{ id: "f1", value: "linen" },
],
},
};
const w = mount(ConstraintInstanceModal, {
props: {
module: m,
siblingModules: [sourceSibling],
chainModules: [targetChain],
},
});
const matrix = w.findComponent({ name: "MatrixSection" });
// Target name resolves from the chain entry (var_binding > displayName).
expect(matrix.props("targetName")).toBe("fabric");
// Target subcats come from the cross-node wildcard, not matrix keys.
expect(matrix.props("targetSubs")).toEqual(["cotton", "silk"]);
// Source still resolves from the same-node sibling.
expect(matrix.props("sourceName")).toBe("color");
expect(matrix.props("sourceSubs")).toEqual(["red", "blue"]);
const exSection = w.findComponent({ name: "ExceptionsSection" });
// Target option values flow from the chain entry into autocomplete.
expect(exSection.props("targetValues")).toEqual(["denim", "linen"]);
});
it("chainModules wins over siblingModules for the same uuid (chain is the superset)", () => {
const m = makeModule({
payload: {
source_wildcard_id: "wc_color",
target_wildcard_id: "wc_fabric",
matrix: {},
exceptions: [],
},
});
// Same uuid present in BOTH: chain entry must win so the merge is
// safe for the current node (which appears inside the chain too).
const sourceSibling: ModuleEntry = {
id: "wc_color",
type: "wildcard",
enabled: true,
meta: { name: "sibling_color" },
entries: [],
payload: { sub_categories: ["sib_red"], options: [] },
};
const sourceChain = {
id: "wc_color",
rowKey: "7#wc_color",
type: "wildcard",
displayName: "chain_color",
payload: { sub_categories: ["chain_red", "chain_blue"], options: [] },
};
const w = mount(ConstraintInstanceModal, {
props: {
module: m,
siblingModules: [sourceSibling],
chainModules: [sourceChain],
},
});
const matrix = w.findComponent({ name: "MatrixSection" });
expect(matrix.props("sourceName")).toBe("chain_color");
expect(matrix.props("sourceSubs")).toEqual(["chain_red", "chain_blue"]);
});
});
describe("ConstraintInstanceModal — target reach instance override", () => {
// The modal collapses a reach choice to `null` ("inherit library") ONLY
// when it equals the EFFECTIVE library default — so a real per-instance
// override (including an explicit `all` over a non-all library default)
// is preserved and reaches the engine's `instance ?? payload ?? all`.
function emitReach(w: ReturnType<typeof mount>, next: TargetSelect) {
return w.findComponent(TargetReachSection).vm.$emit("update:modelValue", next);
}
function lastReach(w: ReturnType<typeof mount>): TargetSelect | null | undefined {
const updates = w.emitted("update") as Array<[Partial<ModuleEntry>]> | undefined;
if (!updates?.length) return undefined;
return updates[updates.length - 1][0].instance?.target_select;
}
function moduleWithLibraryReach(reach?: TargetSelect): ModuleEntry {
const m = makeModule();
if (reach) (m.payload as Record<string, unknown>).target_select = reach;
return m;
}
it("collapses 'all' to null when the library default is already all (inherit)", async () => {
const w = mount(ConstraintInstanceModal, { props: { module: moduleWithLibraryReach() } });
emitReach(w, { mode: "all" });
await w.vm.$nextTick();
expect(lastReach(w)).toBeNull();
});
it("stores an explicit 'all' override when the library default is non-all (first)", async () => {
// Regression guard: the old `mode === "all"` collapse dropped this to
// null, so the engine fell back to the library `first` and the user's
// widen-to-all was silently ignored.
const w = mount(ConstraintInstanceModal, {
props: { module: moduleWithLibraryReach({ mode: "first" }) },
});
emitReach(w, { mode: "all" });
await w.vm.$nextTick();
expect(lastReach(w)).toEqual({ mode: "all" });
});
it("collapses to null when the choice equals a non-all library default (first == first)", async () => {
const w = mount(ConstraintInstanceModal, {
props: { module: moduleWithLibraryReach({ mode: "first" }) },
});
emitReach(w, { mode: "first" });
await w.vm.$nextTick();
expect(lastReach(w)).toBeNull();
});
it("stores a non-default reach (next N) as an explicit override", async () => {
const w = mount(ConstraintInstanceModal, { props: { module: moduleWithLibraryReach() } });
emitReach(w, { mode: "next", count: 2 });
await w.vm.$nextTick();
expect(lastReach(w)).toEqual({ mode: "next", count: 2 });
});
});
@@ -16,10 +16,12 @@
* `locked_seed` for this kind. Dropping the section is honest;
* dimmed dead UI would lie.
*/
import { computed, watch } from "vue";
import { computed, ref, watch } from "vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
import type { ChainModule, TargetSelect } from "../../../../extension/constraint-pairs";
import IdentitySection from "./sections/IdentitySection.vue";
import MatrixSection from "./sections/MatrixSection.vue";
import TargetReachSection from "./sections/TargetReachSection.vue";
import ExceptionsSection from "./sections/ExceptionsSection.vue";
// Library fallback for cross-Context constraints. When the referenced
// source/target wildcard isn't a sibling in this WP_Context (lives in
@@ -28,6 +30,20 @@ import ExceptionsSection from "./sections/ExceptionsSection.vue";
// exception chips, and autocomplete suggestions reflect the live
// wildcard instead of the saved-matrix's frozen keys.
import { cacheVersion, ensure, lookup } from "../../../../extension/preview-resolver";
import ConstraintReattachSection from "./sections/ConstraintReattachSection.vue";
import ConfirmDialog from "../../../shared/ConfirmDialog.vue";
import { walkRemap } from "../../bundles/uuid-remap";
import { buildWildcardRefData } from "../../../../manager/utils/library-suggestions";
import type { ModuleRow } from "../../../../manager/api/types";
import { api } from "../../../../manager/api/client";
import {
fetchCommunityPostDetail,
downloadCommunityVersion,
type CommunityDepEdge,
} from "../../../../manager/community/community-posts";
import { collectTransitiveDeps } from "../../../../manager/community/transitive-deps";
import { downloadDepsForDangling } from "../../../../manager/community/download-and-reattach";
import { installEnvelope } from "../../../../manager/import-export/install";
const props = withDefaults(
defineProps<{
@@ -40,6 +56,15 @@ const props = withDefaults(
* matrix axes (sub_categories) + extra-exception autocomplete
* (option values). Optional; falls back to empty axes. */
siblingModules?: ModuleEntry[];
/** Flattened cross-node module chain (upstream + own + downstream
* WP_Context nodes), computed at the mount layer. Lets the modal
* resolve a source/target wildcard that lives in a DOWNSTREAM (or
* upstream) Context node — not just the same node. A superset of
* `siblingModules` (the own node's modules appear in the chain
* too), so merging is safe; chain entries win on uuid collision.
* Optional — undefined in headless/legacy mounts falls back to
* `siblingModules` alone. */
chainModules?: ChainModule[];
}>(),
{ isDrifted: false, isModified: false, siblingModules: () => [] },
);
@@ -67,9 +92,51 @@ interface WildcardPayload {
options?: Array<{ value?: string }>;
}
function findWildcardModule(id: string | null | undefined): ModuleEntry | null {
/** Normalised module shape the resolution lookups consume. Unifies the
* two source shapes: `ModuleEntry` (same-node siblings — name under
* `meta.name`) and `ChainModule` (cross-node chain — name under
* `displayName`). Carrying a flat `name` lets `findWildcardModule` +
* `wildcardName` stay shape-agnostic. */
interface LookupModule {
id: string;
type: string;
payload: Record<string, unknown>;
name?: string;
}
/** Merge of the cross-node chain (`chainModules`) and same-node
* siblings (`siblingModules`), keyed by uuid. Chain entries win on
* collision: the chain is a superset that already includes the current
* node's modules, and it carries the authoritative cross-node view, so
* preferring it keeps same-node behaviour identical (same payload) while
* adding reach to downstream/upstream targets. Deduped by id. */
const modulesForLookup = computed<LookupModule[]>(() => {
const byId = new Map<string, LookupModule>();
// Siblings first so chain entries (added second) overwrite on uuid.
for (const s of props.siblingModules) {
if (s.type !== "wildcard") continue;
byId.set(s.id, {
id: s.id,
type: s.type,
payload: (s.payload ?? {}) as Record<string, unknown>,
name: s.meta?.name,
});
}
for (const c of props.chainModules ?? []) {
if (c.type !== "wildcard") continue;
byId.set(c.id, {
id: c.id,
type: c.type,
payload: (c.payload ?? {}) as Record<string, unknown>,
name: c.displayName,
});
}
return [...byId.values()];
});
function findWildcardModule(id: string | null | undefined): LookupModule | null {
if (!id) return null;
const m = props.siblingModules.find((x) => x.id === id);
const m = modulesForLookup.value.find((x) => x.id === id);
if (!m || m.type !== "wildcard") return null;
return m;
}
@@ -79,13 +146,18 @@ function findWildcard(id: string | null | undefined): WildcardPayload | null {
return m ? ((m.payload ?? {}) as WildcardPayload) : null;
}
/** Sibling first, then preview-resolver cache. Returns the wildcard's
* display name; falls back to varBinding for cross-Context refs that
* have a `$var` name but no human title. Empty string when neither
* source knows the uuid. */
/** Chain/sibling first, then preview-resolver cache. Returns the
* wildcard's display name; for chain entries the `displayName` is
* `meta.name` at flatten time. A `$var`-bound wildcard with no human
* title (common for cross-node chain entries) falls to its
* `var_binding`, then the cache's varBinding/name. Empty string when
* no source knows the uuid. Display name is preferred over var_binding
* so a same-node sibling's human title still wins (unchanged). */
function wildcardName(id: string | null | undefined): string {
const m = findWildcardModule(id);
if (m?.meta?.name) return m.meta.name;
if (m?.name) return m.name;
const binding = (m?.payload?.var_binding as string | undefined)?.trim().replace(/^\$+/, "");
if (binding) return binding;
if (!id) return "";
void cacheVersion.value;
const hit = lookup(id);
@@ -112,8 +184,19 @@ watch(
interface ConstraintPayload {
source_wildcard_id?: string;
target_wildcard_id?: string;
/** Cached display names, stamped on write so the broken-reference banner
* can show the name after the wildcard is deleted (the live lookup misses
* then). Display-only; the engine never reads them. */
source_wildcard_name?: string;
target_wildcard_name?: string;
matrix?: Record<string, Record<string, unknown>>;
exceptions?: Array<{ source_value?: string; target_value?: string; source?: string; target?: string }>;
target_select?: TargetSelect;
/** Community post this constraint was installed from (engine migration
* 013, denormalized onto the picked module's payload). Drives Feature 2's
* "Download from community" affordance — only when set can the modal pull
* a missing dependency. Absent for hand-authored / non-community modules. */
community_post_slug?: string;
}
/** Pull the wildcard's live sub_categories, preferring sibling module
@@ -210,6 +293,282 @@ const targetName = computed(() => {
return wildcardName(pl.target_wildcard_id);
});
/** Cached display name for the broken-reference banner. Prefer the LIVE
* resolved name (`wildcardName`), falling back to the payload's stamped
* `*_wildcard_name` when the wildcard is dangling — that's the whole point
* of the cache: the live lookup returns "" once the wildcard is gone, so the
* banner would otherwise show the bare uuid. Display-only. */
const sourceCachedName = computed(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return sourceName.value || pl.source_wildcard_name || "";
});
const targetCachedName = computed(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return targetName.value || pl.target_wildcard_name || "";
});
/** A source/target id is DANGLING when non-empty but it resolves to nothing
* — neither a sibling/chain wildcard (findWildcardModule) nor the
* preview-resolver cache (lookup). Today this silently falls back to matrix
* keys; here it raises the reattach banner (spec Component B "Detection"). */
function isDangling(id: string | undefined): boolean {
if (typeof id !== "string" || id.length === 0) return false;
if (findWildcardModule(id)) return false;
void cacheVersion.value;
return lookup(id) === undefined;
}
const danglingSource = computed(() =>
isDangling(((props.module.payload ?? {}) as ConstraintPayload).source_wildcard_id),
);
const danglingTarget = computed(() =>
isDangling(((props.module.payload ?? {}) as ConstraintPayload).target_wildcard_id),
);
const hasDangling = computed(() => danglingSource.value || danglingTarget.value);
const danglingSourceUuid = computed(() =>
((props.module.payload ?? {}) as ConstraintPayload).source_wildcard_id ?? "",
);
const danglingTargetUuid = computed(() =>
((props.module.payload ?? {}) as ConstraintPayload).target_wildcard_id ?? "",
);
/** WildcardRefData for the reattach dropdown — from the same cross-node +
* sibling wildcard set the modal already merges (modulesForLookup). */
const reattachRefData = computed(() => {
const rows = modulesForLookup.value.map((m) => ({
id: m.id, name: m.name ?? "", type: "wildcard", payload: m.payload,
}) as unknown as ModuleRow);
return buildWildcardRefData(rows);
});
/** Live pre-confirm reattach selection, surfaced by the section's `@pick`.
* Drives the dropped-cell preview; reset to null when the section abandons
* the pick (`@pickcleared`) or after a reattach is handled. */
const reattachPick = ref<{ side: "source" | "target"; uuid: string } | null>(null);
/** Cells the picked candidate would DROP from the saved matrix, counted at
* the cell level (not axis keys) so the pre-confirm preview is honest:
* - a vanished SOURCE row drops every cell in that row;
* - a vanished TARGET key drops one cell per row that carries it.
* The candidate's sub_categories come from the same ref-data the dropdown
* picks from; an empty/unknown set means every current key vanishes. */
const reattachDroppedCellCount = computed(() => {
const pick = reattachPick.value;
if (!pick) return 0;
const pl = (props.module.payload ?? {}) as ConstraintPayload;
const matrix = pl.matrix ?? {};
const newSubs = new Set(reattachRefData.value.uuidToSubCategories.get(pick.uuid) ?? []);
let dropped = 0;
if (pick.side === "source") {
for (const [srcKey, row] of Object.entries(matrix)) {
if (!newSubs.has(srcKey)) dropped += Object.keys((row ?? {}) as Record<string, unknown>).length;
}
} else {
for (const row of Object.values(matrix)) {
for (const tgtKey of Object.keys((row ?? {}) as Record<string, unknown>)) {
if (!newSubs.has(tgtKey)) dropped += 1;
}
}
}
return dropped;
});
/** Conservative blast-radius signal: the constraint reaches beyond the
* current node when the cross-node chain spans more than one context. The
* modal can't cheaply run a full reverse-dep, so this errs toward the
* WARNING (the safer default for a silent cross-context library mutation). */
const referencedElsewhere = computed(() => {
const chain = props.chainModules ?? [];
const contexts = new Set(chain.map((m) => m.rowKey.split("#")[0]));
return contexts.size > 1;
});
function onReattach(payload: { side: "source" | "target"; oldUuid: string; newUuid: string; newName: string }): void {
const pl = (props.module.payload ?? {}) as Record<string, unknown>;
// Single walkRemap pass over the constraint's OWN payload: repoints the
// source/target whole-string id AND any embedded @{oldUuid} in exception
// values (segments preserved). Matrix axes re-derive from the new wildcard
// via the existing liveSubCategories → sourceSubs/targetSubs path. Mirrors
// cascade-restore.ts:rewriteChildId, which already walkRemaps a constraint
// payload to repoint source/target + @{} refs.
const rewritten = walkRemap(pl, { [payload.oldUuid]: payload.newUuid }) as Record<string, unknown>;
// Re-cache the display name from the picked candidate (walkRemap leaves the
// OLD cached name in place — it only rewrites the uuid). Display-only; feeds
// the broken-reference banner if this new wildcard is later deleted too.
if (payload.side === "source") rewritten.source_wildcard_name = payload.newName;
else rewritten.target_wildcard_name = payload.newName;
emit("update", { payload: rewritten });
// Reattach edits source/target — library-defining — so it rides
// Save-to-library (rewrites the library row → every context that uses it).
emit("save-to-library");
// Pick consumed — clear the live preview so a stale dropped-cell count
// can't survive into the next reattach.
reattachPick.value = null;
}
// ── Download-missing-dep-from-community (Feature 2) ──────────────────
//
// Mirrors ConstraintEditor.vue's SPA flow, adapted to the canvas modal's
// data sources. The modal has no moduleStore catalog — it resolves refs via
// the preview-resolver cache (`ensure`/`lookup`). After install, the dep
// lands at its original id (installEnvelope preserves publisher ids); a
// fresh `ensure([uuid])` repopulates the cache and the `isDangling`
// computed (which reads `cacheVersion`) clears the banner. Gated by the
// post slug being present on the picked module's payload — absent (e.g. a
// hand-authored constraint) means the affordance never shows.
/** The constraint's community post slug, if it was installed from one. */
const constraintModalSlug = computed<string>(
() => ((props.module.payload ?? {}) as ConstraintPayload).community_post_slug ?? "",
);
/** The constraint post's OWN dependency edges, lazily fetched once. `null`
* = not fetched; `[]` = fetched (or failed → "no downloadable deps"). */
const constraintPostDeps = ref<CommunityDepEdge[] | null>(null);
function hasEdgeFor(uuid: string): boolean {
if (!uuid) return false;
return !!constraintPostDeps.value?.some((e) => e.module_id === uuid);
}
const downloadableSides = computed(() => ({
source: danglingSource.value && hasEdgeFor(danglingSourceUuid.value),
target: danglingTarget.value && hasEdgeFor(danglingTargetUuid.value),
}));
// Lazily fetch the post's dependency edges the first time a side dangles on
// a community-installed constraint. Errors degrade to `[]`.
watch(
[hasDangling, constraintModalSlug],
async ([dangling, slug]) => {
if (!dangling || !slug) return;
if (constraintPostDeps.value !== null) return;
try {
const detail = await fetchCommunityPostDetail(slug);
constraintPostDeps.value = detail.dependencies;
} catch {
constraintPostDeps.value = [];
}
},
{ immediate: true },
);
const downloadConfirmOpen = ref(false);
const downloadConfirmBody = ref("");
const pendingDownloadSide = ref<"source" | "target" | null>(null);
const pendingDownloadUuid = ref<string | null>(null);
const downloading = ref(false);
const downloadError = ref<string>("");
async function onDownloadReattach({ side }: { side: "source" | "target" }): Promise<void> {
const uuid = side === "source" ? danglingSourceUuid.value : danglingTargetUuid.value;
if (!uuid || !constraintPostDeps.value) return;
const provider = constraintPostDeps.value.find((e) => e.module_id === uuid);
if (!provider) return;
const { slugs, capped } = await collectTransitiveDeps(
[provider.slug],
async (s) => (await fetchCommunityPostDetail(s)).dependencies.map((e) => e.slug).filter(Boolean),
);
downloadConfirmBody.value =
`Download ${slugs.length} post(s) from the community:\n` +
slugs.map((s) => `• ${s}`).join("\n") +
(capped ? "\n(+ more — list capped)" : "");
pendingDownloadSide.value = side;
pendingDownloadUuid.value = uuid;
downloadConfirmOpen.value = true;
}
async function confirmDownload(): Promise<void> {
downloadConfirmOpen.value = false;
downloading.value = true;
downloadError.value = "";
try {
const res = await downloadDepsForDangling({
danglingUuid: pendingDownloadUuid.value!,
constraintDeps: constraintPostDeps.value!,
fetchDetail: fetchCommunityPostDetail,
download: downloadCommunityVersion,
install: (env, deps, origin) =>
installEnvelope({ envelope: env }, { importExport: api.importExport, dependencies: deps, origin }),
});
if (!res.ok) {
downloadError.value = res.error ?? "Could not download the missing dependency.";
return;
}
// Refresh the preview-resolver cache for the (now-installed) uuid so the
// dangling computed re-evaluates. The dep lands at its original id, so
// this resolves the reference; the banner clears on the next tick.
const uuid = pendingDownloadUuid.value!;
ensure([uuid]);
} finally {
downloading.value = false;
}
}
// ── Target reach (SP3) ──────────────────────────────────────────
//
// Effective `target_select`: per-instance override wins, then the
// library payload value, then the engine default `{mode:"all"}`. The
// section reads this; writes flow back into `instance.target_select`.
const targetSelect = computed<TargetSelect>(() => {
const inst = props.module.instance?.target_select;
if (inst && typeof inst === "object") return inst;
const pl = (props.module.payload ?? {}) as ConstraintPayload;
if (pl.target_select && typeof pl.target_select === "object") return pl.target_select;
return { mode: "all" };
});
const targetWildcardId = computed<string>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return pl.target_wildcard_id ?? "";
});
/** The edited constraint's row id, as it appears in `chainModules`
* (`${nodeId}#${_uid}`). The pick checklist walks DOWNSTREAM of this
* row, so it must match the chain's `rowKey` — find the constraint chain
* entry by `_uid` suffix (preferred) or `id`. Falls back to the bare
* `_uid`/`id` when the chain is absent (headless/legacy mounts) — the
* section then renders an empty checklist rather than crashing. */
const constraintUid = computed<string>(() => {
const uid = props.module._uid;
const chain = props.chainModules ?? [];
const hit = chain.find((m) => {
if (m.type !== "constraint") return false;
if (uid && m.rowKey.endsWith(`#${uid}`)) return true;
return m.id === props.module.id;
});
return hit?.rowKey ?? uid ?? props.module.id;
});
/** True when `next` is exactly what the constraint resolves to with NO
* per-instance override — the EFFECTIVE library default
* (`payload.target_select`, itself defaulting to `{mode:"all"}`). Only then
* is collapsing to `null` ("inherit library") lossless. */
function reachEqualsLibraryDefault(next: TargetSelect): boolean {
const lib = ((props.module.payload ?? {}) as ConstraintPayload).target_select ?? { mode: "all" };
if (next.mode !== lib.mode) return false;
if (next.mode === "next") return Number(next.count ?? 1) === Number(lib.count ?? 1);
// `pick` never matches the (pick-less) library default; first/all match on mode.
return next.mode !== "pick";
}
/** Persist a new `target_select` onto the instance. Mirrors the modal's
* other instance writes (full-replacement values, shallow-merged).
* Collapse to `null` ONLY when `next` equals the effective library default
* (null = "inherit library") — keeps an untouched reach out of the instance
* while PRESERVING a real override. The old `mode === "all"` collapse lost
* an explicit `all` placed over a non-all library default: the engine's
* `instance ?? payload ?? all` then fell back to the library value, so the
* user's widen-to-all was silently ignored. */
function onTargetSelect(next: TargetSelect): void {
const inherits = reachEqualsLibraryDefault(next);
emit("update", {
instance: {
...(props.module.instance ?? {}),
target_select: inherits ? null : next,
},
});
}
/** Tell the ExceptionsSection whether the source / target wildcard
* carries a null option. Drives the pi-ban chip render on library
* exception rows whose value is the empty string (null option key). */
@@ -262,10 +621,12 @@ const targetOptionsById = computed<ReadonlyMap<string, string>>(() => {
* chip the value editor shows — instead of raw `@{c0f09840}` text. */
const uuidToName = computed<ReadonlyMap<string, string>>(() => {
const m = new Map<string, string>();
for (const sib of props.siblingModules) {
if (sib.type !== "wildcard") continue;
const name = sib.meta?.name;
if (typeof name === "string" && name.length > 0) m.set(sib.id, name);
// modulesForLookup already merges chain (cross-node) + siblings and
// filters to wildcards, so nested `@{uuid}` refs pointing at a
// downstream-node wildcard resolve to a name too (not raw uuid).
for (const mod of modulesForLookup.value) {
const name = mod.name;
if (typeof name === "string" && name.length > 0) m.set(mod.id, name);
}
return m;
});
@@ -300,6 +661,26 @@ function onSpaClick(): void {
><i class="pi pi-times" aria-hidden="true" /></button>
</header>
<ConstraintReattachSection
v-if="hasDangling"
:dangling-source="danglingSource"
:dangling-target="danglingTarget"
:source-uuid="danglingSourceUuid"
:source-cached-name="sourceCachedName"
:target-uuid="danglingTargetUuid"
:target-cached-name="targetCachedName"
:ref-data="reattachRefData"
:referenced-elsewhere="referencedElsewhere"
:dropped-cell-count="reattachDroppedCellCount"
:downloadable-sides="downloadableSides"
@reattach="onReattach"
@pick="reattachPick = $event"
@pickcleared="reattachPick = null"
@downloadreattach="onDownloadReattach"
/>
<p v-if="downloadError" class="wp-cnm__dl-error" data-test="cnm-download-error">
<i class="pi pi-exclamation-triangle" aria-hidden="true" /> {{ downloadError }}
</p>
<IdentitySection :module="module" @update="onUpdate" />
<MatrixSection
:module="module"
@@ -307,6 +688,7 @@ function onSpaClick(): void {
:target-subs="targetSubs"
:source-name="sourceName"
:target-name="targetName"
:stranded="hasDangling"
@update="onUpdate"
/>
<ExceptionsSection
@@ -318,9 +700,19 @@ function onSpaClick(): void {
:source-options-by-id="sourceOptionsById"
:target-options-by-id="targetOptionsById"
:uuid-to-name="uuidToName"
:stranded="hasDangling"
@update="onUpdate"
/>
<div class="wp-cnm__dock">
<TargetReachSection
:model-value="targetSelect"
:chain-modules="chainModules"
:constraint-uid="constraintUid"
:target-wildcard-id="targetWildcardId"
:target-name="targetName"
@update:model-value="onTargetSelect"
/>
<footer class="wp-cnm__foot">
<a
v-if="isLibraryTracked"
@@ -357,6 +749,18 @@ function onSpaClick(): void {
<button type="button" class="wp-cnm__btn" data-test="cnm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-cnm__btn wp-cnm__btn--primary" data-test="cnm-save" @click="emit('save')">Save</button>
</footer>
</div>
<!-- Feature 2: confirm pulling the missing dependency (+ its closure)
from the community before any network/install side effect. -->
<ConfirmDialog
:visible="downloadConfirmOpen"
title="Download dependencies"
:body="downloadConfirmBody"
confirm-label="Download & install"
@confirm="confirmDownload"
@cancel="downloadConfirmOpen = false"
/>
</div>
</template>
@@ -370,13 +774,19 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-cnm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-cnm__foot {
display: flex;
align-items: center;
@@ -434,4 +844,17 @@ function onSpaClick(): void {
color: var(--wp-text-muted, var(--wp-text2));
}
.wp-cnm__btn--quiet .pi { font-size: 10px; }
/* Inline download-failure note under the reattach banner — the modal has no
* toast surface, so a failed community pull reports here. */
.wp-cnm__dl-error {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
padding: 8px 16px;
font: 11px var(--wp-font-sans);
color: var(--wp-danger, var(--wp-red));
background: color-mix(in oklab, var(--wp-danger, #ef4444) 8%, transparent);
}
.wp-cnm__dl-error .pi { font-size: 12px; }
</style>
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import ConstraintReattachSection from "./ConstraintReattachSection.vue";
import type { WildcardRefData } from "../../../../../manager/utils/library-suggestions";
function refData(): WildcardRefData {
return {
uuidToName: new Map([["beef0001", "colour"]]),
uuidToSubCategories: new Map([["beef0001", ["warm", "cold"]]]),
uuidToHasNull: new Map([["beef0001", false]]),
uuidToOptionsCount: new Map([["beef0001", 2]]),
uuidToOptionTagSets: new Map([["beef0001", [["warm"], ["cold"]]]]),
uuidToTagGroups: new Map([["beef0001", {}]]),
};
}
function mountSection(overrides = {}) {
return mount(ConstraintReattachSection, {
props: {
danglingSource: true,
danglingTarget: false,
sourceUuid: "deadbeef",
sourceCachedName: "old colour",
targetUuid: "",
targetCachedName: "",
refData: refData(),
referencedElsewhere: false,
...overrides,
},
});
}
describe("ConstraintReattachSection", () => {
it("renders a remap banner row for the dangling source only", () => {
const w = mountSection();
expect(w.find("[data-test='reattach-row-source']").exists()).toBe(true);
expect(w.find("[data-test='reattach-row-target']").exists()).toBe(false);
expect(w.text()).toContain("deadbeef".slice(0, 8));
});
it("Reattach opens the dropdown and confirm emits the side + old/new uuids", async () => {
const w = mountSection();
await w.find("[data-test='reattach-btn-source']").trigger("click");
await w.find("[data-test-id='reattach-candidate-beef0001']").trigger("click");
await w.find("[data-test='reattach-confirm-source']").trigger("click");
const ev = w.emitted("reattach");
expect(ev).toBeTruthy();
expect(ev![0][0]).toEqual({
side: "source",
oldUuid: "deadbeef",
newUuid: "beef0001",
newName: "colour",
});
});
it("shows the library-mutation blast-radius warning when referenced elsewhere", async () => {
const w = mountSection({ referencedElsewhere: true });
await w.find("[data-test='reattach-btn-source']").trigger("click");
const warn = w.find("[data-test='reattach-blast-radius']");
expect(warn.exists()).toBe(true);
expect(warn.text()).toMatch(/everywhere it.s used/i);
});
it("hides the blast-radius warning when only the current context uses it", async () => {
const w = mountSection({ referencedElsewhere: false });
await w.find("[data-test='reattach-btn-source']").trigger("click");
expect(w.find("[data-test='reattach-blast-radius']").exists()).toBe(false);
});
it("shows a dropped-cells preview note for the picked candidate", async () => {
const w = mountSection({ droppedCellCount: 3 });
await w.find("[data-test='reattach-btn-source']").trigger("click");
await w.find("[data-test-id='reattach-candidate-beef0001']").trigger("click");
expect(w.find("[data-test='reattach-dropped-source']").text()).toMatch(/3 cells dropped/);
});
it("floats the cached-name match to the top of the candidate list", async () => {
const rd = refData();
rd.uuidToName = new Map([["aaa00000", "zzz"], ["beef0001", "colour"]]);
const w = mountSection({ sourceCachedName: "colour", refData: rd });
await w.find("[data-test='reattach-btn-source']").trigger("click");
const first = w.findAll("[data-test='reattach-candidate']")[0];
expect(first.text()).toBe("colour");
});
it("renders the community-download button only on a downloadable side", () => {
const w = mountSection({ downloadableSides: { source: true, target: false } });
expect(w.find("[data-test='reattach-download-source']").exists()).toBe(true);
// Target isn't dangling here, but even its (absent) row shouldn't carry
// the download button when not flagged downloadable.
expect(w.find("[data-test='reattach-download-target']").exists()).toBe(false);
});
it("hides the community-download button when the side isn't downloadable", () => {
const w = mountSection({ downloadableSides: { source: false, target: false } });
expect(w.find("[data-test='reattach-download-source']").exists()).toBe(false);
});
it("omits the community-download button by default (no downloadableSides prop)", () => {
const w = mountSection();
expect(w.find("[data-test='reattach-download-source']").exists()).toBe(false);
});
it("emits downloadreattach with the side when the download button is clicked", async () => {
const w = mountSection({ downloadableSides: { source: true, target: false } });
await w.find("[data-test='reattach-download-source']").trigger("click");
const ev = w.emitted("downloadreattach");
expect(ev).toBeTruthy();
expect(ev![0][0]).toEqual({ side: "source" });
});
});
@@ -0,0 +1,282 @@
<script setup lang="ts">
/**
* ConstraintReattachSection — heals a constraint whose source/target
* wildcard uuid is missing locally (#4). Renders ABOVE Identity in the
* constraint surfaces (canvas modal + SPA editor). One red row per dangling
* endpoint: the dead uuid (+ cached name), a Reattach button opening the
* same searchable wildcard dropdown #3 uses, a dropped-cell preview count,
* and the library-mutation blast-radius warning.
*
* SCOPE BOUNDARY (task_5200c1fc) — DO NOT CONFLATE WITH THE ENGINE FIX:
* Reattach SIDESTEPS the engine pick-bucket collision in the common case
* (re-pointing at a distinct local uuid yields a unique __wp_picks__ key).
* It does NOT fix the collision: remapping two broken constraints onto the
* SAME local wildcard re-manifests it (two instances of one library
* wildcard share the pick bucket; the second pick clobbers the first). The
* engine disambiguation (key picks on per-instance _uid + source-INSTANCE
* binding) is tracked separately as task_5200c1fc. This is UI-layer
* reference-healing only.
*
* RECONCILE IS DESTRUCTIVE: the dropped-cell count is previewed before
* confirm; CONFIRM is the point of no return (the parent runs walkRemap +
* rides Save-to-library); CANCEL is the only undo. No post-confirm undo of
* dropped cells in v1.
*/
import { ref } from "vue";
import type { WildcardRefData } from "../../../../../manager/utils/library-suggestions";
const props = withDefaults(defineProps<{
danglingSource: boolean;
danglingTarget: boolean;
sourceUuid: string;
sourceCachedName: string;
targetUuid: string;
targetCachedName: string;
refData: WildcardRefData;
/** True when the constraint is referenced beyond the current context
* (reverse-dep / reach data). Drives the blast-radius warning. */
referencedElsewhere: boolean;
/** Optional preview: cells that would be dropped by the picked candidate
* (computed by the parent against the candidate's sub_categories). */
droppedCellCount?: number;
/** Per-side gate for the "Download from community" affordance (Feature 2).
* True only when the constraint was installed from a community post AND
* that post declares a dependency edge providing the dangling uuid — i.e.
* the missing wildcard is actually pullable. The parent owns that check;
* the section just renders the button when the matching side is flagged. */
downloadableSides?: { source: boolean; target: boolean };
}>(), { droppedCellCount: 0, downloadableSides: () => ({ source: false, target: false }) });
const emit = defineEmits<{
reattach: [payload: { side: "source" | "target"; oldUuid: string; newUuid: string; newName: string }];
/** Live pre-confirm selection — lets the parent derive the dropped-cell
* preview against the candidate's sub_categories. Fired on every pick. */
pick: [payload: { side: "source" | "target"; uuid: string }];
/** Selection abandoned (dropdown opened/closed without confirming, or a
* confirm consumed it) — parent resets its dropped-cell preview to 0. */
pickcleared: [];
/** "Download from community" clicked for a side (Feature 2). The parent
* resolves the dep closure, opens its own confirm dialog, then pulls +
* installs; reattach falls out of local resolution afterwards. */
downloadreattach: [payload: { side: "source" | "target" }];
}>();
type Side = "source" | "target";
const openSide = ref<Side | null>(null);
const query = ref("");
const picked = ref<{ uuid: string; name: string } | null>(null);
interface Candidate { uuid: string; name: string }
function candidates(): Candidate[] {
const q = query.value.trim().toLowerCase();
// Cached name of the open side is the SORT KEY (auto-suggest), mirroring
// #3 RemapRefPopup: exact match floats to top WITHOUT filtering others out.
const cached = (openSide.value === "target" ? props.targetCachedName : props.sourceCachedName).trim().toLowerCase();
const out: Candidate[] = [];
const dead = new Set([props.sourceUuid, props.targetUuid]);
for (const [uuid, name] of props.refData.uuidToName) {
if (dead.has(uuid)) continue; // can't re-pick the dangling uuid
if (q && !name.toLowerCase().includes(q)) continue;
out.push({ uuid, name });
}
return out.sort((a, b) => {
const ax = a.name.toLowerCase() === cached ? 0 : 1;
const bx = b.name.toLowerCase() === cached ? 0 : 1;
return ax !== bx ? ax - bx : a.name.localeCompare(b.name);
});
}
function openDropdown(side: Side): void {
openSide.value = side;
// Empty query keeps every live candidate visible; the cached name is the
// sort key in candidates() (auto-suggest), floating an exact match to top
// without filtering the rest out (mirrors #3 RemapRefPopup).
query.value = "";
picked.value = null;
// Opening (or re-opening the other side) abandons any prior selection —
// tell the parent so a stale dropped-cell preview can't linger.
emit("pickcleared");
}
function pick(c: Candidate): void {
picked.value = c;
// Surface the live pre-confirm pick so the parent can derive the
// dropped-cell preview before the irreversible confirm.
if (openSide.value) emit("pick", { side: openSide.value, uuid: c.uuid });
}
function confirm(side: Side): void {
if (!picked.value) return;
emit("reattach", {
side,
oldUuid: side === "source" ? props.sourceUuid : props.targetUuid,
newUuid: picked.value.uuid,
newName: picked.value.name,
});
openSide.value = null;
picked.value = null;
// Confirm consumes the pick; the parent resets its preview (the reattach
// handler also clears it, this keeps the two emits self-consistent).
emit("pickcleared");
}
</script>
<template>
<section class="rb" data-test="reattach-section">
<div class="rb__label">Broken reference</div>
<div v-if="danglingSource" class="rb__row" data-test="reattach-row-source">
<i class="pi pi-exclamation-triangle rb__warn-icon" aria-hidden="true" />
<span class="rb__txt">
Source wildcard <code>{{ sourceUuid.slice(0, 8) }}</code>
<span v-if="sourceCachedName"> (was “{{ sourceCachedName }}”)</span>
is not in your library.
</span>
<button type="button" class="rb__btn" data-test="reattach-btn-source" @click="openDropdown('source')">Reattach</button>
<button
v-if="downloadableSides.source"
type="button"
class="rb__btn rb__btn--download"
data-test="reattach-download-source"
@click="emit('downloadreattach', { side: 'source' })"
><i class="pi pi-cloud-download" aria-hidden="true" /> Download from community</button>
<div v-if="openSide === 'source'" class="rb__picker">
<input v-model="query" type="text" class="rb__search" placeholder="Search wildcards…" spellcheck="false" aria-label="Search wildcards to reattach source" />
<ul class="rb__list">
<li
v-for="c in candidates()"
:key="c.uuid"
class="rb__cand"
data-test="reattach-candidate"
:data-test-id="`reattach-candidate-${c.uuid}`"
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
@click="pick(c)"
>{{ c.name }}</li>
</ul>
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-source">
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
</p>
<p v-if="referencedElsewhere" class="rb__blast" data-test="reattach-blast-radius">
<i class="pi pi-info-circle" aria-hidden="true" />
This changes the constraint everywhere it's used.
</p>
<p class="rb__note">Cancel is the only undo — confirm is final.</p>
<button type="button" class="rb__confirm" data-test="reattach-confirm-source" :disabled="!picked || undefined" @click="confirm('source')">Confirm reattach</button>
</div>
</div>
<div v-if="danglingTarget" class="rb__row" data-test="reattach-row-target">
<i class="pi pi-exclamation-triangle rb__warn-icon" aria-hidden="true" />
<span class="rb__txt">
Target wildcard <code>{{ targetUuid.slice(0, 8) }}</code>
<span v-if="targetCachedName"> (was “{{ targetCachedName }}”)</span>
is not in your library.
</span>
<button type="button" class="rb__btn" data-test="reattach-btn-target" @click="openDropdown('target')">Reattach</button>
<button
v-if="downloadableSides.target"
type="button"
class="rb__btn rb__btn--download"
data-test="reattach-download-target"
@click="emit('downloadreattach', { side: 'target' })"
><i class="pi pi-cloud-download" aria-hidden="true" /> Download from community</button>
<div v-if="openSide === 'target'" class="rb__picker">
<input v-model="query" type="text" class="rb__search" placeholder="Search wildcards…" spellcheck="false" aria-label="Search wildcards to reattach target" />
<ul class="rb__list">
<li
v-for="c in candidates()"
:key="c.uuid"
class="rb__cand"
data-test="reattach-candidate"
:data-test-id="`reattach-candidate-${c.uuid}`"
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
@click="pick(c)"
>{{ c.name }}</li>
</ul>
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-target">
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
</p>
<p v-if="referencedElsewhere" class="rb__blast" data-test="reattach-blast-radius">
<i class="pi pi-info-circle" aria-hidden="true" />
This changes the constraint everywhere it's used.
</p>
<p class="rb__note">Cancel is the only undo — confirm is final.</p>
<button type="button" class="rb__confirm" data-test="reattach-confirm-target" :disabled="!picked || undefined" @click="confirm('target')">Confirm reattach</button>
</div>
</div>
</section>
</template>
<style scoped>
.rb {
padding: 12px 16px;
background: color-mix(in oklab, var(--wp-danger, #ef4444) 6%, var(--wp-bg2, #15151f));
border-bottom: 1px solid var(--wp-border-soft, var(--wp-border));
}
.rb__label {
font: 600 9px var(--wp-font-sans);
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--wp-danger, #ef4444);
margin-bottom: 8px;
}
.rb__row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font: 11px var(--wp-font-sans); padding: 4px 0; }
.rb__warn-icon { color: var(--wp-danger, #ef4444); font-size: 12px; }
.rb__txt { flex: 1; color: var(--wp-text, #e7e7ee); }
.rb__txt code { font-family: var(--wp-font-mono, monospace); }
.rb__btn,
.rb__confirm {
padding: 4px 12px;
border: 1px solid var(--wp-accent, #8b5cf6);
border-radius: 4px;
background: transparent;
color: var(--wp-accent-text, var(--wp-text));
font: 11px var(--wp-font-sans);
cursor: pointer;
}
.rb__confirm { background: var(--wp-accent, #8b5cf6); color: #fff; margin-top: 6px; }
/* Secondary affordance beside Reattach — pulls the missing dep from the
* community. Quieter than the primary accent border so the manual dropdown
* stays the obvious first action; the cloud icon signals the network pull. */
.rb__btn--download {
display: inline-flex;
align-items: center;
gap: 5px;
border-color: var(--wp-border, rgba(255,255,255,0.12));
color: var(--wp-text-muted, var(--wp-text2, var(--wp-text)));
}
.rb__btn--download:hover {
border-color: var(--wp-accent, #8b5cf6);
color: var(--wp-accent-text, var(--wp-text));
}
.rb__btn--download .pi { font-size: 11px; }
.rb__picker {
flex-basis: 100%;
margin-top: 8px;
padding: 10px;
border: 1px solid var(--wp-border, rgba(255,255,255,0.12));
border-radius: 6px;
background: var(--wp-bg, #11111b);
}
.rb__search {
width: 100%;
box-sizing: border-box;
padding: 6px 9px;
border-radius: 4px;
border: 1px solid var(--wp-border, rgba(255,255,255,0.12));
background: var(--wp-bg-2, #15151f);
color: var(--wp-text, #e7e7ee);
font: 12px var(--wp-font-mono, monospace);
outline: none;
}
.rb__list { list-style: none; margin: 6px 0; padding: 0; max-height: 150px; overflow-y: auto; }
.rb__cand { padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
.rb__cand:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
.rb__cand--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
.rb__dropped { margin: 6px 0 0; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
.rb__blast { margin: 6px 0 0; display: flex; align-items: center; gap: 5px; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
.rb__note { margin: 6px 0 0; font: 10px var(--wp-font-sans); color: var(--wp-text-dim, #6e6e7c); }
</style>
@@ -300,3 +300,114 @@ describe("constraint ExceptionsSection", () => {
expect(src.find(".pi-ban").exists()).toBe(false);
});
});
// ── Colored mode chips (glyph + label), mirroring the SPA ──────────
//
// The SPA exception table renders each mode as a colored chip with a
// glyph (↑ boost / ↓ reduce / × exclude / · neutral) + label. The canvas
// mode chip keeps its click-to-cycle behaviour but gains the glyph so the
// two surfaces speak one visual language.
describe("constraint ExceptionsSection — mode chip glyph", () => {
it("editable mode chip renders the mode glyph alongside the label", () => {
const w = mount(ExceptionsSection, {
props: { module: makeModule(), sourceValues: SOURCE_VALUES, targetValues: TARGET_VALUES },
});
// Row 0 is exclude → × glyph; row 1 is boost → ↑ glyph.
const chip0 = w.find('[data-test="ex-mode-0"]');
expect(chip0.find(".ex__mode-glyph").exists()).toBe(true);
expect(chip0.find(".ex__mode-glyph").text()).toBe("×");
const chip1 = w.find('[data-test="ex-mode-1"]');
expect(chip1.find(".ex__mode-glyph").text()).toBe("↑");
});
it("editable rows carry a per-row mode accent class keyed to the mode", () => {
const w = mount(ExceptionsSection, {
props: { module: makeModule(), sourceValues: SOURCE_VALUES, targetValues: TARGET_VALUES },
});
expect(w.find('[data-test="ex-row-0"]').classes()).toContain("ex__row--exclude");
expect(w.find('[data-test="ex-row-1"]').classes()).toContain("ex__row--boost");
});
});
// ── Stranded (read-only) exceptions, mirroring the SPA ─────────────
//
// When the parent modal reports a dangling source/target wildcard
// (`stranded`), the exception list becomes a read-only snapshot: static
// colored chips (no cycle button), values as text, and none of the
// edit affordances (checkbox / factor input / trash / add-extra).
function strandedModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return makeModule({
instance: {
extra_exceptions: [
{ source_value: "silver", target_value: "ivory", mode: "boost", factor: 2.0 },
],
},
...overrides,
});
}
describe("constraint ExceptionsSection — stranded read-only", () => {
it("renders the mode as a STATIC chip (no cycle button) when stranded", () => {
const w = mount(ExceptionsSection, {
props: {
module: strandedModule(),
sourceValues: SOURCE_VALUES,
targetValues: TARGET_VALUES,
stranded: true,
},
});
// The cycle button is gone; a read-only chip stands in its place.
expect(w.find('[data-test="ex-mode-0"]').exists()).toBe(false);
const ro = w.find('[data-test="ex-mode-ro-0"]');
expect(ro.exists()).toBe(true);
// It is NOT a button (no click-to-cycle); a span snapshot.
expect(ro.element.tagName).not.toBe("BUTTON");
// It still carries the glyph + label of the mode (row 0 = exclude).
expect(ro.find(".ex__mode-glyph").text()).toBe("×");
expect(ro.text().toLowerCase()).toContain("exclude");
});
it("drops all edit affordances (checkbox / factor / trash / add-extra) when stranded", () => {
const w = mount(ExceptionsSection, {
props: {
module: strandedModule(),
sourceValues: SOURCE_VALUES,
targetValues: TARGET_VALUES,
stranded: true,
},
});
expect(w.find('[data-test="ex-cb-0"]').exists()).toBe(false);
expect(w.find('[data-test="ex-factor-1"]').exists()).toBe(false);
expect(w.find('[data-test="ex-add-extra"]').exists()).toBe(false);
expect(w.find('[data-test="ex-extra-trash-0"]').exists()).toBe(false);
});
it("renders extra exceptions as read-only rows (no autocomplete inputs) when stranded", () => {
const w = mount(ExceptionsSection, {
props: {
module: strandedModule(),
sourceValues: SOURCE_VALUES,
targetValues: TARGET_VALUES,
stranded: true,
},
});
// The extra row still shows, but as a static snapshot.
expect(w.find('[data-test="ex-extra-0"]').exists()).toBe(true);
expect(w.findAllComponents({ name: "VarAutocompleteInput" }).length).toBe(0);
});
it("control: a healthy (non-stranded) constraint keeps the cycle button + edit controls", () => {
const w = mount(ExceptionsSection, {
props: {
module: strandedModule(),
sourceValues: SOURCE_VALUES,
targetValues: TARGET_VALUES,
// stranded omitted → defaults false
},
});
expect(w.find('[data-test="ex-mode-0"]').exists()).toBe(true);
expect(w.find('[data-test="ex-mode-ro-0"]').exists()).toBe(false);
expect(w.find('[data-test="ex-cb-0"]').exists()).toBe(true);
expect(w.find('[data-test="ex-add-extra"]').exists()).toBe(true);
});
});
@@ -22,6 +22,18 @@ import RichTextPreview from "../../../../../manager/components/RichTextPreview.v
type Mode = "allow" | "exclude" | "boost" | "reduce";
/** Mode → glyph + label for the colored mode chips. Glyphs mirror the
* matrix's MODE_ICON / cellGlyph convention (boost ↑ / reduce ↓ /
* exclude × / allow ·) and the SPA ConstraintEditor `MODE_META`, so the
* whole constraint surface speaks one visual language. Colour stays in
* the `ex__mode-chip--{mode}` CSS classes (already token-mapped). */
const MODE_META: Record<Mode, { glyph: string; label: string }> = {
boost: { glyph: "↑", label: "Boost" },
reduce: { glyph: "↓", label: "Reduce" },
exclude: { glyph: "×", label: "Exclude" },
allow: { glyph: "·", label: "Neutral" },
};
/**
* Engine accepts BOTH legacy (`source` / `target`) and tier 2
* (`source_value` / `target_value`) library exception shapes — see
@@ -103,12 +115,19 @@ const props = withDefaults(defineProps<{
* raw `@{c0f09840}` text. Built by the parent modal from the
* Context's wildcard catalog. */
uuidToName?: ReadonlyMap<string, string>;
/** Read-only recovery view: the source/target wildcard was deleted, so
* the exception list is a snapshot for understanding only — static
* colored chips, values as text, no edit controls. Reattach a live
* wildcard (banner above) to edit. Mirrors the SPA ConstraintEditor's
* stranded read-only exceptions table. */
stranded?: boolean;
}>(), {
sourceHasNull: false,
targetHasNull: false,
sourceOptionsById: () => new Map(),
targetOptionsById: () => new Map(),
uuidToName: () => new Map(),
stranded: false,
});
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
@@ -285,18 +304,24 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
<section class="ex" data-test="ex-section">
<div class="ex__label">Exceptions</div>
<div class="ex__list">
<div
v-for="(exc, i) in libraryExceptions"
:key="libKey(exc)"
class="ex__row"
:class="{ 'ex__row--off': disabledKeys.has(libKey(exc)) }"
:class="[
`ex__row--${effectiveMode(exc)}`,
{ 'ex__row--off': disabledKeys.has(libKey(exc)) },
]"
:data-test="`ex-row-${i}`"
>
<!-- Styled checkbox matching OptionRow's wildcard pattern — a
role-checkbox span with inline SVG tick. Replaces the bare
native input for visual parity with the wildcard / fixed-
values edit modals. -->
values edit modals. Hidden when stranded: a broken constraint
is a read-only snapshot, so the enable/disable toggle is gone. -->
<span
v-if="!stranded"
class="ex__check"
:class="{ 'ex__check--on': !disabledKeys.has(libKey(exc)) }"
:data-test="`ex-cb-${i}`"
@@ -356,15 +381,36 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
/>
</span>
</span>
<!-- Editable: click-to-cycle chip with glyph + label. Read-only
(stranded): the same colored chip as a static span — no cycle. -->
<button
v-if="!stranded"
type="button"
class="ex__mode-chip"
:class="`ex__mode-chip--${effectiveMode(exc)}`"
:data-test="`ex-mode-${i}`"
@click="onLibModeCycle(exc)"
>{{ effectiveMode(exc) }}</button>
>
<span class="ex__mode-glyph" aria-hidden="true">{{ MODE_META[effectiveMode(exc)].glyph }}</span>
{{ MODE_META[effectiveMode(exc)].label }}
</button>
<span
v-if="effectiveMode(exc) === 'boost' || effectiveMode(exc) === 'reduce'"
v-else
class="ex__mode-chip ex__mode-chip--ro"
:class="`ex__mode-chip--${effectiveMode(exc)}`"
:data-test="`ex-mode-ro-${i}`"
>
<span class="ex__mode-glyph" aria-hidden="true">{{ MODE_META[effectiveMode(exc)].glyph }}</span>
{{ MODE_META[effectiveMode(exc)].label }}
</span>
<!-- Factor: editable spinner when healthy; static ×N text when stranded. -->
<span
v-if="stranded && (effectiveMode(exc) === 'boost' || effectiveMode(exc) === 'reduce')"
class="ex__factor-ro"
:data-test="`ex-factor-ro-${i}`"
>×{{ effectiveFactor(exc) }}</span>
<span
v-else-if="effectiveMode(exc) === 'boost' || effectiveMode(exc) === 'reduce'"
class="ex__factor-wrap"
@wheel.stop
>
@@ -403,8 +449,37 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
</span>
</div>
<div v-for="(exc, i) in extras" :key="`extra-${i}`" class="ex__row ex__row--extra" :data-test="`ex-extra-${i}`">
<div
v-for="(exc, i) in extras"
:key="`extra-${i}`"
class="ex__row ex__row--extra"
:class="`ex__row--${exc.mode}`"
:data-test="`ex-extra-${i}`"
>
<span class="ex__extra-badge" data-test="ex-extra-badge">extra</span>
<!-- Editable inputs when healthy; read-only chips when stranded.
A stranded constraint is a snapshot, so extras lock alongside
the library rows (matches the SPA read-only exceptions table). -->
<template v-if="stranded">
<span class="ex__pair">
<span class="ex__src"><RichTextPreview :value="excSrc(exc, props.sourceOptionsById)" :uuid-to-name="uuidToName" surface="wildcard" /></span>
<span class="ex__arrow">→</span>
<span class="ex__tgt"><RichTextPreview :value="excTgt(exc, props.targetOptionsById)" :uuid-to-name="uuidToName" surface="wildcard" /></span>
</span>
<span
class="ex__mode-chip ex__mode-chip--ro"
:class="`ex__mode-chip--${exc.mode}`"
:data-test="`ex-extra-mode-ro-${i}`"
>
<span class="ex__mode-glyph" aria-hidden="true">{{ MODE_META[exc.mode].glyph }}</span>
{{ MODE_META[exc.mode].label }}
</span>
<span
v-if="exc.mode === 'boost' || exc.mode === 'reduce'"
class="ex__factor-ro"
>×{{ exc.factor }}</span>
</template>
<template v-else>
<VarAutocompleteInput
:model-value="excSrc(exc, props.sourceOptionsById)"
:suggestions="[...sourceValues]"
@@ -427,7 +502,10 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
class="ex__mode-chip"
:class="`ex__mode-chip--${exc.mode}`"
@click="onExtraModeCycle(i)"
>{{ exc.mode }}</button>
>
<span class="ex__mode-glyph" aria-hidden="true">{{ MODE_META[exc.mode].glyph }}</span>
{{ MODE_META[exc.mode].label }}
</button>
<span
v-if="exc.mode === 'boost' || exc.mode === 'reduce'"
class="ex__factor-wrap"
@@ -472,14 +550,23 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
aria-label="Remove extra exception"
@click="onExtraTrash(i)"
><i class="pi pi-trash" /></button>
</template>
</div>
</div>
<button
v-if="!stranded"
type="button"
class="ex__add-extra"
data-test="ex-add-extra"
@click="onAddExtra"
><i class="pi pi-plus" /> Add extra exception</button>
<!-- Stranded snapshot has no editable list; a hint points to the
reattach banner above (mirrors the SPA read-only exceptions). -->
<p v-else class="ex__readonly-hint" data-test="ex-readonly-hint">
Reattach a live wildcard to edit these exceptions.
</p>
</section>
</template>
@@ -496,11 +583,18 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
color: var(--wp-text-dim, var(--wp-text3));
margin-bottom: 8px;
}
.ex__list {
/* Long exception lists scroll in place; the label + add button stay put. */
max-height: 34vh;
overflow-y: auto;
}
.ex__row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
/* right padding keeps the trash off the section edge + the list
* scrollbar when the exceptions list scrolls. */
padding: 4px 8px 4px 0;
font: 11px var(--wp-font-mono);
border-bottom: 1px dashed var(--wp-border-soft, var(--wp-border));
}
@@ -510,6 +604,28 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
border-left: 2px solid var(--wp-status-modified, #fb923c);
padding-left: 6px;
}
/* ── Per-row mode accent — left bar + faint wash keyed to the row's
* effective mode, mirroring the SPA ConstraintEditor `.cn-ex-row`.
* Library rows only: the `--extra` orange accent owns its border-left,
* so its row keeps the "instance-only" cue. The `allow`/neutral case
* intentionally has no accent (no rule worth flagging). */
.ex__row:not(.ex__row--extra) {
border-left: 3px solid transparent;
padding-left: 6px;
}
.ex__row--boost:not(.ex__row--extra) {
border-left-color: color-mix(in srgb, var(--wp-success, #22c55e) 60%, transparent);
background: color-mix(in srgb, var(--wp-success, #22c55e) 5%, transparent);
}
.ex__row--reduce:not(.ex__row--extra) {
border-left-color: color-mix(in srgb, var(--wp-warn, #f97316) 60%, transparent);
background: color-mix(in srgb, var(--wp-warn, #f97316) 5%, transparent);
}
.ex__row--exclude:not(.ex__row--extra) {
border-left-color: color-mix(in srgb, var(--wp-danger, #ef4444) 60%, transparent);
background: color-mix(in srgb, var(--wp-danger, #ef4444) 5%, transparent);
}
.ex__pair {
flex: 1;
display: inline-flex;
@@ -592,6 +708,9 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
opacity: 0.5;
}
.ex__mode-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: 1px solid var(--wp-border);
color: var(--wp-text);
@@ -600,6 +719,22 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
white-space: nowrap;
}
/* Glyph mirrors the matrix cell icons (↑ ↓ × ·) — mono so it lines up. */
.ex__mode-glyph {
font-family: var(--wp-font-mono, monospace);
font-size: 11px;
line-height: 1;
}
/* Read-only mode chip (stranded) — a static span, so kill the pointer
* affordance; colour stays via the shared ex__mode-chip--{mode} class. */
.ex__mode-chip--ro { cursor: default; }
/* Static factor text (stranded) — the ×N the editable spinner would
* show, rendered inert. */
.ex__factor-ro {
font: 10px var(--wp-font-mono);
color: var(--wp-text-muted, var(--wp-text2));
}
/* Mode chip palette mirrors the matrix legend: boost = green,
* reduce = orange/warn, exclude = red/danger. Allow uses the accent
@@ -675,17 +810,17 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: 20px;
height: 20px;
padding: 0;
background: color-mix(in srgb, var(--wp-text) 3%, transparent);
border: 1px solid var(--wp-border);
background: transparent;
border: 0;
border-radius: 4px;
color: var(--wp-text-dim, var(--wp-text3));
cursor: pointer;
font-size: 11px;
flex-shrink: 0;
transition: background 0.12s, border-color 0.12s, color 0.12s;
transition: background 0.12s, color 0.12s;
}
.ex__trash:hover {
background: color-mix(in srgb, var(--wp-danger, #e05252) 18%, transparent);
@@ -714,4 +849,9 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
color: var(--wp-accent-text, var(--wp-text));
}
.ex__add-extra .pi { font-size: 10px; }
.ex__readonly-hint {
margin: 8px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -355,6 +355,66 @@ describe("constraint MatrixSection — legacy migration", () => {
});
});
// ── Stranded (read-only) matrix, mirroring the SPA ConstraintMatrix ─
//
// When the parent modal reports a dangling source/target wildcard
// (`stranded`), the grid becomes a read-only snapshot of the configured
// rules: cells are inert (no popover opens), the section root carries a
// `mx--readonly` modifier (dashed frame + muted cells via CSS), and a
// read-only lock affordance replaces the click-to-edit interaction.
describe("constraint MatrixSection — stranded read-only", () => {
it("marks the section read-only and shows a lock affordance when stranded", () => {
const w = mount(MatrixSection, {
props: {
module: makeModule(),
sourceSubs: SOURCE_SUBS,
targetSubs: TARGET_SUBS,
stranded: true,
},
});
expect(w.find('[data-test="mx-section"]').classes()).toContain("mx--readonly");
expect(w.find('[data-test="mx-readonly-lock"]').exists()).toBe(true);
});
it("cells are inert — clicking does NOT open the rule popover when stranded", async () => {
const w = mount(MatrixSection, {
props: {
module: makeModule(),
sourceSubs: SOURCE_SUBS,
targetSubs: TARGET_SUBS,
stranded: true,
},
});
await w.find('[data-test="mx-cell-red-cotton"]').trigger("click");
expect(w.findComponent({ name: "CellRulePopover" }).exists()).toBe(false);
expect(w.find('[data-test="mx-cell-red-cotton"]').classes()).not.toContain("open");
});
it("cells keep their s-{state} hue class so the snapshot stays legible", () => {
const w = mount(MatrixSection, {
props: {
module: makeModule(),
sourceSubs: SOURCE_SUBS,
targetSubs: TARGET_SUBS,
stranded: true,
},
});
// Same mode hues as the editable grid (muting is a CSS concern).
expect(w.find('[data-test="mx-cell-red-silk"]').classes()).toContain("s-boost");
expect(w.find('[data-test="mx-cell-blue-silk"]').classes()).toContain("s-reduce");
});
it("control: a healthy (non-stranded) matrix stays interactive", async () => {
const w = mount(MatrixSection, {
props: { module: makeModule(), sourceSubs: SOURCE_SUBS, targetSubs: TARGET_SUBS },
});
expect(w.find('[data-test="mx-section"]').classes()).not.toContain("mx--readonly");
expect(w.find('[data-test="mx-readonly-lock"]').exists()).toBe(false);
await w.find('[data-test="mx-cell-red-cotton"]').trigger("click");
expect(w.findComponent({ name: "CellRulePopover" }).exists()).toBe(true);
});
});
describe("constraint MatrixSection — keyboard", () => {
it("Escape closes the popover", async () => {
const w = mount(MatrixSection, {
@@ -33,8 +33,14 @@ const props = withDefaults(
targetSubs: readonly string[];
sourceName?: string;
targetName?: string;
/** Read-only recovery view: the source/target wildcard was deleted, so
* the configured rules are shown for understanding only. Cells don't
* open the rule popover; the grid frame + cells mute to a snapshot.
* Reattach a live wildcard (banner above) to edit. Mirrors the SPA
* ConstraintMatrix `readonly` prop / `wp-mx--readonly` treatment. */
stranded?: boolean;
}>(),
{ sourceName: "", targetName: "" },
{ sourceName: "", targetName: "", stranded: false },
);
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
@@ -128,7 +134,10 @@ function cellFactorText(src: string, tgt: string): string {
function cellAriaLabel(src: string, tgt: string): string {
const s = effectiveState(src, tgt);
const factor = (s === "boost" || s === "reduce") ? ` ×${effectiveFactor(src, tgt).toFixed(1)}` : "";
return `Rule: ${src} → ${tgt}, current state ${s}${factor}. Click to edit.`;
// Stranded → the grid is a read-only snapshot, so drop the "Click to
// edit" affordance from the label (the cell is inert).
const suffix = props.stranded ? " (read-only)" : ". Click to edit.";
return `Rule: ${src} → ${tgt}, current state ${s}${factor}${suffix}`;
}
// ── Popover ────────────────────────────────────────────────────
@@ -170,6 +179,8 @@ function cssEscape(s: string): string {
}
function onCellClick(src: string, tgt: string): void {
// Read-only recovery view (deleted source/target): cells are inert.
if (props.stranded) return;
const open = openPopover.value;
if (open && open.src === src && open.tgt === tgt) {
openPopover.value = null;
@@ -302,8 +313,16 @@ onBeforeUnmount(() => {
</script>
<template>
<section class="mx" data-test="mx-section">
<div class="mx__label">Rule matrix</div>
<section class="mx" :class="{ 'mx--readonly': stranded }" data-test="mx-section">
<div class="mx__label-row">
<div class="mx__label">Rule matrix</div>
<!-- Stranded ref → the grid is a read-only snapshot, so a lock pill
replaces the click-to-edit interaction. Reattaching a live
wildcard (banner above) is the only way back to editing. -->
<span v-if="stranded" class="mx__lock-pill" data-test="mx-readonly-lock">
<i class="pi pi-lock" aria-hidden="true" /> Read-only
</span>
</div>
<div class="mx-axes">
<span class="mx-axis mx-axis--src" data-test="mx-axis-src">
@@ -331,6 +350,10 @@ onBeforeUnmount(() => {
<tr v-for="s in sourceSubs" :key="s">
<th class="mx-th-row">{{ s }}</th>
<td v-for="t in targetSubs" :key="`${s}-${t}`" class="mx-td">
<!-- role/tabindex stay static even when stranded (mirrors the
SPA ConstraintMatrix): the cell is still a labelled
snapshot a screen-reader can land on; `onCellClick` early-
returns so it's inert. -->
<div
:class="cellClasses(s, t)"
:data-test="`mx-cell-${s}-${t}`"
@@ -350,6 +373,10 @@ onBeforeUnmount(() => {
</table>
</div>
<p v-if="stranded" class="mx__readonly-hint" data-test="mx-readonly-hint">
Reattach a live wildcard to edit these rules.
</p>
<MatrixLegend />
<!-- Popover lives in a body-level portal so its hover state stays
@@ -390,13 +417,34 @@ onBeforeUnmount(() => {
background: var(--wp-bg2);
border-bottom: 1px solid var(--wp-border-soft, var(--wp-border));
}
.mx__label-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.mx__label {
font: 600 9px var(--wp-font-sans);
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--wp-text-dim, var(--wp-text3));
margin-bottom: 8px;
}
/* Read-only lock pill — mirrors the SPA ConstraintEditor `.cn-lock-pill`
* (Rule matrix actions when stranded). */
.mx__lock-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: var(--wp-radius, 6px);
font: 600 9px var(--wp-font-sans);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--wp-text-dim, var(--wp-text3));
background: color-mix(in srgb, var(--wp-text) 4%, transparent);
border: 1px solid color-mix(in srgb, var(--wp-text) 10%, transparent);
}
.mx__lock-pill .pi { font-size: 9px; }
/* ── Axis tags ─────────────────────────────────────────────── */
.mx-axes {
@@ -523,6 +571,42 @@ onBeforeUnmount(() => {
.glyph { font-size: 14px; line-height: 1; }
.factor { font-size: 11px; font-weight: 700; }
/* ── Read-only recovery view (deleted source/target) ───────────
* Mirrors the SPA ConstraintMatrix `wp-mx--readonly`. Cells are
* inert: no pointer, no hover lift. The grid frame turns DASHED and
* the colored cells drop to ~1/3 of their editable intensity (8% bg /
* 18% border, text softened toward the dim grey) so the snapshot reads
* as "look, don't touch" while the boost/reduce/exclude hues stay
* legible. */
.mx--readonly .mx-grid {
border: 1px dashed color-mix(in srgb, var(--wp-text) 16%, transparent);
}
.mx--readonly .mx-cell { cursor: default; }
.mx--readonly .mx-cell:hover {
transform: none;
box-shadow: none;
}
.mx--readonly .mx-cell.s-boost {
background: color-mix(in srgb, var(--wp-success, #22c55e) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--wp-success, #22c55e) 18%, transparent);
color: color-mix(in srgb, var(--wp-success, #22c55e) 70%, var(--wp-text-dim));
}
.mx--readonly .mx-cell.s-reduce {
background: color-mix(in srgb, var(--wp-warn, #f97316) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--wp-warn, #f97316) 18%, transparent);
color: color-mix(in srgb, var(--wp-warn, #f97316) 70%, var(--wp-text-dim));
}
.mx--readonly .mx-cell.s-exclude {
background: color-mix(in srgb, var(--wp-danger, #ef4444) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--wp-danger, #ef4444) 18%, transparent);
color: color-mix(in srgb, var(--wp-danger, #ef4444) 70%, var(--wp-text-dim));
}
.mx__readonly-hint {
margin: 8px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
/* Popover is teleported to body so positioning is handled by inline
* styles on the anchor wrapper. Nothing extra needed here. */
</style>
@@ -0,0 +1,208 @@
// Constraint TargetReachSection — reach selector for the edited
// constraint's `target_select`. Segmented control first | next N | all |
// pick; `next` reveals a min-1 stepper, `pick` reveals a checklist of
// reachable downstream target occurrences (direct + nested-via-carrier)
// built from `chainModules`. Ticking a row toggles its `picks` entry.
//
// Pick identity MUST match what the engine + computePairingsFull match
// on: a direct occurrence is its target instance row's `rowKey`; a nested
// occurrence is `(carrier rowKey, option_id)`.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import TargetReachSection from "./TargetReachSection.vue";
import type { ChainModule, TargetSelect } from "../../../../../extension/constraint-pairs";
const TARGET_UUID = "ffffffff";
/** Minimal chain: a constraint, a direct downstream target instance, and
* a downstream carrier whose option transitively refs the same target.
* Ordered execution-order — the constraint sits first so both the
* direct + carrier rows are DOWNSTREAM of it. */
function makeChain(): ChainModule[] {
return [
{
id: "cn0",
rowKey: "1#cn0",
type: "constraint",
payload: { source_wildcard_id: "src0", target_wildcard_id: TARGET_UUID },
displayName: "color-mood",
nodeLabel: "A",
},
{
id: TARGET_UUID,
rowKey: "1#mood-a",
type: "wildcard",
payload: { var_binding: "mood", options: [{ id: "m0", value: "calm" }] },
displayName: "mood",
nodeLabel: "A",
},
{
id: "carrier0",
rowKey: "2#backdrop-b",
type: "wildcard",
payload: { options: [{ id: "opt_a", value: `@{${TARGET_UUID}}` }] },
displayName: "backdrop",
nodeLabel: "B",
},
];
}
const CONSTRAINT_UID = "1#cn0";
/** Last emitted value (project TS lib predates Array.prototype.at). */
function last(xs: TargetSelect[]): TargetSelect | undefined {
return xs[xs.length - 1];
}
function mountSection(
modelValue: TargetSelect,
opts: Partial<{ chainModules: ChainModule[]; targetName: string }> = {},
) {
const updates: TargetSelect[] = [];
const w = mount(TargetReachSection, {
props: {
modelValue,
constraintUid: CONSTRAINT_UID,
targetWildcardId: TARGET_UUID,
targetName: opts.targetName ?? "test-target",
chainModules: opts.chainModules ?? makeChain(),
// Reflect emitted values back into the prop — the modal owns
// `target_select` state, so the section re-renders from the new
// model on every change (segmented mode reveals stepper/checklist).
"onUpdate:modelValue": (v: TargetSelect) => {
updates.push(v);
void w.setProps({ modelValue: v });
},
},
});
return { w, updates };
}
describe("TargetReachSection — segmented control", () => {
it("emits {mode:'next', count:2} after clicking 'next N' then setting the stepper to 2", async () => {
const { w, updates } = mountSection({ mode: "all" });
await w.find('[data-test="reach-mode-next"]').trigger("click");
// Entering next defaults count:1.
expect(last(updates)).toEqual({ mode: "next", count: 1 });
await w.vm.$nextTick();
const stepper = w.find<HTMLInputElement>('[data-test="reach-count"]');
expect(stepper.exists()).toBe(true);
await stepper.setValue("2");
expect(last(updates)).toEqual({ mode: "next", count: 2 });
});
it("stepper ▲ button increments the count", async () => {
const { w, updates } = mountSection({ mode: "next", count: 2 });
await w.find('[data-test="reach-count-up"]').trigger("click");
expect(last(updates)).toEqual({ mode: "next", count: 3 });
});
it("stepper ▼ button decrements the count", async () => {
const { w, updates } = mountSection({ mode: "next", count: 3 });
await w.find('[data-test="reach-count-down"]').trigger("click");
expect(last(updates)).toEqual({ mode: "next", count: 2 });
});
it("stepper ▼ button is disabled at the minimum (count 1)", () => {
const { w } = mountSection({ mode: "next", count: 1 });
const down = w.find('[data-test="reach-count-down"]');
expect(down.exists()).toBe(true);
expect(down.attributes("disabled")).toBeDefined();
});
it("emits {mode:'all'} when 'all' clicked", async () => {
const { w, updates } = mountSection({ mode: "next", count: 3 });
await w.find('[data-test="reach-mode-all"]').trigger("click");
expect(last(updates)).toEqual({ mode: "all" });
});
it("default all → no checklist + no stepper rendered (minimal)", () => {
const { w } = mountSection({ mode: "all" });
expect(w.find('[data-test="reach-count"]').exists()).toBe(false);
expect(w.find('[data-test="reach-picklist"]').exists()).toBe(false);
});
});
describe("TargetReachSection — pick checklist", () => {
it("pick mode lists reachable instances (direct + nested), nested tagged + showing node label; ticking emits the right picks entry", async () => {
const { w, updates } = mountSection({ mode: "pick", picks: [] });
const list = w.find('[data-test="reach-picklist"]');
expect(list.exists()).toBe(true);
// Direct row for the mood instance + nested row for the backdrop carrier.
const directRow = w.find('[data-test="reach-pick-1#mood-a"]');
const nestedRow = w.find('[data-test="reach-pick-2#backdrop-b::opt_a"]');
expect(directRow.exists()).toBe(true);
expect(nestedRow.exists()).toBe(true);
// Nested row carries the `nested` tag + the carrier's node label (B).
expect(nestedRow.text().toLowerCase()).toContain("nested");
expect(nestedRow.text()).toContain("B");
// Direct row tagged `direct`.
expect(directRow.text().toLowerCase()).toContain("direct");
// Tick the direct row → picks gains the direct entry keyed by the
// BARE _uid (engine identity), NOT the rowKey. The row's data-test
// still uses the full rowKey (`1#mood-a`); the persisted uid strips
// the `1#` node prefix → `mood-a`.
await directRow.find(".wp-check").trigger("click");
expect(last(updates)).toEqual({
mode: "pick",
picks: [{ kind: "direct", uid: "mood-a" }],
});
});
it("nested row labels the @TARGET ref (display name) hosted in the carrier (display name, no @)", () => {
const { w } = mountSection({ mode: "pick", picks: [] }, { targetName: "test-target" });
const nestedRow = w.find('[data-test="reach-pick-2#backdrop-b::opt_a"]');
expect(nestedRow.exists()).toBe(true);
const txt = nestedRow.text();
// The nested ref is `@{target}` — the `@` belongs to the TARGET, shown by
// display name: "@test-target".
expect(txt).toContain("@test-target");
// Host carrier named by DISPLAY NAME, NOT @-prefixed (you don't @ the host).
expect(txt).toContain("in backdrop");
expect(txt).not.toContain("@backdrop");
expect(txt).not.toContain("@subject");
});
it("ticking a nested row emits a nested picks entry keyed by (bare carrier _uid, option_id)", async () => {
const { w, updates } = mountSection({ mode: "pick", picks: [] });
await w.find('[data-test="reach-pick-2#backdrop-b::opt_a"] .wp-check').trigger("click");
// carrier_uid is the BARE _uid (`backdrop-b`), stripped of the `2#`
// node prefix — the engine's nested carrier_ctx stamps bare uids.
expect(last(updates)).toEqual({
mode: "pick",
picks: [{ kind: "nested", carrier_uid: "backdrop-b", option_id: "opt_a" }],
});
});
it("un-ticking a picked row removes its entry (bare-uid pick matches the row)", async () => {
const { w, updates } = mountSection({
mode: "pick",
picks: [{ kind: "direct", uid: "mood-a" }],
});
await w.find('[data-test="reach-pick-1#mood-a"] .wp-check').trigger("click");
expect(last(updates)).toEqual({ mode: "pick", picks: [] });
});
it("empty chainModules → empty checklist + a hint, no crash", () => {
const { w } = mountSection({ mode: "pick", picks: [] }, { chainModules: [] });
expect(w.find('[data-test="reach-pick-empty"]').exists()).toBe(true);
expect(w.find('[data-test="reach-pick-1#mood-a"]').exists()).toBe(false);
});
it("pick mode lists a derivation-nested occurrence keyed by branch key", () => {
const deriv: ChainModule = {
id: "d1aaaaaa", rowKey: "2#deriv-d", type: "derivation", displayName: "my deriv", nodeLabel: "B",
payload: { rules: [{ id: "r1", branches: [{ action: { value: `@{${TARGET_UUID}}` } }] }] },
};
// Derivation is downstream of the constraint row — append after makeChain().
const { w } = mountSection({ mode: "pick", picks: [] }, { chainModules: [...makeChain(), deriv], targetName: "test-target" });
const row = w.find('[data-test="reach-pick-2#deriv-d::r1:0"]');
expect(row.exists()).toBe(true);
expect(row.text()).toContain("@test-target");
expect(row.text()).toContain("in my deriv");
});
});
@@ -0,0 +1,535 @@
<script setup lang="ts">
/**
* Constraint TargetReachSection — the reach selector for the edited
* constraint's `target_select`. Mirrors the engine reach model
* (`engine/modules/_constraints.py::apply_constraints_for_target`):
* each constraint covers SOME subset of the downstream instances of its
* `target_wildcard_id`. Modes:
* - `all` — every downstream target occurrence (default; minimal UI).
* - `first` — only the first encounter.
* - `next N` — the first N encounters (stepper, min 1).
* - `pick` — an explicit checklist of reachable occurrences.
*
* A pick names an occurrence the SAME way the engine + the static
* `computePairingsFull` layer do:
* - direct: a downstream `wildcard` row whose `id === targetWildcardId`,
* named by its row `rowKey` (the per-instance uid the engine matches).
* - nested: a downstream carrier whose option value transitively refs
* `@{targetWildcardId}`, named by `(carrier rowKey, option_id)`.
*
* Library-defining edits stay in the SPA; this is a per-instance override
* surfaced on the modal. Writes flow up via `update:modelValue` — the
* modal threads the new `TargetSelect` into `module.instance.target_select`.
*/
import { computed } from "vue";
import {
carrierOptionIdsFor,
type ChainModule,
type TargetSelect,
} from "../../../../../extension/constraint-pairs";
import WpCheck from "../../../../shared/WpCheck.vue";
type PickEntry = NonNullable<TargetSelect["picks"]>[number];
/** Strip the badge-layer `${nodeId}#` prefix off a rowKey, yielding the
* bare per-instance `_uid`. The persisted `pick` identity MUST be the
* bare `_uid` because the ENGINE matches `firing_uid =
* ctx["__wp_current_module_uid__"]` (the bare `_uid`, no node prefix —
* see engine/pipeline.py + _constraints.py::_occurrence_matches). The
* `nodeId#` prefix is a graph-wide-uniqueness convenience for the badge
* map only; `_uid` is globally unique on its own. Keeping the full
* rowKey for display keys, persisting the bare uid for engine match. */
const bareUid = (rk: string): string => {
const i = rk.indexOf("#");
return i >= 0 ? rk.slice(i + 1) : rk;
};
const props = withDefaults(
defineProps<{
/** Current `target_select` (effective: instance override or library,
* defaulting to `{mode:"all"}`). */
modelValue?: TargetSelect;
/** Flattened cross-node execution chain — the pick checklist is the
* reachable downstream target occurrences within it. Optional;
* empty/undefined renders an empty checklist (no crash). */
chainModules?: ChainModule[];
/** The edited constraint's row id (rowKey/`_uid`) — used to find its
* position in the chain so the walk only collects DOWNSTREAM rows. */
constraintUid: string;
/** The constraint's `target_wildcard_id` — the uuid direct rows match
* on + nested carriers transitively ref. */
targetWildcardId: string;
/** Display name of the target wildcard (the thing being constrained).
* Used to label nested-pick rows as "<target> via @<carrier>" so the
* user sees WHAT is reached, not the carrier name twice. */
targetName?: string;
}>(),
{ modelValue: () => ({ mode: "all" }), chainModules: () => [], targetName: "" },
);
const emit = defineEmits<{ "update:modelValue": [next: TargetSelect] }>();
const mode = computed<TargetSelect["mode"]>(() => props.modelValue?.mode ?? "all");
const count = computed<number>(() => {
const c = Number(props.modelValue?.count);
return Number.isFinite(c) && c >= 1 ? Math.floor(c) : 1;
});
const picks = computed<PickEntry[]>(() => {
const p = props.modelValue?.picks;
return Array.isArray(p) ? p : [];
});
/** Target wildcard's display name for nested-pick labels; "target" when the
* modal didn't supply one (headless / legacy mounts). */
const targetLabel = computed<string>(() => props.targetName?.trim() || "target");
const MODES: Array<{ key: TargetSelect["mode"]; label: string }> = [
{ key: "first", label: "first" },
{ key: "next", label: "next N" },
{ key: "all", label: "all" },
{ key: "pick", label: "pick" },
];
/** Switch reach mode. Entering `next` defaults `count:1`; entering `pick`
* defaults `picks:[]`. `first`/`all` carry no extra fields. Preserves the
* current count/picks when re-entering the same mode is a no-op (we only
* emit a fresh selector). */
function setMode(next: TargetSelect["mode"]): void {
if (next === mode.value) return;
switch (next) {
case "next":
emit("update:modelValue", { mode: "next", count: count.value || 1 });
break;
case "pick":
emit("update:modelValue", { mode: "pick", picks: picks.value });
break;
default:
emit("update:modelValue", { mode: next });
}
}
function onCountInput(ev: Event): void {
const raw = Number((ev.target as HTMLInputElement).value);
const n = Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 1;
emit("update:modelValue", { mode: "next", count: n });
}
/** Custom stepper ▲/▼. The native number-input spinners are suppressed
* (they only show on hover and render inconsistently inside ComfyUI's
* embedded DOM), so explicit buttons give the always-visible arrows the
* user asked for. Clamped at the engine minimum of 1 (a `next 0` reach
* covers nothing). */
function bumpCount(delta: number): void {
const next = Math.max(1, count.value + delta);
emit("update:modelValue", { mode: "next", count: next });
}
// ── Pick checklist ──────────────────────────────────────────────
//
// Build a uuid → option-value-strings lookup from the chain (first-seen
// wins, carriers only), EXACTLY as computePairingsFull does, so the
// transitive carrier scan matches what the badge layer + engine resolve.
const CARRIER_TYPES = new Set(["wildcard", "derivation"]);
const lookup = computed<(uuid: string) => string[]>(() => {
const byUuid = new Map<string, string[]>();
for (const m of props.chainModules) {
if (!CARRIER_TYPES.has(m.type) || byUuid.has(m.id)) continue;
const values: string[] = [];
if (m.type === "derivation") {
// Derivation action values can host @{uuid} refs (engine stamps
// branchKey as the option_id). Mirrors refBearingEntries in
// constraint-pairs.ts — but here we only need the VALUE strings
// for the transitive reachPath lookup, not the ids.
const p = m.payload as {
rules?: Array<{
branches?: Array<{ action?: { value?: string } }>;
else?: { action?: { value?: string } } | null;
}>;
};
for (const r of Array.isArray(p.rules) ? p.rules : []) {
for (const b of Array.isArray(r?.branches) ? r.branches : []) {
const v = b?.action?.value;
if (typeof v === "string") values.push(v);
}
const elseV = r?.else?.action?.value;
if (typeof elseV === "string") values.push(elseV);
}
} else {
const opts = (m.payload as { options?: Array<{ value?: string }> }).options;
if (!Array.isArray(opts)) continue;
for (const o of opts) if (typeof o?.value === "string") values.push(o.value);
}
byUuid.set(m.id, values);
}
return (uuid: string) => byUuid.get(uuid) ?? [];
});
interface PickRow {
/** Stable key + data-test suffix. */
key: string;
entry: PickEntry;
name: string;
kind: "direct" | "nested";
nodeLabel?: string;
/** Per-uuid duplicate disambiguator, e.g. "2 of 3" — omitted when the
* occurrence is unique among its kind+name siblings. */
ordinal?: string;
}
/** Walk the chain DOWNSTREAM of the edited constraint, collecting every
* reachable occurrence of `targetWildcardId`: direct top-level instances
* + transitive carriers. Identity matches the engine /
* computePairingsFull (rowKey for direct; carrier rowKey + option_id for
* nested). Empty when the chain is empty or the constraint isn't found. */
const pickRows = computed<PickRow[]>(() => {
const chain = props.chainModules;
const start = chain.findIndex((m) => m.rowKey === props.constraintUid);
if (start < 0) return [];
const rows: PickRow[] = [];
const tgt = props.targetWildcardId;
for (let j = start + 1; j < chain.length; j++) {
const cm = chain[j];
if (cm.type === "wildcard" && cm.id === tgt) {
// Direct top-level instance.
rows.push({
// `key`/`data-test` keep the full rowKey (display-only, graph-
// wide unique); the persisted pick `uid` is the BARE _uid the
// engine matches (see bareUid).
key: cm.rowKey,
entry: { kind: "direct", uid: bareUid(cm.rowKey) },
name: cm.displayName?.trim() || cm.id,
kind: "direct",
nodeLabel: cm.nodeLabel,
});
continue;
}
// Transitive carrier — one row per matching option id.
const match = carrierOptionIdsFor(cm, tgt, lookup.value);
for (const optionId of match.optionIds) {
rows.push({
// `key`/`data-test` keep the full rowKey (display-only); the
// persisted `carrier_uid` is the BARE _uid the engine's nested
// carrier_ctx stamps (wildcard_handler stamps bare `_uid`).
key: `${cm.rowKey}::${optionId}`,
entry: { kind: "nested", carrier_uid: bareUid(cm.rowKey), option_id: optionId },
// The nested ref IS the TARGET (`@{target}`), hosted inside this
// carrier's option. Name it "@<target-display-name> in <carrier-
// display-name>" — the `@` belongs to the referenced TARGET, NOT the
// host; both shown by display name (not binding). The host carrier
// here happens to be the constraint's source wildcard.
name: `@${targetLabel.value} in ${cm.displayName?.trim() || cm.id}`,
kind: "nested",
nodeLabel: cm.nodeLabel,
});
}
}
// Duplicate disambiguator: when several rows share kind+name (two
// instances of the same target wildcard downstream), stamp "#N of M".
const groups = new Map<string, PickRow[]>();
for (const r of rows) {
const g = `${r.kind}:${r.name}`;
const arr = groups.get(g);
if (arr) arr.push(r);
else groups.set(g, [r]);
}
for (const arr of groups.values()) {
if (arr.length < 2) continue;
arr.forEach((r, i) => { r.ordinal = `${i + 1} of ${arr.length}`; });
}
return rows;
});
function samePick(a: PickEntry, b: PickEntry): boolean {
if (a.kind === "direct" && b.kind === "direct") return a.uid === b.uid;
if (a.kind === "nested" && b.kind === "nested") {
return a.carrier_uid === b.carrier_uid && a.option_id === b.option_id;
}
return false;
}
function isPicked(entry: PickEntry): boolean {
return picks.value.some((p) => samePick(p, entry));
}
function togglePick(entry: PickEntry): void {
const cur = picks.value;
const next = isPicked(entry)
? cur.filter((p) => !samePick(p, entry))
: [...cur, entry];
emit("update:modelValue", { mode: "pick", picks: next });
}
// ── Consequence hint ────────────────────────────────────────────
const reachCount = computed(() => pickRows.value.length);
const hint = computed<string>(() => {
const n = reachCount.value;
const tgtN = `${n} downstream ${n === 1 ? "target" : "targets"}`;
switch (mode.value) {
case "all":
return `→ re-weights all ${tgtN}`;
case "first":
return n > 0 ? "→ re-weights the first downstream target" : "→ no downstream target to cover";
case "next": {
const k = count.value;
return `→ re-weights the first ${k} downstream ${k === 1 ? "target" : "targets"}`;
}
case "pick": {
const sel = picks.value.length;
return `→ re-weights ${sel} picked ${sel === 1 ? "target" : "targets"}`;
}
default:
return "";
}
});
</script>
<template>
<section class="rh" data-test="reach-section">
<div class="rh__label">Target reach</div>
<div class="rh-seg" role="group" aria-label="Target reach mode">
<button
v-for="m in MODES"
:key="m.key"
type="button"
class="rh-btn"
:class="{ active: mode === m.key }"
:aria-pressed="mode === m.key"
:data-test="`reach-mode-${m.key}`"
@click="setMode(m.key)"
>{{ m.label }}</button>
<span v-if="mode === 'next'" class="rh-step">
<input
class="rh-step__field"
type="number"
min="1"
step="1"
inputmode="numeric"
:value="count"
aria-label="Number of downstream targets"
data-test="reach-count"
@input="onCountInput"
/>
<span class="rh-step__arrows">
<button
type="button"
class="rh-step__arrow"
aria-label="Increase count"
data-test="reach-count-up"
@click="bumpCount(1)"
>▲</button>
<button
type="button"
class="rh-step__arrow"
aria-label="Decrease count"
:disabled="count <= 1"
data-test="reach-count-down"
@click="bumpCount(-1)"
>▼</button>
</span>
</span>
</div>
<div v-if="mode === 'pick'" class="rh-pick" data-test="reach-picklist">
<div
v-if="pickRows.length === 0"
class="rh-pick__empty"
data-test="reach-pick-empty"
>No reachable target instances — use first / next / all.</div>
<!-- WpCheck is the interactive control (own role/keyboard + aria-
label); the row is presentational so the checkbox is the single
focus/click target — no redundant row-level handler. -->
<div
v-for="row in pickRows"
:key="row.key"
class="rh-pick__row"
:data-test="`reach-pick-${row.key}`"
>
<WpCheck
:model-value="isPicked(row.entry)"
:aria-label="`Cover ${row.name}`"
@update:model-value="togglePick(row.entry)"
/>
<span class="rh-pick__name">{{ row.name }}</span>
<span class="rh-pick__tag" :class="`rh-pick__tag--${row.kind}`">{{ row.kind }}</span>
<span v-if="row.nodeLabel" class="rh-pick__node">{{ row.nodeLabel }}</span>
<span v-if="row.ordinal" class="rh-pick__ord">#{{ row.ordinal }}</span>
</div>
</div>
<div class="rh__hint" data-test="reach-hint">{{ hint }}</div>
</section>
</template>
<style scoped>
.rh {
padding: 12px 16px;
/* Subtle neutral demarcation. The earlier accent wash + inset rail read
as too loud; a faint text-tinted panel (same idiom as `.rh-seg` /
row hover below) sets the section apart on both light + dark without
shouting. */
background: color-mix(in srgb, var(--wp-text) 3%, transparent);
border-bottom: 1px solid var(--wp-border-soft, var(--wp-border));
}
.rh__label {
font: 600 9px var(--wp-font-sans);
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--wp-text-dim, var(--wp-text3));
margin-bottom: 8px;
}
/* ── Segmented control — mirrors CellRulePopover `.pop-btn` group. ── */
.rh-seg {
display: inline-flex;
align-items: stretch;
gap: 0;
border: 1px solid var(--wp-border);
border-radius: 4px;
overflow: hidden;
background: color-mix(in srgb, var(--wp-text) 2%, transparent);
}
.rh-btn {
padding: 6px 12px;
border: 0;
border-right: 1px solid var(--wp-border);
background: transparent;
color: var(--wp-text-muted, var(--wp-text2));
font: 600 10px var(--wp-font-sans);
text-transform: uppercase;
letter-spacing: 0.06em;
cursor: pointer;
}
.rh-btn:last-of-type { border-right: 0; }
.rh-btn:hover { color: var(--wp-text); background: color-mix(in srgb, var(--wp-text) 6%, transparent); }
.rh-btn.active {
/* White-on-accent, matching the modal footer's `.wp-cnm__btn--primary`
* (the accent is a saturated purple in both light + dark themes, so a
* hard white reads on both — `--wp-accent-on` isn't reliably defined). */
background: var(--wp-accent);
color: #fff;
}
/* Stepper sits flush after the segmented group: number field + a stacked
▲/▼ arrow column. */
.rh-step {
display: inline-flex;
align-items: stretch;
border-left: 1px solid var(--wp-border);
background: var(--wp-bg-deep, var(--wp-bg));
}
.rh-step__field {
width: 38px;
background: transparent;
border: 0;
padding: 0 6px;
font: 600 12px var(--wp-font-mono);
color: var(--wp-text);
text-align: center;
-moz-appearance: textfield;
}
/* Native spinners are suppressed in favour of the custom always-visible
▲/▼ buttons (native arrows only appear on hover + render inconsistently
in ComfyUI's embedded DOM). */
.rh-step__field::-webkit-outer-spin-button,
.rh-step__field::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.rh-step__field:focus { outline: none; }
.rh-step__arrows {
display: flex;
flex-direction: column;
border-left: 1px solid var(--wp-border);
}
.rh-step__arrow {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
padding: 0;
border: 0;
background: transparent;
color: var(--wp-text-muted, var(--wp-text2));
font-size: 7px;
line-height: 1;
cursor: pointer;
}
.rh-step__arrow + .rh-step__arrow { border-top: 1px solid var(--wp-border); }
.rh-step__arrow:hover:not(:disabled) {
color: var(--wp-text);
background: color-mix(in srgb, var(--wp-text) 8%, transparent);
}
.rh-step__arrow:disabled { opacity: 0.3; cursor: default; }
/* ── Pick checklist ─────────────────────────────────────────────── */
.rh-pick {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 180px;
overflow-y: auto;
padding: 6px;
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: 4px;
}
.rh-pick__empty {
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
padding: 4px 2px;
}
.rh-pick__row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 3px;
cursor: pointer;
font: 11px var(--wp-font-sans);
color: var(--wp-text);
}
.rh-pick__row:hover { background: color-mix(in srgb, var(--wp-text) 5%, transparent); }
.rh-pick__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--wp-font-mono);
}
.rh-pick__tag {
font: 600 8px var(--wp-font-sans);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 1px 6px;
border-radius: 999px;
}
.rh-pick__tag--direct {
color: var(--wp-constraint-target-text, var(--wp-text-muted, var(--wp-text2)));
background: color-mix(in oklab, var(--wp-constraint-target, var(--wp-accent)) 18%, transparent);
}
.rh-pick__tag--nested {
color: var(--wp-accent-text, var(--wp-text-muted, var(--wp-text2)));
background: color-mix(in oklab, var(--wp-accent) 18%, transparent);
}
.rh-pick__node {
font: 600 9px var(--wp-font-mono);
color: var(--wp-text-dim, var(--wp-text3));
border: 1px solid var(--wp-border);
border-radius: 3px;
padding: 0 5px;
}
.rh-pick__ord {
font: 9px var(--wp-font-mono);
color: var(--wp-text-dim, var(--wp-text3));
}
.rh__hint {
margin-top: 8px;
font: 10px var(--wp-font-mono);
color: var(--wp-text-muted, var(--wp-text2));
}
</style>
@@ -4,7 +4,7 @@
// reset overrides + drift kebab + cancel/save. Header icon: pi-arrow-right-arrow-left
// (same icon picker.ts uses for derivation kind).
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount } from "@vue/test-utils";
import DerivationInstanceModal from "./DerivationInstanceModal.vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
@@ -112,3 +112,131 @@ describe("DerivationInstanceModal", () => {
expect(w.emitted("clear-all-overrides")).toBeTruthy();
});
});
// ── Library-catalog fetch + ref-data wiring (@{} parity) ──────────────
//
// On open the modal fetches /wp/api/modules ONCE, builds the wildcard
// `@{}` ref-data via buildWildcardRefData, and forwards it (+ the
// $var suggestion list from upstream/sibling vars) down to RulesSection
// so the canvas reaches full parity with the SPA derivation editor.
// Mirrors the fetch pattern in ModulePickerModal (`{ items: ModuleRow[] }`).
interface FakeRow {
id: string;
type: string;
name: string;
payload?: Record<string, unknown>;
}
const LIBRARY_ROWS: FakeRow[] = [
// This derivation module itself — must be EXCLUDED from refSuggestions.
{ id: "dv012345", type: "derivation", name: "mood-rules", payload: { rules: [] } },
{
id: "aabbccdd",
type: "wildcard",
name: "color",
payload: {
var_binding: "color",
sub_categories: ["warm", "cool"],
options: [
{ id: "o1", value: "red", sub_categories: ["warm"] },
{ id: "o2", value: "blue", sub_categories: ["cool"] },
],
tag_groups: { temp: ["warm", "cool"] },
},
},
{
id: "eeff0011",
type: "wildcard",
name: "outfit",
payload: { var_binding: "outfit", options: [{ id: "o1", value: "tee" }] },
},
// A non-wildcard row — must NOT contribute to refSuggestions.
{ id: "cc001122", type: "combine", name: "combo", payload: { output_var: "x" } },
];
function stubModulesFetch(rows: FakeRow[]): void {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => ({
ok: true,
status: 200,
json: async () =>
url.includes("/wp/api/modules") ? { items: rows, total: rows.length } : {},
})) as unknown as typeof fetch,
);
}
/** Flush the fetch → json() microtask chain + a nextTick so the
* ref-data computed re-evaluates after the catalog populates. */
async function flushFetch(w: ReturnType<typeof mount>): Promise<void> {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
await w.vm.$nextTick();
}
describe("DerivationInstanceModal — @{} ref-data fetch + forwarding", () => {
beforeEach(() => stubModulesFetch(LIBRARY_ROWS));
afterEach(() => vi.unstubAllGlobals());
it("fetches /wp/api/modules once when the modal mounts", async () => {
const w = mount(DerivationInstanceModal, { props: { module: makeModule() } });
await flushFetch(w);
const calls = (globalThis.fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls;
const modulesCalls = calls.filter((c) => String(c[0]).includes("/wp/api/modules"));
expect(modulesCalls.length).toBe(1);
});
it("forwards wildcard refSuggestions (excluding self + non-wildcards) to RulesSection", async () => {
const w = mount(DerivationInstanceModal, { props: { module: makeModule() } });
await flushFetch(w);
const rules = w.findComponent({ name: "RulesSection" });
const refs = rules.props("refSuggestions") as string[];
// Only the two wildcard rows; sorted by display name (color < outfit).
expect(refs).toEqual(["aabbccdd", "eeff0011"]);
// Self id + the combine row are excluded.
expect(refs).not.toContain("dv012345");
expect(refs).not.toContain("cc001122");
});
it("forwards the six buildWildcardRefData maps to RulesSection", async () => {
const w = mount(DerivationInstanceModal, { props: { module: makeModule() } });
await flushFetch(w);
const rules = w.findComponent({ name: "RulesSection" });
const uuidToName = rules.props("uuidToName") as Map<string, string>;
expect(uuidToName.get("aabbccdd")).toBe("color");
expect((rules.props("uuidToSubCategories") as Map<string, string[]>).get("aabbccdd")).toEqual(["warm", "cool"]);
expect((rules.props("uuidToOptionsCount") as Map<string, number>).get("aabbccdd")).toBe(2);
expect((rules.props("uuidToHasNull") as Map<string, boolean>).get("aabbccdd")).toBe(false);
expect(rules.props("uuidToOptionTagSets")).toBeInstanceOf(Map);
expect((rules.props("uuidToTagGroups") as Map<string, Record<string, string[]>>).get("aabbccdd")).toEqual({ temp: ["warm", "cool"] });
});
it("forwards upstream + sibling vars as the $var suggestion list", async () => {
const w = mount(DerivationInstanceModal, {
props: { module: makeModule(), upstreamVars: ["age"], siblingVars: ["mood"] },
});
await flushFetch(w);
const rules = w.findComponent({ name: "RulesSection" });
const vars = rules.props("varSuggestions") as string[];
expect(vars).toContain("age");
expect(vars).toContain("mood");
});
it("does not crash + passes empty ref-data before the fetch resolves", () => {
// No flush — assert the synchronous mount path is safe (maps empty).
const w = mount(DerivationInstanceModal, { props: { module: makeModule() } });
const rules = w.findComponent({ name: "RulesSection" });
expect((rules.props("refSuggestions") as string[]) ?? []).toEqual([]);
expect((rules.props("uuidToName") as Map<string, string>).size).toBe(0);
});
it("survives a failed /wp/api/modules fetch (empty ref-data, no throw)", async () => {
vi.unstubAllGlobals();
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })) as unknown as typeof fetch);
const w = mount(DerivationInstanceModal, { props: { module: makeModule() } });
await flushFetch(w);
const rules = w.findComponent({ name: "RulesSection" });
expect((rules.props("refSuggestions") as string[]) ?? []).toEqual([]);
});
});
@@ -12,8 +12,14 @@
* Section order matches all shipped v2 modals: Header → Identity →
* Rules (kind-specific) → Runtime → Footer.
*/
import { computed } from "vue";
import { computed, onMounted, ref } from "vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
import type { PairingBadge } from "../../../../extension/constraint-pairs";
import type { ModuleRow } from "../../../../manager/api/types";
import {
buildWildcardRefData,
collectLibraryWildcardRefs,
} from "../../../../manager/utils/library-suggestions";
import IdentitySection from "./sections/IdentitySection.vue";
import RulesSection from "./sections/RulesSection.vue";
import RuntimeSection from "./sections/RuntimeSection.vue";
@@ -25,10 +31,73 @@ const props = withDefaults(
/** True when draft has unsaved instance edits. Gates "Save to library"
* visibility — pushing an unmodified payload back is a no-op. */
isModified?: boolean;
/** Vars produced upstream of this Context node — forwarded as the
* `$var` autocomplete list for the rule override fields. */
upstreamVars?: string[];
/** Vars produced by other modules in the SAME Context node. */
siblingVars?: string[];
/** Via-nested constraint-pair badges for this derivation when it acts
* as a constraint carrier (a rule ACTION value hosts a nested `@{uuid}`
* that resolves to a downstream constraint's target). Keyed by the
* engine branch key (`${rule_id}:${bi}` / `${rule_id}:else`). Forwarded
* verbatim to RulesSection, which renders the inline `↪#N` badge. */
viaOptionPairs?: Map<string, readonly PairingBadge[]>;
}>(),
{ isDrifted: false, isModified: false },
{
isDrifted: false,
isModified: false,
upstreamVars: () => [],
siblingVars: () => [],
viaOptionPairs: () => new Map(),
},
);
// ── Library catalog → `@{}` ref-data ───────────────────────────────
//
// Bug parity: the rule ACTION-value override fields reuse the wildcard
// `@{}` nested-ref machinery (autocomplete + chips + step-2 sub-cat
// picker), and the read-only summary chips `@{uuid}` refs. Both need the
// per-wildcard ref-data the SPA derivation editor builds from
// `moduleStore.catalog`. The canvas has no Pinia store, so we fetch the
// library ONCE when the modal mounts (same `/wp/api/modules` source the
// ModulePickerModal reads) and build the SAME maps via the shared
// `buildWildcardRefData`. The `@{}` source is the LIBRARY (by identity),
// NOT this Context node's chain — a chain sibling would be a `$var`.
const catalog = ref<ModuleRow[]>([]);
const refData = computed(() => buildWildcardRefData(catalog.value));
const uuidToName = computed(() => refData.value.uuidToName);
// Library wildcard uuids for `@{}` autocomplete, excluding this module's own
// id (a wildcard never nests itself) — sorted by display name to mirror the
// SPA's `collectLibraryWildcardRefs`.
const refSuggestions = computed(() =>
collectLibraryWildcardRefs({ catalog: catalog.value }, props.module.id, refData.value.uuidToName),
);
/** `$var` suggestion list for the override fields — upstream + sibling
* producer vars, deduped + alpha-sorted (mirrors the combine modal). */
const varSuggestions = computed<string[]>(() => {
const set = new Set<string>();
for (const n of props.upstreamVars) if (n) set.add(n);
for (const n of props.siblingVars) if (n) set.add(n);
return [...set].sort();
});
onMounted(async () => {
// Fire-and-forget: until this resolves the ref-data maps stay empty, so
// autocomplete simply shows nothing + summary chips fall back to cached
// names — no crash, no blocking. A failed fetch leaves the catalog empty.
try {
if (typeof fetch !== "function") return;
const res = await fetch("/wp/api/modules", { credentials: "same-origin" });
if (!res.ok) return;
const json = (await res.json()) as { items?: ModuleRow[] };
if (Array.isArray(json.items)) catalog.value = json.items;
} catch {
// Non-fatal — leave catalog empty (editor still works without @{} data).
}
});
const emit = defineEmits<{
"update": [patch: Partial<ModuleEntry>];
"save": [];
@@ -78,7 +147,20 @@ function onSpaClick(): void {
</header>
<IdentitySection :module="module" @update="onUpdate" />
<RulesSection :module="module" @update="onUpdate" />
<RulesSection
:module="module"
:var-suggestions="varSuggestions"
:ref-suggestions="refSuggestions"
:uuid-to-name="uuidToName"
:uuid-to-sub-categories="refData.uuidToSubCategories"
:uuid-to-has-null="refData.uuidToHasNull"
:uuid-to-options-count="refData.uuidToOptionsCount"
:uuid-to-option-tag-sets="refData.uuidToOptionTagSets"
:uuid-to-tag-groups="refData.uuidToTagGroups"
:via-option-pairs="viaOptionPairs"
@update="onUpdate"
/>
<div class="wp-dvm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<footer class="wp-dvm__foot">
@@ -117,6 +199,7 @@ function onSpaClick(): void {
<button type="button" class="wp-dvm__btn" data-test="dvm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-dvm__btn wp-dvm__btn--primary" data-test="dvm-save" @click="emit('save')">Save</button>
</footer>
</div>
</div>
</template>
@@ -130,13 +213,19 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-dvm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-dvm__foot {
display: flex;
align-items: center;
@@ -0,0 +1,201 @@
<script setup lang="ts">
/**
* RuleValueChips — read-only chip renderer for a derivation rule's
* action.value / condition.value strings inside the canvas instance modal
* summary. Tokenizes the value and renders `@{uuid}` nested-wildcard refs +
* `$var` references as CHIPS, brace blocks / escapes as colored spans — so a
* value like `@{ad00acd5#mood:warm}` reads as a `@mood` chip with a funnel
* mark instead of leaking raw hex.
*
* Chip markup + CSS are copied verbatim from the wildcard editor's OptionRow
* (`editors/wildcard/sections/OptionRow.vue`) so the canvas reads identically
* to the option-value chips. Names resolve from the modal-supplied
* `uuidToName` map (the live library catalog) — no preview-resolver dep on
* this surface. A missing display value renders the placeholder text.
*/
import { computed } from "vue";
import { splitRefFilter, tokenizeRich, type RichToken } from "../../../../../widgets/richTokenize";
import { parse, readsAs } from "@/manager/parsing/subcatFilter";
const props = withDefaults(
defineProps<{
value?: string;
/** UUID → display name, from the modal's catalog fetch. Empty until the
* fetch resolves — chips fall back to the token's cached `#name`. */
uuidToName?: Map<string, string>;
/** Shown when `value` is empty (mirrors the old `|| "?"` placeholder). */
placeholder?: string;
}>(),
{ value: "", uuidToName: () => new Map(), placeholder: "?" },
);
const tokens = computed<RichToken[]>(() => tokenizeRich(props.value ?? ""));
const isEmpty = computed(() => (props.value ?? "").length === 0);
/** Human label for a ref chip — `@name` from the live map, else the cached
* `#name` (stripping a leaked `!null`), else the raw token. Mirrors
* OptionRow.refDisplay's fallback chain. */
function refDisplay(tok: RichToken): string {
const uuid = tok.meta?.uuid;
if (!uuid) return tok.raw;
const live = props.uuidToName.get(uuid);
if (live && live.trim()) return `@${live.trim()}`;
const cached = typeof tok.meta?.name === "string" ? tok.meta.name : "";
if (cached.trim()) return `@${cached.split("!")[0].trim() || cached.trim()}`;
return tok.raw;
}
/** Ref uuid absent from the live map → broken reference. Empty map (pre-fetch)
* is "unknown, not broken" so chips don't flash red while loading. */
function refIsUnresolved(tok: RichToken): boolean {
const uuid = tok.meta?.uuid;
if (!uuid) return false;
if (props.uuidToName.size === 0) return false;
return !props.uuidToName.has(uuid);
}
interface RefFilterInfo { hasExpr: boolean; excludeNull: boolean; isFiltered: boolean; title: string }
const _refFilterMemo = new WeakMap<RichToken, RefFilterInfo>();
/** Funnel (sub-cat expression) + ban (null excluded) marks + hover title.
* Copied from OptionRow.refFilter. */
function refFilter(tok: RichToken): RefFilterInfo {
const cached = _refFilterMemo.get(tok);
if (cached) return cached;
const subs = tok.meta?.sub_categories;
let expr = "";
let excludeNull = false;
if (Array.isArray(subs) && subs.length > 0) {
({ expr, excludeNull } = splitRefFilter(subs.join(",")));
} else {
const name = typeof tok.meta?.name === "string" ? tok.meta.name : "";
const bang = name.indexOf("!");
if (bang >= 0) excludeNull = name.slice(bang + 1) === "null";
}
let reads = "";
if (expr) {
try { reads = readsAs(parse(expr)); } catch { reads = expr; }
}
const parts: string[] = [];
if (reads) parts.push(reads);
if (excludeNull) parts.push("null excluded");
const info: RefFilterInfo = {
hasExpr: expr.length > 0,
excludeNull,
isFiltered: expr.length > 0 || excludeNull,
title: parts.join(" · "),
};
_refFilterMemo.set(tok, info);
return info;
}
</script>
<template>
<span class="rvc">
<span v-if="isEmpty" class="rvc__placeholder">{{ placeholder }}</span>
<template v-for="(tok, idx) in tokens" v-else :key="idx">
<span
v-if="tok.kind === 'ref'"
class="rvc__tok rvc__tok--ref"
:class="{
'rvc__tok--filtered': refFilter(tok).isFiltered,
'rvc__tok--unresolved': refIsUnresolved(tok),
}"
:data-uuid="tok.meta?.uuid"
:title="refIsUnresolved(tok)
? `Reference ${tok.meta?.uuid} not in library`
: (refFilter(tok).title || undefined)"
>
<span class="rvc__tok-icon" aria-hidden="true">{{ refIsUnresolved(tok) ? '?' : '✦' }}</span>
<span class="rvc__tok-label">{{ refDisplay(tok) }}</span>
<span
v-if="refFilter(tok).isFiltered"
class="rvc__tok-filter"
aria-hidden="true"
>
<i v-if="refFilter(tok).hasExpr" class="pi pi-filter rvc__tok-funnel"></i>
<i v-if="refFilter(tok).excludeNull" class="pi pi-ban rvc__tok-nonull"></i>
</span>
</span>
<span
v-else-if="tok.kind === 'var'"
class="rvc__tok rvc__tok--var"
>
<span class="rvc__tok-icon" aria-hidden="true">⌘</span>
<span class="rvc__tok-label">{{ tok.raw }}</span>
</span>
<span v-else-if="tok.kind === 'dp-brace'" class="rvc__tok rvc__tok--brace">{{ tok.raw }}</span>
<span v-else-if="tok.kind === 'dp-multi'" class="rvc__tok rvc__tok--multi">{{ tok.raw }}</span>
<span v-else-if="tok.kind === 'escape'" class="rvc__tok rvc__tok--escape">{{ tok.raw }}</span>
<template v-else>{{ tok.raw }}</template>
</template>
</span>
</template>
<style scoped>
/* Inline-syntax chips — palette + icon prefix copied from the wildcard
* OptionRow (`editors/wildcard/sections/OptionRow.vue`) so an `@nestedName`
* reads the same in the derivation summary as in an option value: purple ✦
* for refs, green ⌘ for vars, amber funnel/ban filter marks. */
.rvc {
display: inline;
}
.rvc__placeholder {
color: var(--wp-text);
background: color-mix(in srgb, var(--wp-text-dim, var(--wp-text3)) 12%, transparent);
padding: 0 4px;
border-radius: 2px;
}
.rvc__tok {
margin: 0 1px;
font-weight: 500;
display: inline-flex;
align-items: baseline;
vertical-align: baseline;
}
.rvc__tok--ref,
.rvc__tok--var {
border-radius: 3px;
padding: 0 5px;
gap: 3px;
border: 1px solid;
font: 10px/1.4 var(--wp-font-mono);
}
.rvc__tok-icon { font-size: 8px; opacity: 0.75; }
.rvc__tok-filter {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 2px;
color: var(--wp-status-modified, #fbbf24);
}
.rvc__tok-funnel { font-size: 8px; line-height: 1; }
.rvc__tok-nonull { font-size: 8px; line-height: 1; opacity: 0.85; }
.rvc__tok--ref {
background: color-mix(in srgb, var(--wp-kind-wildcard, #a855f7) 15%, transparent);
border-color: color-mix(in srgb, var(--wp-kind-wildcard, #a855f7) 50%, transparent);
color: var(--wp-kind-wildcard);
}
.rvc__tok--ref.rvc__tok--unresolved {
background: color-mix(in srgb, var(--wp-danger, #ef4444) 15%, transparent);
border-color: color-mix(in srgb, var(--wp-danger, #ef4444) 50%, transparent);
color: var(--wp-danger, #ef4444);
cursor: help;
}
.rvc__tok--var {
background: color-mix(in srgb, var(--wp-success, #22c55e) 15%, transparent);
border-color: color-mix(in srgb, var(--wp-success, #22c55e) 50%, transparent);
color: var(--wp-success);
}
.rvc__tok--brace {
color: var(--wp-warn, #fcd34d);
font-weight: 600;
}
.rvc__tok--multi {
color: var(--wp-rt-token-good, #4ad4c4);
font-weight: 500;
}
.rvc__tok--escape {
color: var(--wp-text-muted, var(--wp-text2));
opacity: 0.7;
}
</style>
@@ -8,10 +8,28 @@
// / edit-mode UI here.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import { mount, flushPromises } from "@vue/test-utils";
import RulesSection from "./RulesSection.vue";
import RichTextInput from "../../../../../manager/components/RichTextInput.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
// The override fields are RichTextInput hosts loaded via defineAsyncComponent
// (lazy chunk — keeps the bundle split). `flushPromises()` resolves the async
// wrapper so `findAllComponents(RichTextInput)` matches the real instance, and
// driving the field is `vm.$emit("update:modelValue", …)` rather than the
// old `<input>` value-set + "input" event.
async function expandAndFindRti(
w: ReturnType<typeof mount>,
ruleId: string,
testId: string,
) {
await w.find(`[data-test="rule-head-${ruleId}"]`).trigger("click");
await flushPromises();
return w
.findAllComponents(RichTextInput)
.find((c) => c.attributes("data-test") === testId);
}
interface DerivationBranch {
condition: { var: string; op: string; value: string };
action: { target_var: string; mode: string; value: string };
@@ -192,19 +210,21 @@ describe("derivation RulesSection (tier-D accordion + branch table)", () => {
props: { module: makeModule([multiRule()]) },
});
await w.find('[data-test="rule-head-r1"]').trigger("click");
expect(w.find('[data-test="action-override-r1-0"]').exists()).toBe(true);
expect(w.find('[data-test="action-override-r1-1"]').exists()).toBe(true);
expect(w.find('[data-test="action-override-r1-else"]').exists()).toBe(true);
await flushPromises();
const rtis = w.findAllComponents(RichTextInput);
const has = (t: string) => rtis.some((c) => c.attributes("data-test") === t);
expect(has("action-override-r1-0")).toBe(true);
expect(has("action-override-r1-1")).toBe(true);
expect(has("action-override-r1-else")).toBe(true);
});
it("typing into IF action override emits action_value_overrides patch", async () => {
const w = mount(RulesSection, {
props: { module: makeModule([multiRule()]) },
});
await w.find('[data-test="rule-head-r1"]').trigger("click");
const input = w.find<HTMLInputElement>('[data-test="action-override-r1-0"]');
input.element.value = "fiery";
await input.trigger("input");
const input = await expandAndFindRti(w, "r1", "action-override-r1-0");
input!.vm.$emit("update:modelValue", "fiery");
await w.vm.$nextTick();
const patch = lastPatch(w);
expect(patch.instance?.action_value_overrides).toEqual({
r1: { "0": "fiery" },
@@ -215,10 +235,9 @@ describe("derivation RulesSection (tier-D accordion + branch table)", () => {
const w = mount(RulesSection, {
props: { module: makeModule([multiRule()]) },
});
await w.find('[data-test="rule-head-r1"]').trigger("click");
const input = w.find<HTMLInputElement>('[data-test="action-override-r1-else"]');
input.element.value = "blank";
await input.trigger("input");
const input = await expandAndFindRti(w, "r1", "action-override-r1-else");
input!.vm.$emit("update:modelValue", "blank");
await w.vm.$nextTick();
expect(lastPatch(w).instance?.action_value_overrides).toEqual({
r1: { else: "blank" },
});
@@ -232,10 +251,9 @@ describe("derivation RulesSection (tier-D accordion + branch table)", () => {
}),
},
});
await w.find('[data-test="rule-head-r1"]').trigger("click");
const input = w.find<HTMLInputElement>('[data-test="action-override-r1-0"]');
input.element.value = "";
await input.trigger("input");
const input = await expandAndFindRti(w, "r1", "action-override-r1-0");
input!.vm.$emit("update:modelValue", "");
await w.vm.$nextTick();
expect(lastPatch(w).instance?.action_value_overrides).toBeNull();
});
@@ -246,19 +264,21 @@ describe("derivation RulesSection (tier-D accordion + branch table)", () => {
props: { module: makeModule([multiRule()]) },
});
await w.find('[data-test="rule-head-r1"]').trigger("click");
expect(w.find('[data-test="cond-override-r1-0"]').exists()).toBe(true);
expect(w.find('[data-test="cond-override-r1-1"]').exists()).toBe(true);
expect(w.find('[data-test="cond-override-r1-else"]').exists()).toBe(false);
await flushPromises();
const rtis = w.findAllComponents(RichTextInput);
const has = (t: string) => rtis.some((c) => c.attributes("data-test") === t);
expect(has("cond-override-r1-0")).toBe(true);
expect(has("cond-override-r1-1")).toBe(true);
expect(has("cond-override-r1-else")).toBe(false);
});
it("typing into IF condition override emits condition_value_overrides patch", async () => {
const w = mount(RulesSection, {
props: { module: makeModule([multiRule()]) },
});
await w.find('[data-test="rule-head-r1"]').trigger("click");
const input = w.find<HTMLInputElement>('[data-test="cond-override-r1-0"]');
input.element.value = "purple";
await input.trigger("input");
const input = await expandAndFindRti(w, "r1", "cond-override-r1-0");
input!.vm.$emit("update:modelValue", "purple");
await w.vm.$nextTick();
expect(lastPatch(w).instance?.condition_value_overrides).toEqual({
r1: { "0": "purple" },
});
@@ -350,3 +370,280 @@ describe("derivation RulesSection (tier-D accordion + branch table)", () => {
);
});
});
// ── @{} nested-ref parity (chips in summary + RichTextInput overrides) ──
//
// The canvas derivation modal must reach full parity with the SPA
// DerivationRuleCard editor: `@{uuid}` refs render as CHIPS in the
// read-only rule summary, and the ACTION / ELSE-action override fields
// become RichTextInputs with the full wildcard `@{}` machinery
// (allowNestedRefs + the six ref-data maps + $var suggestions). The
// CONDITION override stays var-only (engine compares condition.value
// raw — no `@{}` carrier resolution there). Mirrors the prop sets in
// src/manager/components/DerivationRuleCard.vue.
const REF_DATA = {
varSuggestions: ["mood", "age"],
refSuggestions: ["aabbccdd"],
uuidToName: new Map([["aabbccdd", "color"]]),
uuidToSubCategories: new Map([["aabbccdd", ["warm", "cool"]]]),
uuidToHasNull: new Map([["aabbccdd", false]]),
uuidToOptionsCount: new Map([["aabbccdd", 3]]),
uuidToOptionTagSets: new Map([["aabbccdd", [["warm"], ["cool"]]]]),
uuidToTagGroups: new Map([["aabbccdd", {}]]),
};
/** A rule whose IF action value carries a nested `@{uuid}` ref so the
* summary has something to chipify. */
function refRule(): DerivationRule {
return {
id: "r1",
branches: [{
condition: { var: "color", op: "equals", value: "red" },
action: { target_var: "mood", mode: "replace", value: "@{aabbccdd#color:warm}" },
}],
else: { action: { target_var: "mood", mode: "replace", value: "@{aabbccdd}" } },
};
}
describe("derivation RulesSection — @{} chip + autocomplete parity", () => {
it("summary renders an @{} ref as a chip (not raw @{uuid} text)", () => {
const w = mount(RulesSection, {
props: { module: makeModule([refRule()]), ...REF_DATA },
});
const summary = w.find('[data-test="rule-summary-r1"]');
// The chip carries the uuid as a data attribute (same as OptionRow).
const chip = summary.find('[data-uuid="aabbccdd"]');
expect(chip.exists()).toBe(true);
// Resolved name is shown, raw hex is NOT printed verbatim.
expect(summary.text()).toContain("color");
expect(summary.text()).not.toContain("@{aabbccdd");
});
it("action override + ELSE action override mount RichTextInput with allowNestedRefs + ref-data", async () => {
const w = mount(RulesSection, {
props: { module: makeModule([refRule()]), ...REF_DATA },
});
await w.find('[data-test="rule-head-r1"]').trigger("click");
await flushPromises();
const rtis = w.findAllComponents(RichTextInput);
const byTest = (t: string) => rtis.find((c) => c.attributes("data-test") === t);
const actVal = byTest("action-override-r1-0");
const elseVal = byTest("action-override-r1-else");
expect(actVal).toBeDefined();
expect(elseVal).toBeDefined();
for (const rti of [actVal!, elseVal!]) {
expect(rti.props("surface")).toBe("derivation");
expect(rti.props("allowNestedRefs")).toBe(true);
expect(rti.props("refSuggestions")).toEqual(["aabbccdd"]);
expect(rti.props("varSuggestions")).toEqual(["mood", "age"]);
expect(rti.props("uuidToName")).toBeInstanceOf(Map);
expect(rti.props("uuidToSubCategories")).toBeInstanceOf(Map);
expect(rti.props("uuidToHasNull")).toBeInstanceOf(Map);
expect(rti.props("uuidToOptionsCount")).toBeInstanceOf(Map);
expect(rti.props("uuidToOptionTagSets")).toBeInstanceOf(Map);
expect(rti.props("uuidToTagGroups")).toBeInstanceOf(Map);
}
});
it("condition override mounts RichTextInput WITHOUT allowNestedRefs (raw compare) but WITH var-suggestions", async () => {
const w = mount(RulesSection, {
props: { module: makeModule([refRule()]), ...REF_DATA },
});
await w.find('[data-test="rule-head-r1"]').trigger("click");
await flushPromises();
const rtis = w.findAllComponents(RichTextInput);
const condVal = rtis.find((c) => c.attributes("data-test") === "cond-override-r1-0");
expect(condVal).toBeDefined();
expect(condVal!.props("surface")).toBe("derivation");
// condition.value is compared RAW → no @{} carrier machinery.
expect(condVal!.props("allowNestedRefs")).toBeFalsy();
// …but $var autocomplete stays available (parity with the SPA field).
expect(condVal!.props("varSuggestions")).toEqual(["mood", "age"]);
// No ref-data passed to the condition input (empty default Map).
expect((condVal!.props("refSuggestions") as string[] | undefined) ?? []).toEqual([]);
});
it("typing into the action-override RichTextInput emits action_value_overrides patch", async () => {
const w = mount(RulesSection, {
props: { module: makeModule([refRule()]), ...REF_DATA },
});
const actVal = await expandAndFindRti(w, "r1", "action-override-r1-0");
actVal!.vm.$emit("update:modelValue", "fiery");
await w.vm.$nextTick();
const updates = w.emitted("update")! as unknown[][];
const patch = updates[updates.length - 1][0] as Partial<ModuleEntry>;
expect(patch.instance?.action_value_overrides).toEqual({ r1: { "0": "fiery" } });
});
it("clearing the action-override RichTextInput back to empty collapses the override to null", async () => {
const w = mount(RulesSection, {
props: {
module: makeModule([refRule()], { action_value_overrides: { r1: { "0": "fiery" } } }),
...REF_DATA,
},
});
const actVal = await expandAndFindRti(w, "r1", "action-override-r1-0");
actVal!.vm.$emit("update:modelValue", "");
await w.vm.$nextTick();
const updates = w.emitted("update")! as unknown[][];
const patch = updates[updates.length - 1][0] as Partial<ModuleEntry>;
expect(patch.instance?.action_value_overrides).toBeNull();
});
// Filter-pop-up seam: picking an `@` ref whose target declares
// sub_categories must open the SubcategoryFilterPicker from inside the
// canvas action-override input. Driven via RichTextInput's test seams
// (jsdom can't fake the real keyboard path). This exercises the
// picker-open code path — real-browser z-index/positioning over the
// litegraph modal still needs a human check (see report).
it("action-override RichTextInput opens SubcategoryFilterPicker on @-pick with sub-cats", async () => {
const w = mount(RulesSection, {
props: { module: makeModule([refRule()]), ...REF_DATA },
attachTo: document.body,
});
const actVal = await expandAndFindRti(w, "r1", "action-override-r1-0");
const vm = actVal!.vm as unknown as {
__triggerAutocompleteForTest: (t: "@" | "$") => void;
__applyAutocompleteForTest: (label: string) => void;
};
vm.__triggerAutocompleteForTest("@");
vm.__applyAutocompleteForTest("aabbccdd");
await w.vm.$nextTick();
expect(document.querySelector('[data-test="subcat-picker"]')).not.toBeNull();
w.unmount();
});
});
// ── Inline ↪#N constraint-pair badge in the rule summary ────────────
//
// When this derivation is the CARRIER of a downstream constraint (one of
// its rule ACTION values hosts a nested `@{uuid}` ref the constraint
// reaches its target through), the summary must surface a cyan `↪#N`
// PairBadge inline — RIGHT AFTER the value chips — exactly like OptionRow
// renders `↪#N` for a wildcard option's `@{}` ref. The badges arrive via a
// `viaOptionPairs` map keyed by the engine branch key (`${rule_id}:${bi}` /
// `${rule_id}:else`) — the SAME key `computePairingsFull` stamps as the
// carrier option_id for a derivation occurrence, and the SAME map
// ContextWidget builds for the wildcard option path. Reuses PairBadge.vue
// (variant="option" → `↪#N`); no bespoke styling.
import PairBadge from "../../../PairBadge.vue";
import type { PairingBadge } from "../../../../../extension/constraint-pairs";
function viaPair(number: number, over: Partial<PairingBadge> = {}): PairingBadge {
return {
number,
targetUuid: "ffeeddcc",
colorIndex: 4,
isOrphan: false,
via: {
carrierRowKey: "dv012345",
carrierName: "tint",
optionIds: ["r1:0"],
routeChain: ["ffeeddcc"],
},
...over,
};
}
describe("derivation RulesSection — inline ↪#N pair badge", () => {
it("renders a PairBadge (↪#N) in the IF rule-head summary when the IF branch carries a via-pair", () => {
const w = mount(RulesSection, {
props: {
module: makeModule([refRule()]),
...REF_DATA,
viaOptionPairs: new Map([["r1:0", [viaPair(1)]]]),
},
});
const summary = w.find('[data-test="rule-summary-r1"]');
const badge = summary.findComponent(PairBadge);
expect(badge.exists()).toBe(true);
expect(badge.props("variant")).toBe("option");
expect(summary.text()).toContain("↪#1");
});
it("does NOT render a PairBadge in the summary when no via-pairs are supplied", () => {
const w = mount(RulesSection, {
props: { module: makeModule([refRule()]), ...REF_DATA },
});
const summary = w.find('[data-test="rule-summary-r1"]');
expect(summary.findComponent(PairBadge).exists()).toBe(false);
});
it("renders the ↪#N badge inline AFTER the action value chips (mockup placement)", () => {
const w = mount(RulesSection, {
props: {
module: makeModule([refRule()]),
...REF_DATA,
viaOptionPairs: new Map([["r1:0", [viaPair(1)]]]),
},
});
const summary = w.find('[data-test="rule-summary-r1"]');
// The badge must read AFTER the action value in the rendered text so it
// reads "→ $mood = @color ↪#1" — not the other way round. The IF action
// value resolves to "@color" (REF_DATA maps aabbccdd → color).
const text = summary.text();
expect(text).toContain("color");
expect(text).toContain("↪#1");
expect(text.indexOf("↪#1")).toBeGreaterThan(text.indexOf("color"));
});
it("renders ↪#N badges per-branch in the expanded IF/ELIF/ELSE summary rows", async () => {
const w = mount(RulesSection, {
props: {
module: makeModule([multiRule()]),
...REF_DATA,
viaOptionPairs: new Map<string, PairingBadge[]>([
["r1:0", [viaPair(1)]],
["r1:1", [viaPair(2)]],
["r1:else", [viaPair(3)]],
]),
},
});
await w.find('[data-test="rule-head-r1"]').trigger("click");
const ifRow = w.find('[data-test="branch-row-r1-0"]');
const elifRow = w.find('[data-test="branch-row-r1-1"]');
const elseRow = w.find('[data-test="branch-row-r1-else"]');
expect(ifRow.findComponent(PairBadge).exists()).toBe(true);
expect(ifRow.text()).toContain("↪#1");
expect(elifRow.findComponent(PairBadge).exists()).toBe(true);
expect(elifRow.text()).toContain("↪#2");
expect(elseRow.findComponent(PairBadge).exists()).toBe(true);
expect(elseRow.text()).toContain("↪#3");
});
it("renders multiple ↪#N badges when several constraints route through one branch", () => {
const w = mount(RulesSection, {
props: {
module: makeModule([refRule()]),
...REF_DATA,
viaOptionPairs: new Map([["r1:0", [viaPair(1), viaPair(2, { colorIndex: 5 })]]]),
},
});
const summary = w.find('[data-test="rule-summary-r1"]');
expect(summary.findAllComponents(PairBadge)).toHaveLength(2);
expect(summary.text()).toContain("↪#1");
expect(summary.text()).toContain("↪#2");
});
});
import DerivationInstanceModal from "../DerivationInstanceModal.vue";
describe("DerivationInstanceModal — forwards viaOptionPairs to RulesSection", () => {
it("passes its viaOptionPairs prop through to the RulesSection", () => {
const pairs = new Map([["r1:0", [viaPair(1)]]]);
const w = mount(DerivationInstanceModal, {
props: {
module: makeModule([refRule()]),
viaOptionPairs: pairs,
},
});
const rules = w.findComponent(RulesSection);
expect(rules.exists()).toBe(true);
// Vue rewraps the prop through reactivity, so identity (toBe) can't hold;
// structural equality confirms the same map content reached RulesSection.
expect(rules.props("viaOptionPairs")).toEqual(pairs);
});
});
@@ -24,10 +24,23 @@
* - `condition_value_overrides` — same shape, but for IF + ELIF
* conditions only (ELSE has no condition).
*/
import { computed, ref } from "vue";
import { computed, defineAsyncComponent, ref } from "vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
import type { PairingBadge } from "../../../../../extension/constraint-pairs";
import { patchInstance } from "../../instance/patch";
import { varColorClass } from "../../../../shared/var-color";
import RuleValueChips from "./RuleValueChips.vue";
import PairBadge from "../../../PairBadge.vue";
// Async-import the rich-text editor so its chunk stays split + is only
// pulled in when a derivation instance modal expands a rule. RichTextInput
// is ALREADY a lazy chunk in the extension (combine's TemplateSection imports
// the same module) — Vite dedupes, so reusing the identical specifier adds
// ~0 to the bundle. Bug parity: this brings `@{}` chips + autocomplete + the
// sub-cat picker to the canvas override fields, matching the SPA editor.
const RichTextInput = defineAsyncComponent(
() => import("../../../../../manager/components/RichTextInput.vue"),
);
interface DerivationCondition { var?: string; op?: string; value?: string }
interface DerivationAction { target_var?: string; mode?: string; value?: string }
@@ -38,7 +51,46 @@ interface DerivationRule {
else?: { action?: DerivationAction };
}
const props = defineProps<{ module: ModuleEntry }>();
const props = withDefaults(
defineProps<{
module: ModuleEntry;
/** `$var` autocomplete list for the override RichTextInputs —
* upstream + sibling producer vars, forwarded by the modal. */
varSuggestions?: string[];
/** Library WILDCARD uuids for the `@{}` autocomplete (ACTION /
* ELSE-action inputs only — `condition.value` is compared raw). */
refSuggestions?: string[];
/** The six per-wildcard maps `buildWildcardRefData` returns — used
* both to chipify `@{uuid}` in the read-only summary and to feed the
* ACTION-value RichTextInputs' nested-ref autocomplete + step-2
* sub-cat picker. Empty until the modal's catalog fetch resolves. */
uuidToName?: Map<string, string>;
uuidToSubCategories?: Map<string, string[]>;
uuidToHasNull?: Map<string, boolean>;
uuidToOptionsCount?: Map<string, number>;
uuidToOptionTagSets?: Map<string, string[][]>;
uuidToTagGroups?: Map<string, Record<string, string[]>>;
/** Via-nested constraint pairs that reach a downstream target through a
* nested `@{uuid}` ref hosted in this derivation's rule ACTION values.
* Keyed by the engine branch key (`${rule_id}:${bi}` / `${rule_id}:else`)
* — the SAME carrier-occurrence key `computePairingsFull` stamps. Drives
* the inline `↪#N` PairBadge after the value chips in each branch's
* summary row, mirroring the wildcard editor's per-option badge. Empty
* when this derivation isn't a constraint carrier. */
viaOptionPairs?: Map<string, readonly PairingBadge[]>;
}>(),
{
varSuggestions: () => [],
refSuggestions: () => [],
uuidToName: () => new Map(),
uuidToSubCategories: () => new Map(),
uuidToHasNull: () => new Map(),
uuidToOptionsCount: () => new Map(),
uuidToOptionTagSets: () => new Map(),
uuidToTagGroups: () => new Map(),
viaOptionPairs: () => new Map(),
},
);
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
const rules = computed<DerivationRule[]>(() => {
@@ -78,6 +130,14 @@ function branchKey(ruleId: string, branchIdx: number | "else"): string {
return `${ruleId}:${branchIdx}`;
}
/** Via-nested constraint-pair badges for one branch occurrence, looked up
* by its engine branch key. Empty array when this derivation isn't a
* carrier for that branch — keeps the template `v-for` inert without a
* surrounding `v-if`. */
function pairBadgesFor(ruleId: string, branchIdx: number | "else"): readonly PairingBadge[] {
return props.viaOptionPairs.get(branchKey(ruleId, branchIdx)) ?? [];
}
function isBranchEnabled(ruleId: string, branchIdx: number | "else"): boolean {
return !disabledBranchKeys.value.has(branchKey(ruleId, branchIdx));
}
@@ -138,13 +198,14 @@ function setOverride(
emit("update", patchInstance(props.module, field, collapsed));
}
function onActionOverrideInput(ruleId: string, key: string, ev: Event): void {
const value = (ev.target as HTMLInputElement).value;
// RichTextInput emits the resolved string directly (not a DOM Event), so
// these handlers take the value as-is. `setOverride` already collapses an
// empty string to a dropped entry / null map.
function onActionOverrideInput(ruleId: string, key: string, value: string): void {
setOverride("action_value_overrides", ruleId, key, value);
}
function onCondOverrideInput(ruleId: string, key: string, ev: Event): void {
const value = (ev.target as HTMLInputElement).value;
function onCondOverrideInput(ruleId: string, key: string, value: string): void {
setOverride("condition_value_overrides", ruleId, key, value);
}
@@ -371,14 +432,24 @@ function opUsesValue(op: string | undefined): boolean {
<span
v-if="opUsesValue(rule.branches[0].condition?.op)"
class="rule-tok-val"
>{{ rule.branches[0].condition?.value || "?" }}</span>
><RuleValueChips :value="rule.branches[0].condition?.value" :uuid-to-name="uuidToName" /></span>
<span class="rule-tok-arrow">→</span>
<span
v-if="rule.branches[0].action?.target_var"
:class="['rule-tok-var', varColorClass(rule.branches[0].action.target_var)]"
>${{ rule.branches[0].action.target_var }}</span>
<span class="rule-tok-op">{{ modeLabel(rule.branches[0].action?.mode) }}</span>
<span class="rule-tok-val">{{ rule.branches[0].action?.value || "?" }}</span>
<span class="rule-tok-val"><RuleValueChips :value="rule.branches[0].action?.value" :uuid-to-name="uuidToName" /></span>
<!-- Inline ↪#N constraint-pair badge — rendered when this
derivation is a constraint carrier through the IF branch's
nested `@{uuid}` ref. Mirrors OptionRow's per-option badge
so the summary reads "@{tint} ↪#1". -->
<PairBadge
v-for="p in pairBadgesFor(rule.id, 0)"
:key="`${p.number}-${p.targetUuid}`"
:pair="p"
variant="option"
/>
</template>
</span>
@@ -456,38 +527,64 @@ function opUsesValue(op: string | undefined): boolean {
<span
v-if="opUsesValue(branch.condition?.op)"
class="rule-tok-val"
>{{ branch.condition?.value || "?" }}</span>
><RuleValueChips :value="branch.condition?.value" :uuid-to-name="uuidToName" /></span>
<span class="rule-tok-arrow">→</span>
<span
v-if="branch.action?.target_var"
:class="['rule-tok-var', varColorClass(branch.action.target_var)]"
>${{ branch.action.target_var }}</span>
<span class="rule-tok-op">{{ modeLabel(branch.action?.mode) }}</span>
<span class="rule-tok-val">{{ branch.action?.value || "?" }}</span>
<span class="rule-tok-val"><RuleValueChips :value="branch.action?.value" :uuid-to-name="uuidToName" /></span>
<PairBadge
v-for="p in pairBadgesFor(rule.id, bi)"
:key="`${p.number}-${p.targetUuid}`"
:pair="p"
variant="option"
/>
</span>
<span class="branch-cell branch-cell--cond-override">
<input
<!-- condition.value override — RichTextInput on the
`derivation` surface gives `$var` chips/autocomplete, but
NO `@{}` machinery: the engine compares condition.value
RAW (never resolves refs/vars there). Matches the SPA
DerivationRuleCard condition field. -->
<RichTextInput
v-if="opUsesValue(branch.condition?.op)"
type="text"
surface="derivation"
:var-suggestions="varSuggestions"
:uuid-to-name="uuidToName"
:model-value="getOverride('condition_value_overrides', rule.id, String(bi))"
:placeholder="branch.condition?.value || ''"
class="branch-override-input"
:class="{ 'branch-override-input--mod': getOverride('condition_value_overrides', rule.id, String(bi)) !== '' }"
:value="getOverride('condition_value_overrides', rule.id, String(bi))"
:placeholder="branch.condition?.value || ''"
:data-test="`cond-override-${rule.id}-${bi}`"
:aria-label="`Condition value override for rule ${rule.id} branch ${bi}`"
@input="(ev) => onCondOverrideInput(rule.id, String(bi), ev)"
@update:model-value="(v: string) => onCondOverrideInput(rule.id, String(bi), v)"
/>
</span>
<span class="branch-cell branch-cell--action-override">
<input
type="text"
<!-- action.value override — full `@{}` carrier machinery
(allow-nested-refs + the six ref-data maps): the engine
resolves `@{}` here post-Layer-A. Matches the SPA
DerivationRuleCard action field. -->
<RichTextInput
surface="derivation"
allow-nested-refs
:var-suggestions="varSuggestions"
:ref-suggestions="refSuggestions"
:uuid-to-name="uuidToName"
:uuid-to-sub-categories="uuidToSubCategories"
:uuid-to-has-null="uuidToHasNull"
:uuid-to-options-count="uuidToOptionsCount"
:uuid-to-option-tag-sets="uuidToOptionTagSets"
:uuid-to-tag-groups="uuidToTagGroups"
:model-value="getOverride('action_value_overrides', rule.id, String(bi))"
:placeholder="branch.action?.value || ''"
class="branch-override-input"
:class="{ 'branch-override-input--mod': getOverride('action_value_overrides', rule.id, String(bi)) !== '' }"
:value="getOverride('action_value_overrides', rule.id, String(bi))"
:placeholder="branch.action?.value || ''"
:data-test="`action-override-${rule.id}-${bi}`"
:aria-label="`Action value override for rule ${rule.id} branch ${bi}`"
@input="(ev) => onActionOverrideInput(rule.id, String(bi), ev)"
@update:model-value="(v: string) => onActionOverrideInput(rule.id, String(bi), v)"
/>
</span>
</div>
@@ -520,20 +617,37 @@ function opUsesValue(op: string | undefined): boolean {
:class="['rule-tok-var', varColorClass(rule.else.action.target_var)]"
>${{ rule.else.action.target_var }}</span>
<span class="rule-tok-op">{{ modeLabel(rule.else.action?.mode) }}</span>
<span class="rule-tok-val">{{ rule.else.action?.value || "?" }}</span>
<span class="rule-tok-val"><RuleValueChips :value="rule.else.action?.value" :uuid-to-name="uuidToName" /></span>
<PairBadge
v-for="p in pairBadgesFor(rule.id, 'else')"
:key="`${p.number}-${p.targetUuid}`"
:pair="p"
variant="option"
/>
</span>
<!-- ELSE has no condition → no condition override cell -->
<span class="branch-cell branch-cell--cond-override"></span>
<span class="branch-cell branch-cell--action-override">
<input
type="text"
<!-- ELSE action.value override — full `@{}` carrier machinery,
same as the IF/ELIF action field. -->
<RichTextInput
surface="derivation"
allow-nested-refs
:var-suggestions="varSuggestions"
:ref-suggestions="refSuggestions"
:uuid-to-name="uuidToName"
:uuid-to-sub-categories="uuidToSubCategories"
:uuid-to-has-null="uuidToHasNull"
:uuid-to-options-count="uuidToOptionsCount"
:uuid-to-option-tag-sets="uuidToOptionTagSets"
:uuid-to-tag-groups="uuidToTagGroups"
:model-value="getOverride('action_value_overrides', rule.id, 'else')"
:placeholder="rule.else.action?.value || ''"
class="branch-override-input"
:class="{ 'branch-override-input--mod': getOverride('action_value_overrides', rule.id, 'else') !== '' }"
:value="getOverride('action_value_overrides', rule.id, 'else')"
:placeholder="rule.else.action?.value || ''"
:data-test="`action-override-${rule.id}-else`"
:aria-label="`ELSE action value override for rule ${rule.id}`"
@input="(ev) => onActionOverrideInput(rule.id, 'else', ev)"
@update:model-value="(v: string) => onActionOverrideInput(rule.id, 'else', v)"
/>
</span>
</div>
@@ -579,6 +693,9 @@ function opUsesValue(op: string | undefined): boolean {
display: flex;
flex-direction: column;
gap: 6px;
/* Long rule lists scroll in place so identity / footer stay visible. */
max-height: 44vh;
overflow-y: auto;
}
/* Rule card */
@@ -752,30 +869,29 @@ function opUsesValue(op: string | undefined): boolean {
}
.branch-toggle .pi { font-size: 8px; }
/* The override fields are now RichTextInput hosts (atomic-chip editors)
* rather than bare `<input>`s — RichTextInput supplies its own `.wp-rt`
* border / background / padding / mono font. We only own the grid-cell
* sizing here + the `--mod` accent (same minimal approach combine's
* TemplateSection uses with `.wp-tpl__input--mod`). The `:deep()` rules
* tighten the embedded host to the compact 10px branch-table scale so the
* canvas override field doesn't tower over the summary row. */
.branch-override-input {
width: 100%;
background: var(--wp-bg2);
border: 1px dashed var(--wp-border);
border-radius: 3px;
padding: 3px 6px;
font: 10px var(--wp-font-mono);
color: var(--wp-text);
min-width: 0;
}
.branch-override-input:focus {
border-color: var(--wp-accent);
outline: none;
.branch-override-input :deep(.wp-rt) {
font: 10px var(--wp-font-mono);
border-style: dashed;
}
.branch-override-input--mod {
.branch-override-input :deep(.wp-rt__host) {
padding: 3px 6px;
}
.branch-override-input--mod :deep(.wp-rt) {
border-style: solid;
border-color: var(--wp-accent);
color: var(--wp-accent-text, var(--wp-text));
background: color-mix(in srgb, var(--wp-accent) 6%, var(--wp-bg2));
}
.branch-override-input::placeholder {
color: var(--wp-text-dim, var(--wp-text3));
opacity: 0.6;
}
/* Token coloring (re-used from prior list rendering) */
.rule-tok-var { font-weight: 600; }
@@ -66,6 +66,7 @@ function onSpaClick(): void {
<IdentitySection :module="module" @update="onUpdate" />
<ValuesSection :module="module" @update="onUpdate" />
<div class="wp-fvm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<footer class="wp-fvm__foot">
@@ -105,6 +106,7 @@ function onSpaClick(): void {
<button type="button" class="wp-fvm__btn" data-test="fvm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-fvm__btn wp-fvm__btn--primary" data-test="fvm-save" @click="emit('save')">Save</button>
</footer>
</div>
</div>
</template>
@@ -118,13 +120,19 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-fvm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-fvm__foot {
display: flex;
align-items: center;
@@ -48,6 +48,25 @@ describe("ValueRow", () => {
expect(w.emitted("update")?.[0]).toEqual(["v1", { value: "50mm" }]);
});
it("strips comma + punctuation from the name on input (value names must be identifiers)", async () => {
// The name becomes a `$var`; only identifier chars allowed. The VALUE
// field (below) is free-form and must NOT be sanitized.
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const input = w.find<HTMLInputElement>('[data-test="row-name"]');
input.element.value = "cam,era;lens!";
await input.trigger("input");
expect(w.emitted("update")?.[0]).toEqual(["v1", { name: "cameralens" }]);
expect(input.element.value).toBe("cameralens");
});
it("does NOT strip punctuation from the value field (values are free-form)", async () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const input = w.find<HTMLInputElement>('[data-test="row-value"]');
input.element.value = "85mm, f/1.8!";
await input.trigger("input");
expect(w.emitted("update")?.[0]).toEqual(["v1", { value: "85mm, f/1.8!" }]);
});
it("library row with no override is plain (no --mod, no reset button)", () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
expect(w.find('[data-test="row-name-wrap"]').classes()).not.toContain("row__name-wrap--mod");
@@ -6,6 +6,7 @@ import {
type LibraryRow,
} from "../defaults";
import { tokenize, type PreviewToken } from "../../_shared/preview-tokens";
import { stripNonIdentifierChars } from "../../../../../manager/utils/slug";
const props = defineProps<{
row: DraftRow;
@@ -53,7 +54,12 @@ function onToggle(): void {
emit("toggle", props.row.id);
}
function onNameInput(ev: Event): void {
emit("update", props.row.id, { name: (ev.target as HTMLInputElement).value });
const el = ev.target as HTMLInputElement;
// The name becomes a `$var` — strip non-identifier chars on keystroke +
// force the DOM. The value field stays free-form (onValueInput unchanged).
const name = stripNonIdentifierChars(el.value);
if (el.value !== name) el.value = name;
emit("update", props.row.id, { name });
}
function onValueInput(ev: Event): void {
emit("update", props.row.id, { value: (ev.target as HTMLInputElement).value });
@@ -183,9 +183,14 @@ const addedCount = computed(() => draftRows.value.filter((r) => r.libraryId ===
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: 3px;
overflow: hidden;
/* Long value lists scroll in place; the header row stays pinned. */
max-height: 42vh;
overflow-y: auto;
}
.vals__head {
position: sticky;
top: 0;
z-index: 1;
display: grid;
grid-template-columns: 22px 180px 1fr 28px 28px;
gap: 10px;
@@ -29,10 +29,10 @@ describe("pruneStaleInstanceRefs — wildcard", () => {
expect(result.warnings.length).toBe(1);
});
it("drops stale category_filter values", () => {
const inst = { category_filter: ["cat1", "ghost"] };
it("drops stale category_filter tokens from the expression", () => {
const inst = { category_filter: "cat1 or ghost" };
const result = pruneStaleInstanceRefs(inst, newPayload, "wildcard");
expect(result.instance?.category_filter).toEqual(["cat1"]);
expect(result.instance?.category_filter).toBe("cat1");
expect(result.warnings.length).toBe(1);
});
@@ -1,9 +1,20 @@
import { removeSubcatFromExpr } from "@/manager/cascade/subcatExprCascade";
import { parse as parseSubcatExpr, type Ast } from "@/manager/parsing/subcatFilter";
import type { ModuleEntry } from "../../../../widgets/_shared";
import type { ModuleKind } from "../_shell";
import { encodeKey } from "./keys";
type Instance = NonNullable<ModuleEntry["instance"]>;
/** Distinct sub-category tags referenced by a parsed filter expression. */
function exprTags(ast: Ast | null): string[] {
if (!ast) return [];
if ("tag" in ast) return [ast.tag];
if (ast.op === "not") return exprTags(ast.x);
return ast.kids.flatMap(exprTags);
}
export interface PruneResult {
instance: Instance;
warnings: string[];
@@ -55,14 +66,16 @@ export function pruneStaleInstanceRefs(
out.pinned_option_id = null;
out.mode = "random";
}
if (Array.isArray(out.category_filter)) {
const before = out.category_filter;
const after = before.filter((c) => subCats.has(c));
if (after.length !== before.length) {
for (const dropped of before.filter((c) => !subCats.has(c))) {
if (typeof out.category_filter === "string" && out.category_filter.trim()) {
const stale = [...new Set(exprTags(parseSubcatExpr(out.category_filter)))]
.filter((c) => !subCats.has(c));
if (stale.length > 0) {
let expr = out.category_filter;
for (const dropped of stale) {
warnings.push(`category_filter: dropped stale category '${dropped}'`);
expr = removeSubcatFromExpr(expr, dropped);
}
out.category_filter = after.length > 0 ? after : null;
out.category_filter = expr || null;
}
}
} else if (kind === "derivation") {
@@ -14,10 +14,10 @@ describe("DisabledRulesSection", () => {
const wrapper = renderSection(DisabledRulesSection, {
library: lib, modelValue: null,
});
const checks = wrapper.findAll('input[data-test^="dr-cb-"]');
const checks = wrapper.findAll('[data-test^="dr-cb-"]');
expect(checks).toHaveLength(3);
checks.forEach((c) => {
expect((c.element as HTMLInputElement).checked).toBe(false);
expect(c.attributes("aria-checked")).toBe("false");
});
expect(wrapper.find('[data-test="dr-reset"]').exists()).toBe(false);
});
@@ -26,9 +26,9 @@ describe("DisabledRulesSection", () => {
const wrapper = renderSection(DisabledRulesSection, {
library: lib, modelValue: ["r1", "r3"],
});
expect((wrapper.find('input[data-test="dr-cb-r1"]').element as HTMLInputElement).checked).toBe(true);
expect((wrapper.find('input[data-test="dr-cb-r2"]').element as HTMLInputElement).checked).toBe(false);
expect((wrapper.find('input[data-test="dr-cb-r3"]').element as HTMLInputElement).checked).toBe(true);
expect(wrapper.find('[data-test="dr-cb-r1"]').attributes("aria-checked")).toBe("true");
expect(wrapper.find('[data-test="dr-cb-r2"]').attributes("aria-checked")).toBe("false");
expect(wrapper.find('[data-test="dr-cb-r3"]').attributes("aria-checked")).toBe("true");
expect(wrapper.find('[data-test="dr-reset"]').exists()).toBe(true);
});
@@ -36,9 +36,8 @@ describe("DisabledRulesSection", () => {
const wrapper = renderSection(DisabledRulesSection, {
library: lib, modelValue: null,
});
const cb = wrapper.find('input[data-test="dr-cb-r2"]');
(cb.element as HTMLInputElement).checked = true;
await cb.trigger("change");
const cb = wrapper.find('[data-test="dr-cb-r2"]');
await cb.trigger("click");
const next = getEmittedUpdate<string[] | null>(wrapper);
expect(next).toEqual(["r2"]);
});
@@ -47,9 +46,8 @@ describe("DisabledRulesSection", () => {
const wrapper = renderSection(DisabledRulesSection, {
library: lib, modelValue: ["r1"],
});
const cb = wrapper.find('input[data-test="dr-cb-r3"]');
(cb.element as HTMLInputElement).checked = true;
await cb.trigger("change");
const cb = wrapper.find('[data-test="dr-cb-r3"]');
await cb.trigger("click");
const next = getEmittedUpdate<string[] | null>(wrapper);
expect(next).toContain("r1");
expect(next).toContain("r3");
@@ -60,9 +58,8 @@ describe("DisabledRulesSection", () => {
const wrapper = renderSection(DisabledRulesSection, {
library: lib, modelValue: ["r1"],
});
const cb = wrapper.find('input[data-test="dr-cb-r1"]');
(cb.element as HTMLInputElement).checked = false;
await cb.trigger("change");
const cb = wrapper.find('[data-test="dr-cb-r1"]');
await cb.trigger("click");
const next = getEmittedUpdate<string[] | null>(wrapper);
expect(next).toBeNull();
});
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import WpCheck from "../../../../shared/WpCheck.vue";
interface Rule {
id: string;
@@ -32,14 +33,13 @@ function emitList(ids: string[]): void {
emit("update:modelValue", ids);
}
function onToggle(id: string, ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
function onToggle(id: string, next: boolean): void {
const current = new Set(props.modelValue ?? []);
if (checked) current.add(id);
if (next) current.add(id);
else current.delete(id);
// Preserve library order in emitted array.
const next = props.library.map((r) => r.id).filter((rid) => current.has(rid));
emitList(next);
const ordered = props.library.map((r) => r.id).filter((rid) => current.has(rid));
emitList(ordered);
}
function ruleLabel(rule: Rule): string {
@@ -71,20 +71,20 @@ function onClickReset(): void {
</button>
</div>
<div class="wp-instance-section-body wp-instance-section-body-col">
<label
<div
v-for="rule in library"
:key="rule.id"
class="wp-instance-row"
>
<input
type="checkbox"
<WpCheck
:data-test="`dr-cb-${rule.id}`"
:checked="isDisabled(rule.id)"
@change="(ev) => onToggle(rule.id, ev)"
:model-value="isDisabled(rule.id)"
:aria-label="`disable rule ${rule.id}`"
@update:model-value="(v) => onToggle(rule.id, v)"
/>
<span class="wp-instance-row-id">{{ rule.id }}</span>
<span class="wp-instance-row-value">{{ ruleLabel(rule) }}</span>
</label>
</div>
</div>
</section>
</template>
@@ -14,8 +14,8 @@ function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
var_binding: "outfit",
sub_categories: ["warm", "cool"],
options: [
{ id: "o1", value: "red", weight: 1, sub_category: "warm" },
{ id: "o2", value: "blue", weight: 1, sub_category: "cool" },
{ id: "o1", value: "red", weight: 1, sub_categories: ["warm"] },
{ id: "o2", value: "blue", weight: 1, sub_categories: ["cool"] },
],
},
instance: {},
@@ -41,6 +41,16 @@ const emit = defineEmits<{
// the older gate of `isLibraryTracked && isModified` is gone.
const canSaveToLibrary = computed(() => Boolean(props.module.payload));
/** SP2a: when the instance picks more than one option the var resolves to a
* list, so the header subtitle drops the singular "per pick" framing. */
const isMultiPick = computed(() => {
const inst = props.module.instance ?? {};
const lo = typeof inst.pick_min === "number" ? inst.pick_min : 1;
const hi = typeof inst.pick_max === "number" ? inst.pick_max : lo;
const loC = Math.max(0, Math.min(lo, hi));
return !(loC === 1 && Math.max(loC, hi) === 1);
});
function spaUrl(): string {
// SPA base is `/wp/` (see `src/manager/router/index.ts:44`),
// so wildcard editor lives at `/wp/wildcards/<id>/edit`. The
@@ -67,7 +77,11 @@ function onSpaClick(): void {
<span class="wp-wcm__name" data-test="wcm-name">{{ module.meta?.name ?? module.type }}</span>
<span class="wp-wcm__chip" data-test="wcm-chip">wildcard</span>
</div>
<div class="wp-wcm__sub">Library entry · weighted options resolved per pick</div>
<div class="wp-wcm__sub" data-test="wcm-sub">
{{ isMultiPick
? "Library entry · resolves a list (multi-pick)"
: "Library entry · weighted options resolved per pick" }}
</div>
</div>
<button
type="button"
@@ -87,6 +101,7 @@ function onSpaClick(): void {
@update="onUpdate"
/>
<PoolSection :module="module" :via-option-pairs="viaOptionPairs" @update="onUpdate" />
<div class="wp-wcm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<footer class="wp-wcm__foot">
@@ -136,6 +151,7 @@ function onSpaClick(): void {
Save
</button>
</footer>
</div>
</div>
</template>
@@ -149,7 +165,7 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
@@ -157,6 +173,12 @@ function onSpaClick(): void {
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). Adopted from InjectorBindingModal
* so every per-instance edit modal reads as one design family. */
.wp-wcm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-wcm__foot {
display: flex;
align-items: center;
@@ -1,23 +1,67 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import OptionRow from "./OptionRow.vue";
import { _resetForTests, _setForTests } from "../../../../../extension/preview-resolver";
import OptionRow from "../sections/OptionRow.vue";
import { _resetForTests, _setForTests } from "@/extension/preview-resolver";
const baseOption = { id: "o1", value: "red", weight: 1, sub_category: "warm" };
const baseOption = { id: "o1", value: "red", weight: 1, sub_categories: ["warm"] };
const allOptions = [
baseOption,
{ id: "o2", value: "blue", weight: 1, sub_category: "cool" },
{ id: "o2", value: "blue", weight: 1, sub_categories: ["cool"] },
];
describe("OptionRow", () => {
beforeEach(() => _resetForTests());
it("renders option name + category label", () => {
it("renders option name + a category chip per sub-category", () => {
const w = mount(OptionRow, {
props: { option: baseOption, allOptions, instance: {} },
});
expect(w.find('[data-test="opt-name"]').text()).toBe("red");
expect(w.find('[data-test="opt-cat"]').text().toLowerCase()).toBe("warm");
const chip = w.find('[data-test="opt-cat-warm"]');
expect(chip.exists()).toBe(true);
expect(chip.text().toLowerCase()).toBe("warm");
});
it("renders multiple category chips for a multi-tag option", () => {
const opt = { id: "m", value: "lynx", weight: 1, sub_categories: ["feline", "warm"] };
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
expect(w.find('[data-test="opt-cat-feline"]').exists()).toBe(true);
expect(w.find('[data-test="opt-cat-warm"]').exists()).toBe(true);
});
it("decomposes a {N$$…} block into scaffolding + an inner ref chip (SP2b #6)", () => {
_setForTests("ad00acd5", { name: "mood", varBinding: "mood" });
const opt = { id: "b1", value: "{2$$, $$@{ad00acd5}|warm}", weight: 1, sub_categories: [] };
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
// The inner @{uuid} arm renders as a resolved ref chip, not raw uuid text.
const ref = w.find(".opt__tok--ref");
expect(ref.exists()).toBe(true);
expect(ref.text()).toContain("@mood");
expect(w.find(".opt__tok--multi").exists()).toBe(true); // scaffolding coloured
expect(w.find('[data-test="opt-name"]').text()).not.toContain("ad00acd5");
});
it("shows the null-excluded ban mark on an inner ref carrying !null (SP2b #1)", () => {
_setForTests("ad00acd5", { name: "mood", varBinding: "mood" });
const opt = { id: "b3", value: "{3$$, $$@{ad00acd5#mood!null}}", weight: 1, sub_categories: [] };
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
const ref = w.find(".opt__tok--ref");
expect(ref.exists()).toBe(true);
// The leaked `!null` is detected → ban (null-excluded) indicator shows.
expect(ref.find(".opt__tok-filter").exists()).toBe(true);
expect(ref.find(".opt__tok-nonull").exists()).toBe(true);
// Label is the clean name, not `@mood!null`.
expect(ref.text()).toContain("@mood");
expect(ref.text()).not.toContain("!null");
});
it("decomposes a plain {a|@{uuid}} alternation into an inner ref chip (SP2b #6)", () => {
_setForTests("ad00acd5", { name: "hair", varBinding: "hair" });
const opt = { id: "b2", value: "{warm|@{ad00acd5}}", weight: 1, sub_categories: [] };
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
expect(w.find(".opt__tok--ref").exists()).toBe(true);
expect(w.find(".opt__tok--brace").exists()).toBe(true);
expect(w.find('[data-test="opt-name"]').text()).not.toContain("ad00acd5");
});
it("checkbox is checked when enabled_options is null (library default)", () => {
@@ -103,15 +147,15 @@ describe("OptionRow", () => {
it("category-filtered row gets opt--filtered modifier class", () => {
const w = mount(OptionRow, {
props: { option: baseOption, allOptions, instance: { category_filter: ["cool"] } },
props: { option: baseOption, allOptions, instance: { category_filter: "cool" } },
});
// baseOption.sub_category = "warm", filter only allows "cool"
// baseOption.sub_categories = ["warm"], filter only allows "cool"
expect(w.classes()).toContain("opt--filtered");
});
it("category-filtered row does NOT emit toggle on checkbox click", async () => {
const w = mount(OptionRow, {
props: { option: baseOption, allOptions, instance: { category_filter: ["cool"] } },
props: { option: baseOption, allOptions, instance: { category_filter: "cool" } },
});
await w.find('[data-test="opt-check"]').trigger("click");
expect(w.emitted("toggle")).toBeUndefined();
@@ -119,7 +163,7 @@ describe("OptionRow", () => {
it("category-filtered row exposes aria-disabled=true on checkbox", () => {
const w = mount(OptionRow, {
props: { option: baseOption, allOptions, instance: { category_filter: ["cool"] } },
props: { option: baseOption, allOptions, instance: { category_filter: "cool" } },
});
expect(w.find('[data-test="opt-check"]').attributes("aria-disabled")).toBe("true");
});
@@ -142,9 +186,6 @@ describe("OptionRow", () => {
});
it("down spinner clamps weight to floor 0.01 (no zero/negative weights)", async () => {
// Weight 0 / negative never picks (probability normalises away),
// so the editor floors at 0.01. To disable an option entirely,
// use the per-row toggle — engine respects that.
const w = mount(OptionRow, {
props: { option: baseOption, allOptions, instance: { option_weights: { o1: 0 } } },
});
@@ -163,40 +204,60 @@ describe("OptionRow", () => {
});
it("renders @{uuid} ref token with raw uuid form when name not yet cached", () => {
const opt = { id: "o9", value: "city @{a361dbdc} dusk", weight: 1, sub_category: "warm" };
const opt = { id: "o9", value: "city @{a361dbdc} dusk", weight: 1, sub_categories: ["warm"] };
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
const ref = w.find('[data-test="opt-name"] .opt__tok--ref');
expect(ref.exists()).toBe(true);
expect(ref.attributes("data-uuid")).toBe("a361dbdc");
// Until preview-resolver lands the lookup, raw form is the fallback.
// Read the identity-text span so the chip icon prefix doesn't bleed in.
expect(ref.find(".opt__tok-label").text()).toBe("@{a361dbdc}");
});
it("renders @{uuid} ref as @<varBinding> once resolver caches the entry", () => {
_setForTests("a361dbdc", { name: "subject", varBinding: "subject_name" });
const opt = { id: "o9", value: "city @{a361dbdc} dusk", weight: 1, sub_category: "warm" };
const opt = { id: "o9", value: "city @{a361dbdc} dusk", weight: 1, sub_categories: ["warm"] };
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
const ref = w.find('[data-test="opt-name"] .opt__tok--ref');
expect(ref.find(".opt__tok-label").text()).toBe("@subject_name");
});
it("renders a v2 boolean ref filter as funnel + ban, not inline !null text", () => {
_setForTests("a361dbdc", { name: "mood", varBinding: "mood" });
const opt = {
id: "o9",
value: "wolf @{a361dbdc:warm or intense!null}",
weight: 1,
sub_categories: ["canine"],
};
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
const ref = w.find('[data-test="opt-name"] .opt__tok--ref');
expect(ref.exists()).toBe(true);
// The raw expression + `!null` marker must NOT appear inline anymore.
expect(ref.text()).not.toContain("!null");
expect(ref.text()).not.toContain("warm or intense");
// Compact indicators instead — funnel (expression) + ban (exclude-null),
// matching the SPA RefChip grammar.
expect(ref.find(".opt__tok-funnel").exists()).toBe(true);
expect(ref.find(".opt__tok-nonull").exists()).toBe(true);
// Full normalized expression lives in the hover title, `!null` peeled.
expect(ref.attributes("title")).toContain("warm or intense");
expect(ref.attributes("title")).not.toContain("!null");
});
it("renders {a|b|c} brace block as a brace token (warn colour)", () => {
const opt = { id: "o9", value: "color {a|b|c}", weight: 1, sub_category: "warm" };
const opt = { id: "o9", value: "color {a|b|c}", weight: 1, sub_categories: ["warm"] };
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
expect(w.find('[data-test="opt-name"] .opt__tok--brace').exists()).toBe(true);
});
it("renders {N$$,$$a|b|c} multi-pick block as a multi token (distinct from brace)", () => {
const opt = { id: "o10", value: "{2$$,$$silver|gold|pearl}", weight: 1, sub_category: "warm" };
const opt = { id: "o10", value: "{2$$,$$silver|gold|pearl}", weight: 1, sub_categories: ["warm"] };
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
expect(w.find('[data-test="opt-name"] .opt__tok--multi').exists()).toBe(true);
// Brace-class should NOT match — multi gets its own visual treatment.
expect(w.find('[data-test="opt-name"] .opt__tok--brace').exists()).toBe(false);
});
it("renders $varname as a var token", () => {
const opt = { id: "o9", value: "uses $style here", weight: 1, sub_category: "warm" };
const opt = { id: "o9", value: "uses $style here", weight: 1, sub_categories: ["warm"] };
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
expect(w.find('[data-test="opt-name"] .opt__tok--var .opt__tok-label').text()).toBe("$style");
});
@@ -206,7 +267,6 @@ describe("OptionRow", () => {
props: { option: baseOption, allOptions, instance: { option_weights: { o1: 1.8 } } },
});
const input = w.find<HTMLInputElement>('[data-test="opt-weight"]');
// baseOption.weight = 1 — matching library default should drop override
input.element.value = "1";
await input.trigger("input");
expect(w.emitted("weight")?.[0]).toEqual(["o1", null]);
@@ -225,12 +285,27 @@ describe("OptionRow", () => {
props: { option: baseOption, allOptions, instance: { option_weights: { o1: 1.1 } } },
});
await w.find('[data-test="opt-weight-down"]').trigger("click");
// 1.1 - 0.1 = 1.0 = library default → cleared
expect(w.emitted("weight")?.[0]).toEqual(["o1", null]);
});
it("colors a category chip by its tag_groups axis when provided", () => {
const opt = { id: "m", value: "lynx", weight: 1, sub_categories: ["feline", "warm"] };
const w = mount(OptionRow, {
props: {
option: opt,
allOptions: [opt],
instance: {},
tagGroups: { family: ["feline"], temp: ["warm"] },
},
});
// Two distinct axes → the two chips carry different axis style hooks.
const feline = w.find('[data-test="opt-cat-feline"]');
const warm = w.find('[data-test="opt-cat-warm"]');
expect(feline.attributes("style")).not.toBe(warm.attributes("style"));
});
describe("null option row", () => {
const nullOpt = { id: "n1", value: "", weight: 1, is_null: true } as typeof baseOption & { is_null: boolean };
const nullOpt = { id: "n1", value: "", weight: 1, is_null: true, sub_categories: [] };
it("renders the pi-ban chip in place of tokens", () => {
const w = mount(OptionRow, {
@@ -238,15 +313,14 @@ describe("OptionRow", () => {
});
expect(w.find(".opt__null-chip").exists()).toBe(true);
expect(w.find(".opt__null-chip .pi-ban").exists()).toBe(true);
// Label literal "null" inside the chip.
expect(w.find(".opt__null-chip").text()).toMatch(/null/i);
});
it("sub-category cell is empty", () => {
it("category cell renders no sub-category chips", () => {
const w = mount(OptionRow, {
props: { option: nullOpt, allOptions: [nullOpt], instance: {} },
});
expect(w.find('[data-test="opt-cat"]').text()).toBe("");
expect(w.findAll('[data-test^="opt-cat-"]')).toHaveLength(0);
});
it("weight + check still render", () => {
@@ -256,5 +330,42 @@ describe("OptionRow", () => {
expect(w.find('[data-test="opt-weight"]').exists()).toBe(true);
expect(w.find('[data-test="opt-check"]').exists()).toBe(true);
});
it("null row is disabled (opt--off) when exclude_null is set", () => {
const w = mount(OptionRow, {
props: { option: nullOpt, allOptions: [nullOpt], instance: { exclude_null: true } },
});
expect(w.classes()).toContain("opt--off");
});
it("null row is disabled + non-toggleable when multiActive (SP2a)", () => {
const w = mount(OptionRow, {
props: { option: nullOpt, allOptions: [nullOpt], instance: {}, multiActive: true },
});
expect(w.classes()).toContain("opt--off");
expect(w.find('[data-test="opt-check"]').attributes("aria-disabled")).toBe("true");
// The checkbox must not READ as checked — the bug was the glyph + tick
// bound to `enabled` alone, leaving a filled blue box on the disabled
// null row in multi-pick.
expect(w.find('[data-test="opt-check"]').classes()).not.toContain("opt__check--on");
expect(w.find(".opt__check-tick").exists()).toBe(false);
});
it("null row stays interactive when not multiActive", () => {
const w = mount(OptionRow, {
props: { option: nullOpt, allOptions: [nullOpt], instance: {} },
});
expect(w.find('[data-test="opt-check"]').attributes("aria-disabled")).toBe("false");
});
});
it("real option row is unaffected by multiActive", () => {
const w = mount(OptionRow, {
props: { option: baseOption, allOptions, instance: {}, multiActive: true },
});
expect(w.find('[data-test="opt-check"]').attributes("aria-disabled")).toBe("false");
// Not over-gated: a real (enabled, non-null) option keeps its checked
// glyph even while a sibling null row is locked out of the multi pool.
expect(w.find('[data-test="opt-check"]').classes()).toContain("opt__check--on");
});
});
@@ -0,0 +1,388 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import PoolSection from "../sections/PoolSection.vue";
import type { ModuleEntry } from "@/widgets/_shared";
const module = {
payload: {
options: [
{ id: "a", value: "cat", weight: 1, sub_categories: ["feline", "warm"] },
{ id: "b", value: "wolf", weight: 1, sub_categories: ["canine", "cold"] },
],
sub_categories: ["feline", "canine", "warm", "cold"],
tag_groups: { family: ["feline", "canine"], temp: ["warm", "cold"] },
},
instance: {},
} as unknown as ModuleEntry;
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "ab12cd34",
type: "wildcard",
enabled: true,
meta: { name: "outfit" },
entries: [],
payload: {
var_binding: "outfit",
sub_categories: ["feline", "canine", "warm", "cold"],
tag_groups: { family: ["feline", "canine"], temp: ["warm", "cold"] },
options: [
{ id: "a", value: "cat", weight: 1, sub_categories: ["feline", "warm"] },
{ id: "b", value: "wolf", weight: 1, sub_categories: ["canine", "cold"] },
{ id: "c", value: "owl", weight: 1, sub_categories: ["warm"] },
],
},
instance: {},
...overrides,
} as unknown as ModuleEntry;
}
function lastPatch(w: ReturnType<typeof mount>): Partial<ModuleEntry> {
const updates = w.emitted("update")!;
return updates[updates.length - 1][0] as Partial<ModuleEntry>;
}
describe("PoolSection pick-count (SP2a)", () => {
it("min stepper emits a pick_min update", async () => {
const w = mount(PoolSection, { props: { module: makeModule() } });
const min = w.get('[data-test="pool-pick-min"]');
(min.element as HTMLInputElement).value = "2";
await min.trigger("input");
expect(lastPatch(w).instance?.pick_min).toBe(2);
});
it("max stepper emits a pick_max update", async () => {
const w = mount(PoolSection, { props: { module: makeModule() } });
const max = w.get('[data-test="pool-pick-max"]');
(max.element as HTMLInputElement).value = "3";
await max.trigger("input");
expect(lastPatch(w).instance?.pick_max).toBe(3);
});
it("'Allow repeats' toggle hides for single-pick, shows for multi + emits pick_independent (SP2c)", async () => {
// Single-pick (default 1..1): toggle irrelevant → hidden.
const single = mount(PoolSection, { props: { module: makeModule() } });
expect(single.find('[data-test="pool-allow-repeats"]').exists()).toBe(false);
// Multi-pick (1..3): toggle shows; checking it emits pick_independent=true.
const w = mount(PoolSection, {
props: { module: makeModule({ instance: { pick_min: 1, pick_max: 3 } } as Partial<ModuleEntry>) },
});
const toggle = w.find('[data-test="pool-allow-repeats"]');
expect(toggle.exists()).toBe(true);
await toggle.find(".wp-check").trigger("click"); // shared Checkbox toggles on box-click
expect(lastPatch(w).instance?.pick_independent).toBe(true);
});
it("clamps max UP to min when min is set above max (SP2a — no inverted range)", async () => {
const w = mount(PoolSection, {
props: { module: makeModule({ instance: { pick_min: 1, pick_max: 2 } } as Partial<ModuleEntry>) },
});
const min = w.get('[data-test="pool-pick-min"]');
(min.element as HTMLInputElement).value = "5";
await min.trigger("input");
const inst = lastPatch(w).instance;
expect(inst?.pick_min).toBe(5);
expect(inst?.pick_max).toBe(5);
});
it("clamps min DOWN to max when max is set below min (SP2a — no inverted range)", async () => {
const w = mount(PoolSection, {
props: { module: makeModule({ instance: { pick_min: 3, pick_max: 4 } } as Partial<ModuleEntry>) },
});
const max = w.get('[data-test="pool-pick-max"]');
(max.element as HTMLInputElement).value = "1";
await max.trigger("input");
const inst = lastPatch(w).instance;
expect(inst?.pick_max).toBe(1);
expect(inst?.pick_min).toBe(1);
});
it("min up-arrow button bumps pick_min by 1 (SP2a)", async () => {
const w = mount(PoolSection, {
props: { module: makeModule({ instance: { pick_min: 1, pick_max: 3 } } as Partial<ModuleEntry>) },
});
await w.get('[data-test="pool-pick-min-up"]').trigger("click");
expect(lastPatch(w).instance?.pick_min).toBe(2);
});
it("enabled count excludes the null option in multi-pick (SP2a)", () => {
const m = {
id: "x", type: "wildcard", enabled: true, meta: { name: "w" }, entries: [],
payload: {
var_binding: "w", sub_categories: [], tag_groups: {},
options: [
{ id: "a", value: "cat", weight: 1, sub_categories: [] },
{ id: "n", value: "", weight: 1, is_null: true, sub_categories: [] },
],
},
instance: { pick_min: 1, pick_max: 3 },
} as unknown as ModuleEntry;
const w = mount(PoolSection, { props: { module: m } });
// Only "a" counts — null is dropped from the multi pool (was wrongly 2 of 2).
expect(w.find('[data-test="pool-summary"]').text()).toMatch(/1\s*of\s*2/);
});
it("separator input appears only when max > 1", () => {
const single = mount(PoolSection, { props: { module: makeModule() } });
expect(single.find('[data-test="pool-pick-sep"]').exists()).toBe(false);
const multi = mount(PoolSection, {
props: { module: makeModule({ instance: { pick_min: 1, pick_max: 3 } } as Partial<ModuleEntry>) },
});
expect(multi.find('[data-test="pool-pick-sep"]').exists()).toBe(true);
});
it("disables exclude_null when multi-pick is active", () => {
const m = {
id: "x", type: "wildcard", enabled: true, meta: { name: "w" }, entries: [],
payload: {
var_binding: "w", sub_categories: [], tag_groups: {},
options: [
{ id: "a", value: "cat", weight: 1, sub_categories: [] },
{ id: "n", value: "", weight: 1, is_null: true, sub_categories: [] },
],
},
instance: { pick_min: 0, pick_max: 2 },
} as unknown as ModuleEntry;
const w = mount(PoolSection, { props: { module: m } });
// Inline wp-check span; locked state surfaces as aria-disabled.
const cb = w.get('[data-test="pool-exclude-null"] .wp-check');
expect(cb.attributes("aria-disabled")).toBe("true");
});
it("pool summary surfaces the pick range when multi-pick (SP2a)", () => {
const w = mount(PoolSection, {
props: { module: makeModule({ instance: { pick_min: 1, pick_max: 3 } } as Partial<ModuleEntry>) },
});
const picks = w.find('[data-test="pool-summary-picks"]');
expect(picks.exists()).toBe(true);
expect(picks.text()).toBe("1–3");
});
it("pool summary omits the pick range in single-pick mode", () => {
const w = mount(PoolSection, { props: { module: makeModule() } });
expect(w.find('[data-test="pool-summary-picks"]').exists()).toBe(false);
});
});
describe("PoolSection grouped quick pills", () => {
it("ticking family=feline + temp=warm builds 'feline and warm'", async () => {
const w = mount(PoolSection, { props: { module } });
await w.get('[data-test="cat-chip-feline"]').trigger("click");
await w.get('[data-test="cat-chip-warm"]').trigger("click");
const updates = w.emitted("update")!;
const patch = updates[updates.length - 1]![0] as Partial<ModuleEntry>;
expect(patch.instance?.category_filter).toBe("feline and warm");
});
it("shows an Advanced toggle that reveals the expression editor", async () => {
const w = mount(PoolSection, { props: { module } });
await w.get('[data-test="pool-advanced"]').trigger("click");
expect(w.find('[data-test="expr-input"]').exists()).toBe(true);
});
it("advanced palette inserts a sub-category token at the cursor (#7)", async () => {
const w = mount(PoolSection, { props: { module } });
await w.get('[data-test="pool-advanced"]').trigger("click");
await w.get('[data-test="pool-ins-feline"]').trigger("click");
expect(lastPatch(w).instance?.category_filter).toBe("feline");
});
it("ticking two tags in the same group ORs them inside parens", async () => {
const w = mount(PoolSection, { props: { module } });
await w.get('[data-test="cat-chip-feline"]').trigger("click");
await w.get('[data-test="cat-chip-canine"]').trigger("click");
expect(lastPatch(w).instance?.category_filter).toBe("(feline or canine)");
});
it("multi-term group AND'd with a single-term group keeps parens only on the multi", async () => {
const w = mount(PoolSection, { props: { module } });
await w.get('[data-test="cat-chip-feline"]').trigger("click");
await w.get('[data-test="cat-chip-canine"]').trigger("click");
await w.get('[data-test="cat-chip-warm"]').trigger("click");
expect(lastPatch(w).instance?.category_filter).toBe("(feline or canine) and warm");
});
it("unticking the last tag clears the filter to empty string", async () => {
const w = mount(PoolSection, {
props: { module: makeModule({ instance: { category_filter: "feline" } }) },
});
await w.get('[data-test="cat-chip-feline"]').trigger("click");
expect(lastPatch(w).instance?.category_filter).toBe("");
});
it("renders pills grouped under their axis headings", () => {
const w = mount(PoolSection, { props: { module } });
const groups = w.findAll('[data-test^="pool-group-"]');
// family + temp = two axis clusters.
expect(groups.length).toBe(2);
});
it("places ungrouped tags into an 'other' cluster", () => {
const w = mount(PoolSection, {
props: {
module: {
payload: {
options: [{ id: "a", value: "x", weight: 1, sub_categories: ["loose"] }],
sub_categories: ["feline", "loose"],
tag_groups: { family: ["feline"] },
},
instance: {},
} as unknown as ModuleEntry,
},
});
expect(w.find('[data-test="pool-group-other"]').exists()).toBe(true);
expect(w.find('[data-test="cat-chip-loose"]').exists()).toBe(true);
});
});
describe("PoolSection advanced expression editor", () => {
it("prefills the expression input from the existing category_filter", async () => {
const w = mount(PoolSection, {
props: { module: makeModule({ instance: { category_filter: "feline and warm" } }) },
});
await w.get('[data-test="pool-advanced"]').trigger("click");
const input = w.get<HTMLInputElement>('[data-test="expr-input"]').element;
expect(input.value).toBe("feline and warm");
});
it("emits category_filter when a valid expression is typed", async () => {
const w = mount(PoolSection, { props: { module: makeModule() } });
await w.get('[data-test="pool-advanced"]').trigger("click");
const input = w.get<HTMLInputElement>('[data-test="expr-input"]');
input.element.value = "not canine";
await input.trigger("input");
expect(lastPatch(w).instance?.category_filter).toBe("not canine");
});
it("does NOT emit while the expression is invalid", async () => {
const w = mount(PoolSection, { props: { module: makeModule() } });
await w.get('[data-test="pool-advanced"]').trigger("click");
const input = w.get<HTMLInputElement>('[data-test="expr-input"]');
input.element.value = "feline and";
await input.trigger("input");
expect(w.emitted("update")).toBeUndefined();
});
it("flags an unknown sub-category as invalid", async () => {
const w = mount(PoolSection, { props: { module: makeModule() } });
await w.get('[data-test="pool-advanced"]').trigger("click");
const input = w.get<HTMLInputElement>('[data-test="expr-input"]');
input.element.value = "zebra";
await input.trigger("input");
expect(w.find('[data-test="expr-error"]').exists()).toBe(true);
expect(w.emitted("update")).toBeUndefined();
});
it("shows a 'reads as' normalized preview for a valid expression", async () => {
const w = mount(PoolSection, {
props: { module: makeModule({ instance: { category_filter: "feline,warm" } }) },
});
await w.get('[data-test="pool-advanced"]').trigger("click");
// Comma is OR sugar — reads-as normalizes it to the word form.
expect(w.get('[data-test="expr-reads-as"]').text()).toContain("feline or warm");
});
});
describe("PoolSection exclude-null toggle", () => {
// The toggle only renders when the wildcard has a null option (#5), so
// append one to the fixture for the toggle/reflect cases.
function withNullOption(m: ModuleEntry): ModuleEntry {
(m.payload as { options: Array<Record<string, unknown>> }).options.push({
id: "n", value: "", weight: 1, is_null: true,
});
return m;
}
it("emits exclude_null=true when toggled on", async () => {
const w = mount(PoolSection, { props: { module: withNullOption(makeModule()) } });
// Clicking the shared Checkbox box toggles exclude_null (#4).
await w.get('[data-test="pool-exclude-null"] .wp-check').trigger("click");
expect(lastPatch(w).instance?.exclude_null).toBe(true);
});
it("reflects an existing exclude_null instance value", () => {
const w = mount(PoolSection, {
props: { module: withNullOption(makeModule({ instance: { exclude_null: true } })) },
});
// "On" reads off aria-checked on the wp-check span.
const cb = w.get('[data-test="pool-exclude-null"] .wp-check');
expect(cb.attributes("aria-checked")).toBe("true");
});
it("hides the toggle entirely when the wildcard has no null option (#5)", () => {
const w = mount(PoolSection, { props: { module: makeModule() } });
expect(w.find('[data-test="pool-exclude-null"]').exists()).toBe(false);
});
it("toggling the null option row drives exclude_null, not enabled_options (#3)", async () => {
const w = mount(PoolSection, { props: { module: withNullOption(makeModule()) } });
// The appended null option renders as the last row; unchecking its box is
// the same control as "Exclude null", so it must flip exclude_null.
const checks = w.findAll('[data-test="opt-check"]');
await checks[checks.length - 1].trigger("click");
const patch = lastPatch(w);
expect(patch.instance?.exclude_null).toBe(true);
expect(patch.instance?.enabled_options).toBeUndefined();
});
});
describe("PoolSection option rows", () => {
it("renders one OptionRow per payload.options entry", () => {
const w = mount(PoolSection, { props: { module: makeModule() } });
const rows = w.findAllComponents({ name: "OptionRow" });
expect(rows).toHaveLength(3);
});
it("OptionRow toggle event emits enabled_options patch", async () => {
const w = mount(PoolSection, { props: { module: makeModule() } });
const firstRow = w.findComponent({ name: "OptionRow" });
firstRow.vm.$emit("toggle", "a");
await w.vm.$nextTick();
// a was enabled by default → toggle disables it → enabled_options = ["b", "c"]
expect(lastPatch(w).instance?.enabled_options).toEqual(["b", "c"]);
});
it("toggling all options back on emits enabled_options=null", async () => {
const w = mount(PoolSection, {
props: { module: makeModule({ instance: { enabled_options: ["a", "b"] } }) },
});
const rows = w.findAllComponents({ name: "OptionRow" });
rows[2].vm.$emit("toggle", "c");
await w.vm.$nextTick();
expect(lastPatch(w).instance?.enabled_options).toBeNull();
});
it("OptionRow weight event emits option_weights patch", async () => {
const w = mount(PoolSection, { props: { module: makeModule() } });
const firstRow = w.findComponent({ name: "OptionRow" });
firstRow.vm.$emit("weight", "a", 2.5);
await w.vm.$nextTick();
expect(lastPatch(w).instance?.option_weights).toEqual({ a: 2.5 });
});
it("weight = null deletes the entry from option_weights map", async () => {
const w = mount(PoolSection, {
props: { module: makeModule({ instance: { option_weights: { a: 2.5 } } }) },
});
const firstRow = w.findComponent({ name: "OptionRow" });
firstRow.vm.$emit("weight", "a", null);
await w.vm.$nextTick();
expect(lastPatch(w).instance?.option_weights).toEqual({});
});
it("passes tag_groups down to each OptionRow for axis colouring", () => {
const w = mount(PoolSection, { props: { module: makeModule() } });
const firstRow = w.findComponent({ name: "OptionRow" });
expect(firstRow.props("tagGroups")).toEqual({
family: ["feline", "canine"],
temp: ["warm", "cold"],
});
});
it("summary shows enabled count", () => {
const w = mount(PoolSection, {
props: { module: makeModule({ instance: { enabled_options: ["a", "b"] } }) },
});
expect(w.find('[data-test="pool-summary"]').text()).toContain("2 of 3");
});
});
@@ -0,0 +1,155 @@
import { describe, it, expect } from "vitest";
import {
effectiveWeight,
isEnabled,
probabilityFor,
type InstanceLike,
type WildcardOption,
} from "../probability";
const opt = (sc: string[], extra: Partial<WildcardOption> = {}): WildcardOption => ({
id: "o",
weight: 1,
sub_categories: sc,
...extra,
});
describe("isEnabled multi-tag", () => {
it("matches the boolean category_filter expression (any/and/not)", () => {
expect(isEnabled(opt(["warm"]), { category_filter: "warm or cold" })).toBe(true);
expect(isEnabled(opt(["warm"]), { category_filter: "warm and cold" })).toBe(false);
expect(isEnabled(opt(["warm"]), { category_filter: "" })).toBe(true);
expect(isEnabled(opt([]), { category_filter: "not cold" })).toBe(true);
});
it("null option governed by exclude_null", () => {
const nul = { id: "n", value: "", weight: 1, is_null: true, sub_categories: [] };
expect(isEnabled(nul, { category_filter: "warm", exclude_null: false })).toBe(true);
expect(isEnabled(nul, { exclude_null: true })).toBe(false);
});
it("null option ignores enabled_options — only exclude_null gates it (#3)", () => {
const nul = { id: "n", value: "", weight: 1, is_null: true, sub_categories: [] };
// enabled_options excludes the null id, but the null slot is orthogonal:
// it stays enabled while exclude_null is false, and only drops when set.
expect(isEnabled(nul, { enabled_options: ["other"], exclude_null: false })).toBe(true);
expect(isEnabled(nul, { enabled_options: ["other"], exclude_null: true })).toBe(false);
});
it("drops the null option entirely in multi-pick, ignoring exclude_null (SP2a)", () => {
const nul = { id: "n", value: "", weight: 1, is_null: true, sub_categories: [] };
// single-pick: governed by exclude_null
expect(isEnabled(nul, { exclude_null: false })).toBe(true);
// multi-pick (3rd arg true): always dropped — the engine excludes it,
// "maybe nothing" is pick_min=0.
expect(isEnabled(nul, { exclude_null: false }, true)).toBe(false);
});
it("drops the null option from the probability denominator in multi-pick (SP2a)", () => {
const a = opt([], { id: "a" });
const nul = { id: "n", value: "", weight: 1, is_null: true, sub_categories: [] };
const opts = [nul, a];
// single-pick: null + a split the per-draw weight 50/50
expect(probabilityFor(a, opts, {})).toBeCloseTo(0.5, 2);
// multi-pick: null excluded → a takes 100% of the denominator, null is 0
expect(probabilityFor(a, opts, {}, true)).toBeCloseTo(1.0, 2);
expect(probabilityFor(nul, opts, {}, true)).toBe(0);
});
it("matches a multi-tag option against an AND across axes", () => {
// Option carries both tags → satisfies "feline and warm".
expect(isEnabled(opt(["feline", "warm"]), { category_filter: "feline and warm" })).toBe(true);
// Option missing one of the AND'd tags → excluded.
expect(isEnabled(opt(["feline", "cold"]), { category_filter: "feline and warm" })).toBe(false);
});
it("returns true when category_filter is absent (library default = no filter)", () => {
expect(isEnabled(opt(["warm"]), {})).toBe(true);
expect(isEnabled(opt([]), {})).toBe(true);
});
it("returns true when category_filter is null", () => {
expect(isEnabled(opt(["neutral"]), { category_filter: null })).toBe(true);
});
it("combines with enabled_options (intersection)", () => {
expect(
isEnabled(opt(["warm"], { id: "o1" }), {
enabled_options: ["o2"],
category_filter: "warm",
}),
).toBe(false);
expect(
isEnabled(opt(["warm"], { id: "o1" }), {
enabled_options: ["o1"],
category_filter: "warm",
}),
).toBe(true);
});
it("returns false when option id is NOT in enabled_options array", () => {
expect(isEnabled(opt(["warm"], { id: "o3" }), { enabled_options: ["o1", "o2"] })).toBe(false);
});
it("null option stays in the pool under a category_filter (exclude_null governs it)", () => {
const nul = { id: "n", value: "", weight: 1, is_null: true, sub_categories: [] };
// A sub-cat filter doesn't strip the null slot — only exclude_null does.
expect(isEnabled(nul, { category_filter: "warm and cold" })).toBe(true);
});
});
describe("effectiveWeight", () => {
it("returns library default when no override", () => {
expect(effectiveWeight(opt([], { weight: 2.5 }), {})).toBe(2.5);
});
it("returns instance override when present", () => {
expect(effectiveWeight(opt([], { id: "o1", weight: 1 }), { option_weights: { o1: 1.8 } })).toBe(1.8);
});
it("falls back to 1.0 when neither library weight nor override is set", () => {
expect(effectiveWeight({ id: "o1", sub_categories: [] }, {})).toBe(1.0);
});
});
describe("probabilityFor", () => {
const opts: WildcardOption[] = [
{ id: "a", weight: 1, sub_categories: [] },
{ id: "b", weight: 1, sub_categories: [] },
{ id: "c", weight: 1, sub_categories: [] },
];
it("uniform weights, all enabled → equal probability", () => {
expect(probabilityFor(opts[0], opts, {})).toBeCloseTo(0.333, 2);
});
it("disabled option → 0", () => {
expect(probabilityFor(opts[2], opts, { enabled_options: ["a", "b"] })).toBe(0);
});
it("skewed weight gets higher probability", () => {
const inst: InstanceLike = { option_weights: { a: 2 } };
// total enabled weight = 2 + 1 + 1 = 4. a gets 2/4 = 0.5.
expect(probabilityFor(opts[0], opts, inst)).toBeCloseTo(0.5, 2);
expect(probabilityFor(opts[1], opts, inst)).toBeCloseTo(0.25, 2);
});
it("single option enabled → probability 1.0 (effectively pinned)", () => {
expect(probabilityFor(opts[0], opts, { enabled_options: ["a"] })).toBe(1.0);
});
it("all disabled → all probabilities 0", () => {
expect(probabilityFor(opts[0], opts, { enabled_options: [] })).toBe(0);
});
it("category filter excludes options from totals", () => {
const catOpts: WildcardOption[] = [
{ id: "a", weight: 1, sub_categories: ["warm"] },
{ id: "b", weight: 1, sub_categories: ["warm"] },
{ id: "c", weight: 1, sub_categories: ["cool"] },
];
// category_filter: "warm" → only a and b count. each gets 1/2 = 0.5.
expect(probabilityFor(catOpts[0], catOpts, { category_filter: "warm" })).toBeCloseTo(0.5, 2);
expect(probabilityFor(catOpts[2], catOpts, { category_filter: "warm" })).toBe(0);
});
});
@@ -1,93 +0,0 @@
import { describe, it, expect } from "vitest";
import { effectiveWeight, isEnabled, probabilityFor } from "./probability";
interface Opt { id: string; weight?: number; sub_category?: string }
type Instance = {
enabled_options?: string[] | null;
option_weights?: Record<string, number> | null;
category_filter?: string[] | null;
};
describe("probability helpers", () => {
describe("isEnabled", () => {
it("returns true when enabled_options is null (library default = all enabled)", () => {
expect(isEnabled({ id: "o1" }, {})).toBe(true);
});
it("returns true when option id is in enabled_options array", () => {
expect(isEnabled({ id: "o1" }, { enabled_options: ["o1", "o2"] })).toBe(true);
});
it("returns false when option id is NOT in enabled_options array", () => {
expect(isEnabled({ id: "o3" }, { enabled_options: ["o1", "o2"] })).toBe(false);
});
it("returns false when sub_category is filtered out", () => {
expect(isEnabled({ id: "o1", sub_category: "neutral" }, { category_filter: ["warm", "cool"] })).toBe(false);
});
it("returns true when sub_category is in category_filter", () => {
expect(isEnabled({ id: "o1", sub_category: "warm" }, { category_filter: ["warm", "cool"] })).toBe(true);
});
it("ignores category_filter when it is null", () => {
expect(isEnabled({ id: "o1", sub_category: "neutral" }, { category_filter: null })).toBe(true);
});
});
describe("effectiveWeight", () => {
it("returns library default when no override", () => {
expect(effectiveWeight({ id: "o1", weight: 2.5 }, {})).toBe(2.5);
});
it("returns instance override when present", () => {
expect(effectiveWeight({ id: "o1", weight: 1 }, { option_weights: { o1: 1.8 } })).toBe(1.8);
});
it("falls back to 1.0 when neither library weight nor override is set", () => {
expect(effectiveWeight({ id: "o1" }, {})).toBe(1.0);
});
});
describe("probabilityFor", () => {
const opts: Opt[] = [
{ id: "a", weight: 1 },
{ id: "b", weight: 1 },
{ id: "c", weight: 1 },
];
it("uniform weights, all enabled → equal probability", () => {
expect(probabilityFor(opts[0], opts, {})).toBeCloseTo(0.333, 2);
});
it("disabled option → 0", () => {
expect(probabilityFor(opts[2], opts, { enabled_options: ["a", "b"] })).toBe(0);
});
it("skewed weight gets higher probability", () => {
const inst: Instance = { option_weights: { a: 2 } };
// total enabled weight = 2 + 1 + 1 = 4. a gets 2/4 = 0.5.
expect(probabilityFor(opts[0], opts, inst)).toBeCloseTo(0.5, 2);
expect(probabilityFor(opts[1], opts, inst)).toBeCloseTo(0.25, 2);
});
it("single option enabled → probability 1.0 (effectively pinned)", () => {
expect(probabilityFor(opts[0], opts, { enabled_options: ["a"] })).toBe(1.0);
});
it("all disabled → all probabilities 0", () => {
expect(probabilityFor(opts[0], opts, { enabled_options: [] })).toBe(0);
});
it("category filter excludes options from totals", () => {
const catOpts: Opt[] = [
{ id: "a", weight: 1, sub_category: "warm" },
{ id: "b", weight: 1, sub_category: "warm" },
{ id: "c", weight: 1, sub_category: "cool" },
];
// category_filter: ["warm"] → only a and b count. each gets 1/2 = 0.5.
expect(probabilityFor(catOpts[0], catOpts, { category_filter: ["warm"] })).toBeCloseTo(0.5, 2);
expect(probabilityFor(catOpts[2], catOpts, { category_filter: ["warm"] })).toBe(0);
});
});
});
@@ -6,15 +6,24 @@
* Mirrors the engine's pick semantics in `engine/modules/wildcard_handler.py`:
* - `enabled_options` null → all options eligible (library default)
* - `enabled_options` array → only listed ids eligible
* - `category_filter` array → restrict to options whose sub_category is in the list
* - `category_filter` boolean expression → restrict to options whose
* `sub_categories` satisfy the parsed expression (OR/AND/NOT over tags).
* Empty/null/absent = no filter. See the multi-tag boolean-filter design
* `docs/superpowers/specs/2026-06-06-wildcard-multi-subcategory-boolean-filter-design.md`.
* - `option_weights[id]` overrides library `option.weight`, else fall back to 1.0
*
* See: docs/superpowers/specs/2026-05-08-instance-overrides-v2-design.md §8.3
*/
import { matches, parse } from "@/manager/parsing/subcatFilter";
export interface WildcardOption {
id: string;
weight?: number;
sub_category?: string;
/** Sub-category tags this option belongs to. Multi-tag (v2): an option
* can sit on several axes at once (e.g. `["feline", "warm"]`). Empty
* array = untagged. The boolean `category_filter` matches against this
* set via the shared parser. */
sub_categories: string[];
/** Marks the single optional "null option" per wildcard — picks
* resolve to empty string. See spec
* `docs/superpowers/specs/2026-05-24-null-wildcard-option-design.md`. */
@@ -24,23 +33,35 @@ export interface WildcardOption {
export interface InstanceLike {
enabled_options?: string[] | null;
option_weights?: Record<string, number> | null;
category_filter?: string[] | null;
/** Boolean sub-category expression (e.g. `"feline and warm"`, `"not lynx"`).
* Empty/null/absent = no filter. Parsed + matched via the shared parser. */
category_filter?: string | null;
/** When true the null option is dropped from the pool. The sub-category
* `category_filter` never strips the null slot — only this toggle does
* (the null option carries no tags, so it's orthogonal to the filter). */
exclude_null?: boolean;
}
export function isEnabled(option: WildcardOption, instance: InstanceLike): boolean {
export function isEnabled(
option: WildcardOption,
instance: InstanceLike,
multiActive = false,
): boolean {
// Null option intentionally has no sub-categories. It's an orthogonal
// "no-output" slot governed SOLELY by `exclude_null` — neither
// `enabled_options` nor `category_filter` apply. Checked first so the
// per-row checkbox and the "Exclude null" toggle read the same flag and
// stay linked (#3). SP2a: multi-pick ALWAYS drops the null option from the
// pool (the engine excludes it; "maybe nothing" is pick_min=0), so it is
// never enabled in multi mode regardless of exclude_null — keeps the
// enabled count + per-option probability honest.
if (option.is_null) {
return !multiActive && !instance.exclude_null;
}
if (Array.isArray(instance.enabled_options) && !instance.enabled_options.includes(option.id)) {
return false;
}
if (Array.isArray(instance.category_filter) && instance.category_filter.length > 0) {
// Null option intentionally has no sub_category. Category filters
// are about narrowing the sub-cat-bearing pool — the null option is
// an orthogonal "no-output" slot and stays in the pool regardless.
// User can still toggle it off via `enabled_options` if desired.
if (option.is_null) return true;
if (!option.sub_category) return false;
if (!instance.category_filter.includes(option.sub_category)) return false;
}
return true;
return matches(parse(instance.category_filter ?? ""), new Set(option.sub_categories ?? []));
}
export function effectiveWeight(option: WildcardOption, instance: InstanceLike): number {
@@ -53,10 +74,11 @@ export function probabilityFor(
option: WildcardOption,
allOptions: readonly WildcardOption[],
instance: InstanceLike,
multiActive = false,
): number {
if (!isEnabled(option, instance)) return 0;
if (!isEnabled(option, instance, multiActive)) return 0;
const totalEnabledWeight = allOptions
.filter((o) => isEnabled(o, instance))
.filter((o) => isEnabled(o, instance, multiActive))
.reduce((sum, o) => sum + effectiveWeight(o, instance), 0);
if (totalEnabledWeight === 0) return 0;
return effectiveWeight(option, instance) / totalEnabledWeight;
@@ -70,6 +70,21 @@ describe("IdentitySection", () => {
expect(patch.instance?.variable_binding).toBeNull();
});
it("strips comma + punctuation from the binding on input (var names must be identifiers)", async () => {
// The binding becomes a `$var`; a comma is a settle delimiter and other
// punctuation can't appear in an identifier. Sanitize on keystroke so a
// junk char can never enter the produced-var name. DOM reflects it too
// (controlled input — force-sync so it can't diverge from the model).
const w = mount(IdentitySection, { props: { module: makeModule() } });
const input = w.find<HTMLInputElement>('[data-test="id-binding"]');
input.element.value = "out,fit;top!";
await input.trigger("input");
const updates = w.emitted("update")!;
const patch = updates[updates.length - 1][0] as Partial<ModuleEntry>;
expect(patch.instance?.variable_binding).toBe("outfittop");
expect(input.element.value).toBe("outfittop");
});
it("renders $-prefix label on binding input", () => {
const w = mount(IdentitySection, { props: { module: makeModule() } });
expect(w.find('[data-test="id-binding-prefix"]').text()).toBe("$");

Some files were not shown because too many files have changed in this diff Show More