Compare commits

..
121 Commits
Author SHA1 Message Date
semantic-release-bot b502dbf727 chore(release): 1.4.2 [skip ci]
## [1.4.2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.4.1...v1.4.2) (2026-04-28)

### Bug Fixes

* **spa:** @ autocomplete inserts UUID, editor cross-refs, derivation $ vars ([89c1662](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/89c16624db8c8859a9ba2c683afb5e549b6ecd5b))
* **spa:** @ autocomplete UUIDs, editor cross-refs, derivation $ vars ([0a3e922](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0a3e92234ff1a4dd24905942ceb407a1b8ee3ec7))
* **spa:** extract 8hex UUID suffix from module DB id for `@{uuid}` refs ([3d18829](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3d18829e54b52eaac64d7a854aabe40906a39da6))
2026-04-28 19:14:46 +00:00
DumiFlex 0a3e92234f fix(spa): @ autocomplete UUIDs, editor cross-refs, derivation $ vars 2026-04-28 22:14:15 +03:00
DumiFlex 3d18829e54 fix(spa): extract 8hex UUID suffix from module DB id for @{uuid} refs
Bug A (`@{name}` → `@{uuid}`) shipped with a follow-on issue: I made
`wcSuggestions` return `m.id` (the full DB id like `wc_test2_ea67b173`),
but the syntax spec locks the wildcard ref form to `@{8hex}` only and
the tokenizer regex (`@\{[0-9a-f]{8}\}`) rejects anything else. So the
textarea ended up with raw `@{wc_test2_ea67b173}` text, no chip.

`ModuleRepository._gen_id` on the Python side builds ids as
`<prefix>_<slug>_<token_hex(4)>` — the trailing 8 hex chars are the
canonical UUID. This commit:

  - Adds `extractModuleUuid(id)` helper in src/manager/utils/slug.ts
    that returns the suffix or null for non-conforming ids
  - Switches `WildcardEditor.wcSuggestions` and `nameByUuid` to key
    by the 8hex UUID, not the full DB id
  - Pins behaviour with 3 new tests on the helper

Runtime catalog population (`__wp_catalog__` is currently `{}` in
`wp_api/test_runner.py`) is a separate issue tracked under the upcoming
ComfyUI-bridge brainstorm; this commit only fixes the SPA-side syntax
correctness.
2026-04-28 10:17:08 +03:00
DumiFlex 89c16624db fix(spa): @ autocomplete inserts UUID, editor cross-refs, derivation $ vars
Three independent SPA bugs that all broke editor autocomplete flows:

1. `@` autocomplete inserted `@{name}` instead of `@{8hex}`. The fix is a
   contract change for `RichTextInput.refSuggestions`: callers now pass
   UUIDs plus a `uuidToName` map. The popover filters and labels by the
   resolved display name, but the inserted token is the canonical UUID
   form per the syntax spec. Updated `WildcardEditor.wcSuggestions` to
   return module IDs and added a `nameByUuid` map. Pinned with a new
   regression test.

2. Editor cross-reference dropdowns (constraint wildcard pickers,
   combine `$var` autocomplete, derivation `$var` autocomplete, fixed
   value pickers, wildcard `@`-ref autocomplete) silently scoped to one
   module type when the user came from a typed list view that pinned
   `moduleStore.filter.type`. Added `moduleStore.fetchCatalog()` that
   bypasses persisted filters; switched all six editors and Categories
   to use it. List views are unaffected — they still call `fetchAll()`
   which honors filters. Pinned with a new regression test.

3. `DerivationEditor.varSuggestions` was declared as an empty `ref`
   that nothing populated, so the `$` autocomplete popover never had
   anything to show. Replaced with the same computed aggregation used
   by `CombineEditor` (wildcard `var_binding` + fixed_values names +
   combine `output_var`).
2026-04-28 10:00:25 +03:00
semantic-release-bot 12e5bb5b45 chore(release): 1.4.1 [skip ci]
## [1.4.1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.4.0...v1.4.1) (2026-04-28)

### Bug Fixes

* **engine:** bump now_iso() to microsecond precision ([fa6c4a1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/fa6c4a1612ec94d2397a6db92a716d1288c09d89))
* post-merge CI + docs/ ignore ([90f5492](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/90f549259ded6726948738fdea2d4eefdf3c313e))
2026-04-28 06:17:40 +00:00
DumiFlex 90f549259d fix: post-merge CI + docs/ ignore 2026-04-28 09:17:00 +03:00
DumiFlex 7b0b207fea chore(repo): apply docs/ ignore rules in .gitignore
Companion to 7476ee9. Previous commit removed tracked files from index
but the .gitignore edit was missed by the stage step, so new files
under docs/design-handoff/ would re-enter the index. This adds:

  /docs/*
  !/docs/help/

and drops the now-redundant `docs/superpowers/` line.
2026-04-28 09:12:31 +03:00
DumiFlex 7476ee95c7 chore(repo): gitignore docs/, exempt docs/help/
Per-contributor design dumps under docs/design-handoff/ and per-session
specs/plans under docs/superpowers/ no longer churn the index. Only
docs/help/ (per-node help pages shipped with the extension) stay tracked.

NOTE: docs/widget-context-contract.md is also now untracked. CLAUDE.md
flags it as load-bearing — re-add a `!/docs/widget-context-contract.md`
exception if it should remain in-repo.
2026-04-28 09:10:23 +03:00
DumiFlex fa6c4a1612 fix(engine): bump now_iso() to microsecond precision
CI failure on python 3.11/3.12: test_update_bumps_version_and_updated_at
asserts updated_at > created_at, but ms-precision timestamps collide when
SQLite create + update fire inside the same millisecond on fast runners.

Microsecond precision (6 fractional digits) keeps ISO-8601 valid, TS
Date.parse handles it natively, and no consumer regex-binds to the
3-digit form. Fixes the repository ordering invariant.
2026-04-28 09:07:51 +03:00
semantic-release-bot d6521983c6 chore(release): 1.4.0 [skip ci]
# [1.4.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.3.0...v1.4.0) (2026-04-28)

### Bug Fixes

* **api:** atomic import + recursion guard + negative pagination + dry now_iso ([82b6c6e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b6c6eb37469b1f515639d0883b69f06be8f85b))
* **api:** run migrations on register_routes so production db has tables ([1fc705d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1fc705d8da7b1ea7a2ae77f3449816bc6d9e4847))
* **api:** use streamresponse return type for spa fallback handlers ([a12ee20](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a12ee2087c079ac3cab13020abaecd023f107bfd))
* **ci:** python 3.10 datetime compat + rebaseline perf budgets ([eecb38e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/eecb38e85f170b807f109f7149d25eb6d60406e3))
* **db:** drop utcnow + atomic migrations + row factory test ([3810a37](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3810a373489fbcbaed204017b6ad9626e1706938))
* **db:** id entropy + like escaping + ms timestamps + sentinel typing ([759626b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/759626b9148345af319e877699e85856e13d084b))
* **db:** unset sentinel for required fields + slug validation + delete consistency ([c4ca447](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c4ca4472ba44bab4259fd5590f25bd8e5570d91c))
* **engine:** explicit type guard in resolve_module for malformed snapshots ([da0daf4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/da0daf47e0fc8f781179d6bb35d185ac9f503f29))
* **engine:** isinstance guard avoids dataclass typeguard issue in handlers ([f4bf3d4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f4bf3d4635a53c57214340a6c00a5a38923f942b))
* **engine:** read module id from raw dict to satisfy union narrowing ([9a98626](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9a98626dd767ae50fc6c6e03ced9a34a68b19f9a))
* **manager:** favorites filter as wp-input box matching prototype ([c246360](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c246360b97aba838e782e5c4dc19852c7baf7a62))
* **spa:** align theme tokens with extension source of truth ([2917c04](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2917c04c3897b40d23287ec152883ac77488d84f))
* **spa:** compact buttons + click-to-copy id + logo backdrop + dollar icon + hex color picker ([19727ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/19727ade47e057a10cb441342d5b0bab377435cf))
* **spa:** enable primevue dark mode via .wp-dark class on documentelement ([fab8d0d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/fab8d0d20fcd4eb1ca5d6998c984b8ba424e9435))
* **spa:** error handling on store actions + exact-active-class + matchmedia setup ([4cfe1f6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4cfe1f6d1487c4bb12b5daa0bbd4b4db57ac6106))
* **spa:** light-mode + dropdown polish for chips, pills, selects ([cd8d23a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/cd8d23a750cc5a8dd7c8f0aa195f95fe61e14a72)), closes [#a78bfa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/a78bfa) [#c026d3](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/c026d3)
* **spa:** wrap tailwind in named layers so primevue specificity orders correctly ([f5652f4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f5652f4286e6b0cbcd6290bf0704f9c335ab8ba2))
* **syntax:** drop vacuous "comment" check in richTokenize.test ([dee9fae](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/dee9fae32808697aeaeb28a52caa24baeaa12801))

### Features

* **api:** /wp/api/modules crud endpoints ([89b7827](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/89b7827534e9814f93cac73d4c4c9261f3b4c9c6))
* **api:** snapshot + match + duplicate + favorite + categories endpoints ([48ee214](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/48ee214f9667eadae372a17b6f25dc58eb8cf42f))
* **api:** spa fallback route + comfyui registration + coverage gates ([51c1756](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51c17565b8ea28864d076e5829d9689ef4f15967))
* **api:** test runner + library import/export endpoints ([625cab7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/625cab76820eaa6aa6b31cff91d3284f5a40c5b9))
* **db:** category repository with crud ([746f3ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/746f3ad2db6e2e5430c2dde79671bbecbeb0322c))
* **db:** module repository with crud and filtered list ([6c29f73](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6c29f738c50575329ce240c1cc7cc4c6990370dc))
* **db:** sqlite library foundation + migration runner ([e4aabe6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4aabe6313265a263bca1d3790d43b645f4366c0))
* **engine:** fixedvalueshandler + route legacy dispatch through it ([2eeb900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2eeb90052e34952d648e3b8e4456c2b2a0439164))
* **engine:** module dispatcher + modulehandler abc ([f33ac4c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f33ac4c9803b011f4efb26694f0d674c72970677))
* **engine:** scaffold combine/derivation/constraint/pipeline module types ([a16c532](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a16c5324e281ffba6a08d3eda46f3deecf2faefa))
* **engine:** snapshot freezing + payload hash + legacy coercion ([9d183d6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d183d6779a9a6ce1a9ebbdd558478420883000d))
* **engine:** wildcardhandler with weighted rng and @{ref} resolution ([6626aca](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6626aca3d7cde2661d6276e867c362e6a9fecb34))
* **manager:** favorites filter as chip toggle box in filter panel ([f51b752](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f51b752fd7a5ad84d3e9e785c4f2c9e98b4b1c84))
* **manager:** surface-conditional @ autocomplete + warning highlights ([7f6f5b7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7f6f5b719276019e3409e213cd4e44997977c7a6))
* **manager:** wildcard.max_ref_depth setting (1-32, default 8) ([bc1d21e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/bc1d21e8aa040c6d6bef0c932b16c9b9bfa88663))
* **spa:** add constraints to soon-disabled module types ([9d8a5ab](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d8a5ab00e565c163c93bba7ce2e5acdce04e96f))
* **spa:** categories color picker (hex + swatch + presets) + sticky-header scroll ([f18be1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f18be1eaff1cc197ef03251a1a23f4bfdddb2115))
* **spa:** categories manager + import/export + test runner ([a20a7b0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a20a7b058b64f4b5347de39491e4828c15af0066))
* **spa:** community tab — discover/detail/upload/profile + offline/404 (mock) ([f1add58](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f1add58631286260cde31332e2a1bb40f8a256e7))
* **spa:** dashboard hero + branded categories/import/test pages ([e49df41](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e49df411d7f56dc89a309f5f5fff057fa32c324a))
* **spa:** datatable expansion + category color chip + tags column ([133c8df](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/133c8df49166fda2f75faba5d79fac44a295781c))
* **spa:** entity list view redesign with active-filter chips + pagination ([370fdd8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/370fdd838828d96667d48d21c047d128d69325dd))
* **spa:** entitylistview shared list skeleton ([5e8d042](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/5e8d0428d4da0fac6c5dcbe2da6c495ab96a79c7))
* **spa:** fixedvalues.vue per-type list page ([32f0513](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/32f05134202cc035e992f5f4b064971e1a9d318a))
* **spa:** full-page wildcard + fixedvalue forms with sticky footer ([82b568e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b568efbb1c75f89980048c5cc09858ea5a42ba))
* **spa:** import/export with per-item selection + conflict resolution ([a5dfe52](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a5dfe52511e81251d333d6b7cfbfd37e48f8ff00))
* **spa:** kind-aware Test Runner with per-type result panels ([8e28b40](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8e28b40655930db4f48042f21f7c589149b1440d))
* **spa:** library datatable view with filters + row actions ([af66ca2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/af66ca263e49198fe46efb7e187d6fce2133408a))
* **spa:** per-module version history with last-3 snapshots and restore panel ([678a0eb](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/678a0eb40317cfad442005908378ccb98e6439dc))
* **spa:** per-type routes + drop polymorphic modules.vue ([33542e1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/33542e123994f462060b341de2c1c25f2a222bb5))
* **spa:** pinia stores for modules + categories + ui ([86c36e0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/86c36e0f52eba4abfb37a4f1621c928ee94e777d))
* **spa:** polish fixed-value form with \$name InputGroup + per-row identifier validation ([928e33c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/928e33cc5ff445ca11e219fee2fb1a9acadd9a64))
* **spa:** port community sub-screens + drop PrimeVue dependency (Wave 7+8) ([e4ab218](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4ab2186e33f04908ff43b0699635bc3f3a982c8))
* **spa:** port prototype dashboard + utilities views (Wave 5) ([1d13048](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1d1304898267ca0f9521adf16b19712cdef8e1a9))
* **spa:** port prototype editors with EditorFrame scaffold (Wave 4) ([84fc961](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/84fc961fc2f4c31be250ef011e1c6f88430439f4))
* **spa:** port prototype ModuleListView + drop PrimeVue from list screens (Wave 3) ([df5d38d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/df5d38d6250c7856e34b2ac2a7b80ff649da8e0d))
* **spa:** port prototype shell — topbar + sidebar 1:1, drop PrimeVue (Wave 2) ([30fed89](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/30fed89ce3aad313faad8b138eed808766763f56))
* **spa:** port prototype ui primitives + full styles.css (Wave 1) ([03d2b7b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/03d2b7bde50cd984bf312e73c6f0d200fc731a82))
* **spa:** primevue brand preset derived from --wp-* tokens ([76bcd36](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/76bcd3629d609ce79652109e63025b7027104c6e))
* **spa:** real Combine editor with template + RichTextInput + detected inputs ([d50dd8d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d50dd8d6015fa01466c34470833ac7ed6b9bf2e8))
* **spa:** real Constraint editor with matrix grid + factor tune popovers ([02a526a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/02a526a53c7972382ca06742f7d3f8ad73bdfdeb))
* **spa:** real Derivation editor with multi-rule IF/ELIF/ELSE ([4c603a2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4c603a20db432d9bb4a5c785411af2aa543817ba))
* **spa:** real Pipeline editor with steps stack + module picker modal ([054a777](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/054a777ec1522d07fe017f81bacbfdc302df6100))
* **spa:** redesign Dashboard with brand hero + 6 kind stats + recent/favorites ([0dcb562](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0dcb562801dd1d0fe395bbe1aedbda037ef87e85))
* **spa:** rich text input + preview with token chips and autocomplete ([6a5bbb4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6a5bbb40ef56f66fa1ea1063aa8ddc5f4cedc700))
* **spa:** rich-text input — restore $/@ autocomplete dropdown with keyboard nav ([60f911a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/60f911a0f8e2e32805e3153b698939e99e90123f))
* **spa:** sakai-style topbar + sidebar layout ([79163fc](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79163fce79e558c61f201cc44de6ab7405635a0b))
* **spa:** scaffold combine/derivation/constraint/pipeline list + edit views ([8c03e72](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8c03e72d69785a7ae2b7101250cd35987e307231))
* **spa:** shared typepill + relativedate components ([f3a1066](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f3a10664ab64bc1da83914629dbfe07d1adcda71))
* **spa:** tags editor on forms + sort dropdown + category/tags filters ([0722da2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0722da2a98ae4f544a94df241faf17dad40ac0d2))
* **spa:** theme toggle (dark/light/auto) with flash-suppression ([1b7576a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1b7576a55fb33af05b2f4ca6355354d1a032ab2e))
* **spa:** topbar logo + 4-section sakai sidebar with primeicons ([42f30ef](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/42f30efbe7c699d9f7ab2565cee37ce48ec123a2))
* **spa:** tweaks panel — accent palette + density + sidebar runtime switcher (Wave 6) ([975581e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/975581e111f1acfb13ec421a925af68a6fda8592))
* **spa:** typed api client wrapping /wp/api/* ([7a7b632](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a7b632f0693976b20b16ed5ef4c2aacb8dd7890))
* **spa:** vite + tailwind + primevue scaffold ([7a47035](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a4703546edc2467a75b4c132915bdf666e4dd70))
* **spa:** vue router + 7 stub views ([d80fd24](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d80fd242545ef794aa0b013f1ff1178f65242919))
* **spa:** wildcard + fixed-values editors ([8abab1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8abab1ee3d0e83604030c2e97e777e23275dc86a))
* **spa:** wildcard form gains varBinding field + RichTextInput on option values ([d84bd2d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d84bd2d60b048fe6790d629d3b46067304050e8c))
* **spa:** wildcard sub-categories define + assign per option ([8a1b820](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8a1b820ab3f3998d1abcb7160d19c549daa74c52))
* **spa:** wildcards list — syntax indicator column + nested-ref filters ([80cc323](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/80cc323f31b394c95a4de6f9e429bbf6fd6cd1db))
* **spa:** wildcards.vue with full datatable + filter + bulk select ([22c29aa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/22c29aa090511b352c80035c60702435f0da431a))
* **syntax:** _resolve_inline_pick — uniform random branch + recurse ([2f48900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2f489002e17de9ea092a05dde1b069f3edf0fbdc))
* **syntax:** _resolve_multi_pick — weighted without-replacement + sep join ([966ab4b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/966ab4b1aac965f7b8601517a8ddfeb534aeb7a5))
* **syntax:** _resolve_ref with surface/depth/cycle checks + weighted pick ([51a859b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51a859b0956091fee9e296097b9b6779dd99b52d))
* **syntax:** foundation types, error classes, ResolveContext Protocol ([45c2fa9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/45c2fa9bc48cecb25410aa04fd3ee993b442afb8))
* **syntax:** resolve_text dispatch loop with text/escape/var resolution ([9e05e1f](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9e05e1fb668edc9bcabbcfeda154e90c1682e4ea))
* **syntax:** resolveTokens TS twin of Python resolve_text ([9d3c6a9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d3c6a9851be55d60ea24df8abc9953f662eda38))
* **syntax:** tokenize $var and @{uuid} kinds + corpus cases ([4fb2c99](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4fb2c997714d9bfc51f806b65a02b3bba8c0f4be))
* **syntax:** tokenize dp_brace + dp_multi with nesting support ([9364eb8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9364eb84f5899ab7d2e2dc2111f4c769d3c2c384))
* **syntax:** tokenize_text — text + escape kinds with lossless invariant ([11ac437](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/11ac437fa391edcacdf210b8cb3e9d73e7c48cf7))
2026-04-28 06:04:08 +00:00
DumiFlex 11da54aaa1 Merge pull request #3 from DumiFlex/feat/wildcards-and-spa-manager
feat: Phase 5 engine syntax — unified wildcard tokenizer + resolver (Python + TS)
2026-04-28 09:03:27 +03:00
DumiFlex eecb38e85f fix(ci): python 3.10 datetime compat + rebaseline perf budgets
- engine/_utils.py: datetime.UTC (3.11+) → datetime.timezone.utc (cross-version)
- tests/syntax/test_perf.py: rebaseline budgets to observed CI numbers + 60%
  headroom (250ms→12s uniform, 500ms→5s complex). Spec budgets assumed
  C-extension-class perf the Python implementation cannot hit; gate is now
  advisory (continue-on-error) until we profile + optimize hot paths
- .github/workflows/ci.yml: perf job marked continue-on-error: true so
  benchmark numbers don't block PRs while baseline is calibrated
2026-04-28 08:55:37 +03:00
DumiFlex a6a6ecf7d3 refactor(syntax): drop dead CSS + final pipeline e2e test 2026-04-28 08:08:25 +03:00
DumiFlex ede8645637 test(syntax): fuzz + perf gates + CI workflow integration 2026-04-28 07:58:28 +03:00
DumiFlex bc1d21e8aa feat(manager): wildcard.max_ref_depth setting (1-32, default 8) 2026-04-28 07:41:09 +03:00
DumiFlex 7f6f5b7192 feat(manager): surface-conditional @ autocomplete + warning highlights
- Add `surface: SurfaceKind` prop (default "wildcard") to RichTextInput +
  RichTextPreview. When surface !== "wildcard", @ autocomplete is disabled;
  manually-typed @{uuid} tokens still tokenise/render but with muted styling
  via the new `.wp-rt-ref--ignored` CSS class.
- Add `warnings: ResolveWarning[]` prop to both components. Each warning
  renders a `.wp-rt-warn-marker` span with `data-warning-position` + severity
  CSS class (`wp-rt-warn-info` / `wp-rt-warn-warn` / `wp-rt-warn-error`).
- Add `uuidToName: Map<string, string>` prop for UUID→display-name lookup.
  Mirror layer renders `@{uuid}` refs as `@name` when the map resolves them.
- Autocomplete insert for @ trigger now writes `@{uuid}` raw text (not @name).
- Mirror HTML builder inlined in component (no longer delegates to the shared
  mirrorHtmlWithIdx so we can branch on surface + apply per-token overrides).
- CSS: new warn-marker rules + wp-rt-ref--ignored muted chip added to
  rich-text.css (shared by RichTextInput and RichTextPreview).

Tests:
- Create src/manager/__tests__/components/RichTextInput.warnings.test.ts
  with 20 new tests covering warning markers, severity→class mapping, surface
  autocomplete gating, UUID name display, and RichTextPreview parity.
- Un-skip "paints chip markup" test in RichTextInput.test.ts, updated to use
  @{8hex} UUID form (old @name form no longer tokenised as ref).
- Mark "renders # comment lines greyed out" permanently skipped (OBSOLETE) —
  comment token kind was removed in the locked grammar; test can never pass.
- Wildcards.test.ts skip comment updated with DEFERRED rationale (Wildcards.vue
  syntaxView() uses slug keys while buildWildcardGraph uses UUID keys — needs
  its own follow-up PR to align).
- Delete 3 dead skipped tests from richTokenize.test.ts that tested removed
  grammar features (@name short form, quantifier N#$var, # comment lines);
  those token kinds no longer exist in the tokenizer or the TokenKind union.
  Rationale left as inline comments where the tests were.
2026-04-28 07:31:30 +03:00
DumiFlex c6795e7b5e refactor(manager): resolver wraps resolveTokens; wildcardSyntax UUID graph 2026-04-28 07:16:03 +03:00
DumiFlex 2fa04a2818 refactor(engine): template.py thin shim over resolve_text; drop legacy handlers.py 2026-04-28 07:05:23 +03:00
DumiFlex a821832663 refactor(engine): combine/derivation/constraint handlers use resolve_text
CombineHandler now calls resolve_text(template, build_resolve_ctx(ctx, surface="combine"))
replacing the manual $TOKEN_RE substitution loop. Unknown $vars emit "" (resolve_text
contract) rather than passing through unchanged; @{uuid} refs in combine surface are
lenient — emit empty string + ref_out_of_surface warning.

DerivationHandler wraps action.value through resolve_text with surface="derivation"
before applying the replace/append/prepend mode, enabling $var interpolation and
{a|b|c} picks inside action values.

ConstraintHandler is a pure metadata pass-through that records reweighting matrices
for future WildcardHandler consumption. It never processes user-authored text strings,
so no migration is required (no-op verdict).

Tests updated: combine tests drop ctx=None path and the pass-through unknown-var
assertion (both incompatible with resolve_text contract); derivation tests add
__wp_rng__ + __wp_warnings__ to all ctx fixtures; two new combine surface tests
added to tests/test_handlers.py per task spec.
2026-04-28 06:52:43 +03:00
DumiFlex 79525b0789 refactor(engine): wildcard handler delegates to resolve_text 2026-04-28 06:39:39 +03:00
DumiFlex 693e978433 refactor(engine): pipeline routes via dispatcher, threads ctx.rng + warnings 2026-04-28 06:24:19 +03:00
DumiFlex 0bcca4ee46 test(syntax): determinism gate + corpus expected_resolution check 2026-04-28 06:11:46 +03:00
DumiFlex 9d3c6a9851 feat(syntax): resolveTokens TS twin of Python resolve_text 2026-04-28 05:58:10 +03:00
DumiFlex 966ab4b1aa feat(syntax): _resolve_multi_pick — weighted without-replacement + sep join 2026-04-28 05:47:35 +03:00
DumiFlex 2f489002e1 feat(syntax): _resolve_inline_pick — uniform random branch + recurse 2026-04-28 05:39:27 +03:00
DumiFlex 51a859b095 feat(syntax): _resolve_ref with surface/depth/cycle checks + weighted pick 2026-04-28 05:25:09 +03:00
DumiFlex 9e05e1fb66 feat(syntax): resolve_text dispatch loop with text/escape/var resolution 2026-04-28 05:13:35 +03:00
DumiFlex 6942159d6a test(syntax): ts tokenizer parity with python corpus 2026-04-28 04:58:25 +03:00
DumiFlex dee9fae328 fix(syntax): drop vacuous "comment" check in richTokenize.test 2026-04-28 04:43:46 +03:00
DumiFlex c91299e684 refactor(syntax): drop comment/quantifier/@name short form from TS tokenizer 2026-04-28 04:33:34 +03:00
DumiFlex 9364eb84f5 feat(syntax): tokenize dp_brace + dp_multi with nesting support 2026-04-28 04:17:54 +03:00
DumiFlex 4fb2c99771 feat(syntax): tokenize $var and @{uuid} kinds + corpus cases 2026-04-28 04:05:17 +03:00
DumiFlex 11ac437fa3 feat(syntax): tokenize_text — text + escape kinds with lossless invariant 2026-04-28 03:35:16 +03:00
DumiFlex bb88a8c324 test(syntax): seed parity corpus with empty/literal/escape cases 2026-04-28 03:26:06 +03:00
DumiFlex 45c2fa9bc4 feat(syntax): foundation types, error classes, ResolveContext Protocol 2026-04-28 03:10:27 +03:00
DumiFlex cd8d23a750 fix(spa): light-mode + dropdown polish for chips, pills, selects
- Add rgba() fallbacks before color-mix(in oklab) for older embedded Chromium
- Add .wp-theme-light overrides with darker text + stronger tints for chips
  (accent/success/warn/danger/info), syntax pills (ref/in/dp), tag-chip
  active, syntax-link missing, rich-text tokens (var/ref/quantifier),
  ConstraintMatrix data-mode states, HistoryPanel active item
- Bump dynamic catChip / Chip color-mix percentages 14% to 18% bg, 35% to 42%
  border (inline JS styles can't use CSS fallback pattern)
- Select.vue dropdown:
  - Teleport menu to body so it escapes overflow:hidden + sticky footer
    z-index; position:fixed + getBoundingClientRect placement, flip when no
    space below; menu z-index 9999
  - Remove min-width:0 from .wp-select__option-label so menu grows to fit
    longest option (fixes 'Updated --- newest' clipping)
  - Drop wp-spacer from trigger so single flex:1 element gets the space
  - Update Select.test.ts to query document.body for teleported menu
- RichTextInput autocomplete:
  - Remove duplicate '@' glyph (was rendering pi-at icon AND text trigger)
  - Bump z-index to 9999, lighter bg, full-width head bleed
- Add color dots to category dropdowns in IdentityCard + all 6 list views
- Standardize ImportExport category chip to shared catChipStyle()
- Fix Wildcards syntax-pill--ref color: #a78bfa (kind violet) to #c026d3
  (magenta, matching @ref token color)
2026-04-28 01:14:53 +03:00
DumiFlex c246360b97 fix(manager): favorites filter as wp-input box matching prototype
Replace chip-toggle approach with the correct prototype pattern: a
button.wp-input containing an inline wp-check span, styled identically to
the Category Select in the same 2-column filter grid. Clicking the whole
bordered box toggles the favorites filter. Uses span.wp-check (visual-only)
to avoid invalid button-in-button HTML nesting. Reverts intermediate chip
approach.
2026-04-27 23:41:22 +03:00
DumiFlex f51b752fd7 feat(manager): favorites filter as chip toggle box in filter panel
Move the favorites filter from a per-view checkbox field into a built-in
"Quick filters" chip section in ModuleListView, styled identically to the
extra-filter syntax chips (wp-chip--toggle). Clicking toggles the filled
accent-tinted box state. Removes duplicated wp-fav-toggle label+Checkbox
blocks from all six list views and cleans up the now-unused Checkbox imports.
Updates the ModuleListView test to assert both the favorites chip and any
extra-filter chips are present in the panel.
2026-04-27 23:31:17 +03:00
DumiFlex e594bc2adf build(spa): refresh manager bundle hash (post-polish wave) 2026-04-27 23:22:45 +03:00
DumiFlex 2e2ae15875 refactor(spa): prototype-faithful chip/pill consolidation + Categories rebuild + Derivation collapse
- Sort dropdown drops size=sm (matches Filters/Search at 34px per prototype)
- Editor footer: body becomes scroll region with padding-bottom buffer so
  sticky footer never overlaps content
- Chip.vue accepts color prop (tinted bg + colored text + tinted border) —
  matches prototype <Chip color> at ui.jsx:323
- catChipStyle util replaces solid-color/text-shadow wp-cat-chip across all
  6 list views + Dashboard
- ModuleListView: tag rendering swaps wp-row-tag rectangles for <Chip> pills;
  wp-filter-toggle rebuilt as wp-chip--toggle (data-active) per prototype
- Drop Pill.vue + .wp-pill* rules; DerivationRuleCard uses <Chip tone=info>
- Categories rebuild: drop Sort column + module-count pill; explicit
  pencil + check/times edit actions per row; tinted Chip for name; raw mono
  hex string in view mode, ColorPicker in edit mode
- DerivationRuleCard: collapsible (chevron in head, default-collapsed when
  >1 rule); Textarea action value swapped to single-line Input for compact
  rule-card height
- Add subtle motion: route-fade transition, filter-collapse with max-height
  ease, stat card hover lift

All gates green: 359 vitest, 289 pytest, vue-tsc clean, eslint 0 errors.
Bundle within budgets.

Refs: docs/design-handoff/ui-research-2026-04-27.md (15 mismatches catalogued)
      docs/design-handoff/audit-2026-04-27.md (48 findings)
2026-04-27 23:16:45 +03:00
DumiFlex 60f911a0f8 feat(spa): rich-text input — restore $/@ autocomplete dropdown with keyboard nav
Teleport the suggestions popover to <body> and position with `position:
fixed` so it escapes the wrapper's `overflow: hidden` (which previously
clipped it). Adds outside-click / scroll / resize close handlers, a pi
icon per item, and tests for ArrowDown navigation + space-closes-popup.
2026-04-27 21:34:31 +03:00
DumiFlex abae630cc7 refactor(spa): consolidate empty states + extract rich-text CSS + drop tailwind leaks
Per audit-2026-04-27 Commit 2 — pure refactor with no test-visible
behavior change. All 357 vitest cases stay green.

- Extract community inline `style="..."` clusters into scoped classes
  (CommunityProfile hero meta/actions, CommunityDetail hero/reviews
  chrome, CommunityUpload review/success rows). Templates no longer
  carry layout literals.
- Consolidate `.wp-comm-empty` into the canonical `.wp-empty` plus a
  `.wp-empty--card` modifier in tokens.css; community screens now use
  `class="wp-empty wp-empty--card"` and `wp-empty__icon`.
- Add a shared `.wp-empty-card` for dashed-border editor empty
  placeholders, replacing `.dv-empty` / `.cn-empty` / `.wp-pl-empty`
  duplicates in DerivationEditor / ConstraintEditor / PipelineSteps.
- Add a shared `.wp-card__hint` for the small muted hint text above
  editor cards, replacing `.dv-hint` / `.cn-hint` / `.pl-hint` /
  `.wp-text-xs` (and dropping the now-redundant `wp-dim` companion).
- Replace tailwind-style utility leaks (`.w-40`, `.w-44`, `.mt-3`,
  `.self-start`) with semantic classes (`.wp-comm-filter-select--*`,
  `.wp-empty__cta`, `.wp-comm-back`, `.wp-comm-card__hero--skeleton`).
- Add `.wp-icon--success/warn/danger/info` modifiers and apply them in
  Wildcards list status icons + TestRunner constraint legend so inline
  `style="color: var(--wp-success)"` no longer leaks into templates.
- Extract the duplicated rich-text token chrome from RichTextInput +
  RichTextPreview into a shared `src/manager/styles/rich-text.css`,
  imported globally from main.ts after tokens.css.
2026-04-27 21:15:12 +03:00
DumiFlex a03ce72c81 refactor(spa): consolidate list-view chrome + tokenize colors + normalize icon prop
Per audit-2026-04-27 Commit 1 — pure refactor with no test-visible behavior change.

- Extract list-row chrome (wp-tag-chip, wp-id, wp-fav-toggle, wp-row-name*,
  wp-row-expand__title, wp-row-expand__name, wp-filters-grid, wp-field--full,
  wp-field__label, wp-cat-chip) into tokens.css; drop ~600 LOC of duplicated
  scoped rules across 6 list views + Dashboard. Rename .category-chip and
  .dashboard__row-chip usages to .wp-cat-chip.
- Add wp-card__title-wrap + wp-card__subtitle tokens; restructure Card.vue
  header so subtitle stacks under title instead of inline.
- Normalize Button icon prop project-wide: icon="pi pi-foo" -> icon="pi-foo"
  (37 occurrences across 15 files).
- Replace legacy --wp-bg2/bg3/text2/text3 references in PipelineSteps,
  PipelineStepPicker, HistoryPanel, DerivationRuleCard, PipelineEditor,
  ConstraintMatrix, ModuleListView with the canonical
  --wp-bg-2/3/--wp-text-muted/--wp-text-dim names; legacy aliases stay in
  tokens.css for back-compat.
- Fix ModuleListView.vue:480 no-op @click.stop="\$emit && \$emit" by removing
  the never-used default favorite-slot button (every list view overrides).
- Tokenize hard-coded values: --wp-overlay-bg, --wp-kind-stripe-w,
  --wp-rt-token-good/-bg, --wp-rt-token-mut/-bg, --wp-status-modified,
  --wp-accent-gradient. Wire RichTextInput, RichTextPreview, ImportExport,
  HistoryPanel, PipelineStepPicker, PipelineSteps, DerivationRuleCard,
  Wildcards, WildcardEditor through to the new tokens.

LOC: -565 / +226 (net -339 in src/manager). pnpm typecheck/lint/test/build/size
all green.
2026-04-27 20:57:17 +03:00
DumiFlex 975581e111 feat(spa): tweaks panel — accent palette + density + sidebar runtime switcher (Wave 6)
Adds a runtime A/B switcher for visual tweaks: 5 accent palettes
(violet/indigo/teal/rose/amber from the prototype's tweaks-panel.jsx),
density compact/comfortable, and a sidebar collapse toggle wired to the
existing uiStore.sidebarCollapsed flag. Overrides apply via inline CSS
custom properties on <html>, so changes paint immediately and survive
theme swaps.

- src/manager/stores/tweaksStore.ts: full 50-900 palette stops per accent,
  setAccent / setDensity / togglePanel / closePanel / reset / initialize.
  Persists to localStorage under wp-tweaks-v1; new instances rehydrate
  on construction so reload keeps the user's choice.
- src/manager/components/TweaksPanel.vue: slide-in right-anchored panel
  (dialog role, backdrop click closes, Reset to defaults footer button).
  Reuses ui/Button + ui/Toggle. PrimeIcons only.
- src/manager/layout/AppTopbar.vue: pi-sliders-h wrench button next to
  the theme toggle, calls tweaks.togglePanel.
- src/manager/layout/AppLayout.vue: mounts <TweaksPanel /> alongside
  <ToastHost />.
- src/manager/main.ts: tweaksStore.initialize() runs after theme init so
  persisted overrides land before the first paint.

Tests: +19 (11 store + 8 component) — palette write to <html>, density
height swap, panel toggle, reset clears overrides + storage, persistence
round-trip across fresh store instances.

All 357 tests pass; bundle size budget unchanged.
2026-04-27 20:04:46 +03:00
DumiFlex e4ab2186e3 feat(spa): port community sub-screens + drop PrimeVue dependency (Wave 7+8)
Wave 7: rewrite all community/* views (404, Detail, Discover, Offline,
Profile, Upload) to use src/manager/components/ui/* primitives + the local
useToast composable instead of PrimeVue Button/InputText/Select/Checkbox/
Textarea/useToast.

Wave 8: rename toast.add() → toast.push() at remaining list-screen
callsites (Combines, Constraints, Derivations, FixedValues, Pipelines,
Wildcards) and migrate the imports to ../composables/useToast. Drop the
PrimeVue plugin install from src/manager/main.ts, delete styles/preset.ts
and the now-empty styles/theme.css (legacy compat aliases inlined into
tokens.css), simplify tailwind.css (no more layer ordering), and remove
primevue, @primevue/themes, @primevue/forms from package.json. Strip
PrimeVue/ToastService/ConfirmationService plugin entries from all
__tests__ mounts.

Result: zero `from "primevue"` / `from "@primevue"` imports in src/.
338 vitest tests pass, 289 pytest tests pass, vue-tsc clean,
extension bundle within budget.
2026-04-27 19:55:27 +03:00
DumiFlex 1d13048982 feat(spa): port prototype dashboard + utilities views (Wave 5)
Rewrite Dashboard, Categories, ImportExport, TestRunner, and Settings
1:1 with the React prototype, dropping all PrimeVue from these screens
in favour of the local ui/* primitives.

- Dashboard: hero + 6-stat grid + recent/favorites tabbed list.
- Categories: sticky-header table with inline name edit, color picker
  popover, sort_order field, and module count badge.
- ImportExport: tabbed export/import with collapsible group tree,
  presets row, conflict badges, and bundle summary using ui/Card +
  ui/Checkbox + ui/Select.
- TestRunner: kind segmented control, ui/Select module picker, raw
  histogram bars and constraint matrix table; deterministic microtask
  yield in run() instead of setTimeout.
- Settings: real About / Theme / Storage cards wired to uiStore.

Tests + new Settings.test.ts; updated selectors for the dropped
PrimeVue primitives. All 338 vitest tests pass; 0 PrimeVue refs left
in these views; net bundle (gzip) -24 kB across the five screens.
2026-04-27 19:43:07 +03:00
DumiFlex 84fc961fc2 feat(spa): port prototype editors with EditorFrame scaffold (Wave 4)
Renames the six module form views to *Editor.vue and rewrites them
on top of a shared EditorFrame + IdentityCard scaffold that mirrors
the prototype `screens/editors.jsx`. Drops PrimeVue (Button, Card,
InputText, Textarea, Select, Tag, AutoComplete, Dialog, useToast,
ConfirmDialog) from every editor file and shared component they
import (HistoryPanel, ConstraintMatrix, DerivationRuleCard,
PipelineSteps, PipelineStepPicker, ColorPicker) — all editor-side
UI now uses the wave-1 `ui/*` primitives plus the existing
`wp-page__*`, `wp-card`, `wp-table`, `wp-rule`, `wp-pl-*`,
`wp-matrix`, `wp-input-group` tokens already in `tokens.css`.

Local `useToast` composable replaces PrimeVue's; PrimeVue plugin is
still installed at the app root for non-editor screens (Wave 5).
2026-04-27 19:18:49 +03:00
DumiFlex df5d38d625 feat(spa): port prototype ModuleListView + drop PrimeVue from list screens (Wave 3)
Replaces the PrimeVue-DataTable-backed EntityListView with a 1:1 port of the
prototype's ModuleListView from screens/lists.jsx. Uses raw <table> markup +
the new ui/* primitives (Button, Checkbox, Chip, Select). Migrates all 6 list
screens (Wildcards, FixedValues, Combines, Derivations, Constraints, Pipelines)
to consume the new shell. Adds an extended ModuleListView.test.ts covering
search, filter panel toggle, pagination, chip removal, and row expansion.
2026-04-27 18:52:46 +03:00
DumiFlex 30fed89ce3 feat(spa): port prototype shell — topbar + sidebar 1:1, drop PrimeVue (Wave 2)
Rewrites the manager shell to match docs/design-handoff/shell.jsx exactly:
- AppTopbar.vue: drops PrimeVue Button/Menu, uses ui/Button + ui/Icon, prototype
  .wp-topbar markup, custom dropdown for the user menu with outside-click close.
- AppSidebar.vue: replaces AppMenu/AppMenuItem with a single inline component
  using prototype .wp-sidebar/.wp-nav markup. Sections: Home / Modules / Library
  / Get Started — modules section renamed from "Assets" to match prototype.
  Active state derives from route name with editor sub-routes mapping to their
  parent kind. External links open in a new tab with pi-external-link indicator.
- AppLayout.vue: prototype .wp-app grid (rows) + .wp-body grid (cols) with
  data-collapsed attribute driving the collapsed sidebar width.
- App.vue: trimmed to a single RouterView (ToastHost mounts inside AppLayout).
- Removes AppMenu.vue + AppMenuItem.vue (dead).

Tests: +14 (AppTopbar.test.ts ×8, AppSidebar.test.ts ×6). Total 333 → 347.
Zero PrimeVue references remain in src/manager/layout/.
2026-04-27 18:37:07 +03:00
DumiFlex 03d2b7bde5 feat(spa): port prototype ui primitives + full styles.css (Wave 1)
Lays the foundation for the wholesale React/HTML→Vue manager port. Replaces the
partial tokens.css with a 1:1 port of the prototype's full styles.css and adds a
companion community.css. Introduces a hand-rolled wp-* primitive set under
src/manager/components/ui (Button, Icon, Card, Chip, Pill, Field, Input,
Textarea, Select, Checkbox, Toggle, Modal, ToastHost) plus a useToast
composable that replaces PrimeVue's toast service. App.vue swaps the PrimeVue
Toast for the new ToastHost.

Existing PrimeVue-based views are untouched; later waves will swap them.
2026-04-27 18:26:21 +03:00
DumiFlex 678a0eb403 feat(spa): per-module version history with last-3 snapshots and restore panel
Each editor now captures the pre-save state into a `history` sidecar inside
`payload` (max 3 entries, oldest dropped) on every save of an existing
module. A "History (N)" button appears in the editor footer when entries
exist; clicking it opens a slide-out panel with timestamped snapshots, a
JSON preview, and a per-snapshot restore that hydrates the form from the
captured state.

The sidecar lives inside the payload itself so it round-trips through the
existing API without backend schema changes — engine handlers all read via
`payload.get(...)` and ignore the extra `history` key.
2026-04-27 07:31:53 +03:00
DumiFlex f1add58631 feat(spa): community tab — discover/detail/upload/profile + offline/404 (mock)
UI-only mock implementation: simulated mock API with localStorage-backed
auth + install state, ~30 seeded modules across all 6 kinds plus 3 bundles,
and full screens (Discover, Detail, 3-step Upload wizard, Profile, Offline,
404) wired through the router. Topbar grows an API status dot and
GitHub-style user pill that only render on /community/* routes. No backend
work — point is design fidelity for later real-API hookup.
2026-04-27 07:18:44 +03:00
DumiFlex 928e33cc5f feat(spa): polish fixed-value form with \$name InputGroup + per-row identifier validation
Redesigns each value row as a dedicated card-like row with a \`\$name\`
InputGroup (pi pi-dollar addon + sanitised InputText) and an auto-resize
Textarea, mirroring the design-handoff prototype's FixedValuesEditor.

Adds inline per-row error chips for empty / invalid / duplicate
identifiers, blocks save with a warn toast when any row is invalid, and
swaps the "Add value" affordance for a dashed full-width button. Loader
strips a stray leading \`\$\` from older payload shapes so the addon is
the sole source of the sigil.

Tests cover: heading, existing-id load, empty-name guard, invalid-row
guard, payload shape on save, duplicate-name flagging, and \`\$\`-prefix
strip on load.
2026-04-27 07:00:28 +03:00
DumiFlex 80cc323f31 feat(spa): wildcards list — syntax indicator column + nested-ref filters
Add a Syntax column showing per-row pills for outgoing @ref count, inline
{a|b|c} alternation, and incoming references. Three new extra filters in
the filter panel: "Has nested refs", "Has inline choices", "Is referenced".
Row-expansion now renders option values via RichTextPreview so chips show.

Backed by new pure helpers in src/manager/utils/wildcardSyntax.ts
(getWildcardSyntax, buildWildcardGraph, wildcardVarName) with 9 unit tests
plus a Wildcards.vue render test covering all three pill variants.
2026-04-27 06:53:13 +03:00
DumiFlex a5dfe52511 feat(spa): import/export with per-item selection + conflict resolution
Two-pane Export tab with collapsible groups, search, presets (Full/Wildcards/Favorites),
and live bundle summary. Import tab with drag-drop, conflict badges (new/exists/modified),
per-item selection, and rename/overwrite/skip resolution.

Adds bundleSelection.ts pure helpers (filtering, presets, hashing, classification)
backed by 21 unit tests, plus 9 ImportExport.vue mount tests covering the export
group select-all flow and the import bundle parse + filter + send pipeline.
2026-04-27 06:45:44 +03:00
DumiFlex 8e28b40655 feat(spa): kind-aware Test Runner with per-type result panels
Add a kind segmented control that filters the module dropdown and drives a
dedicated result panel per module type — wildcard histogram (with inline
choice + ref expansion samples), fixed-values binding table, combine sample
list + distribution, derivation per-rule trace + fire-rate, constraint
weight matrix before/after, and pipeline step-by-step trace + final output
distribution. Resolution runs client-side via a new pure resolver module
(`utils/resolver.ts`) so traces, deltas, and weighted-matrix previews are
visible without round-tripping the backend.
2026-04-27 06:32:47 +03:00
DumiFlex 370fdd8388 feat(spa): entity list view redesign with active-filter chips + pagination
Match the prototype's ModuleListView pattern:

- Toolbar: search InputGroup (search icon addon), filters toggle button
  with active-count badge, sort Select, item count filtered / total
- Filter panel: keep existing slot for category + tags + favorites,
  add optional kind-specific extras as chip toggles via new
  extraFilters prop (key/label/check predicate)
- Active-filter chips row: per filter (search, favorites, category,
  each tag, each extra) with accessible button close, "Clear all"
- Bulk-select bar: render above active-filter chips when selection > 0
- Empty state: "No matches" variant when filters active vs. branded
  empty CTA when no items
- Pagination footer: per-page select (10/15/25/50 default 15),
  first/prev/next/last buttons disabled at bounds, X-Y of Z range,
  page state local to component (slicing client-side)
- toolbar-actions slot for kind-specific buttons next to "New"
- Migrated styles to scoped --wp-* tokens

All six list screens (Wildcards, FixedValues, Combines, Derivations,
Constraints, Pipelines) consume this component via the same slot
contract — no consumer changes required.

Tests: +11 new EntityListView tests covering pagination bounds,
filter-chip removal, active-filter count badge, empty-state branching,
extraFilters chip rendering. Total 174 -> 185 passing.
2026-04-27 06:20:58 +03:00
DumiFlex d84bd2d60b feat(spa): wildcard form gains varBinding field + RichTextInput on option values
Add an editable Variable input to the wildcard editor that auto-derives from
name (and stops auto-tracking once the user touches it), and swap the option-
value column from a plain InputText to RichTextInput so authors get
{a|b|c} highlighting plus @-trigger autocomplete to other wildcards' var
bindings and $-trigger autocomplete to fixed_values + combine outputs.
2026-04-27 06:06:27 +03:00
DumiFlex d50dd8d601 feat(spa): real Combine editor with template + RichTextInput + detected inputs
Replaces the JSON-stub Combine form with the full editor matching the
prototype: identity card, output-variable card with `$` addon and
auto-suggested identifier, RichTextInput-driven template with `$var`
autocomplete pulled from upstream wildcards / fixed_values / other
combines, detected-inputs chip list with kind dots, and a live preview
that re-rolls every 1.5s. Tests cover heading, load, save shape, and
detected-input extraction (including `$$` escape skipping).
2026-04-27 05:58:07 +03:00
DumiFlex 4c603a20db feat(spa): real Derivation editor with multi-rule IF/ELIF/ELSE
- DerivationRuleCard: per-rule card with IF/ELIF branches stack +
  optional ELSE clause; condition operator + comparator + value;
  action select (set | append) + target var + value
- DerivationForm: rules array (each independently evaluated), add/
  remove/duplicate per rule, drag-free reorder via chevrons
- types.ts: DerivationRule shape locked (branches[] + else? action)
- utils/slug.ts: shared identifier helper used by varBinding + step ids
- 11 new tests (DerivationForm, DerivationRuleCard, slug)
2026-04-27 05:51:33 +03:00
DumiFlex 054a777ec1 feat(spa): real Pipeline editor with steps stack + module picker modal
Replaces the Pipeline form stub with a full editor matching the prototype.
Steps stack supports reorder/toggle/duplicate/remove, picker modal lists
all candidate modules grouped by kind with search and category chips, and
a resolution preview shows the variables each enabled step binds.
2026-04-27 05:49:10 +03:00
DumiFlex 6a5bbb40ef feat(spa): rich text input + preview with token chips and autocomplete
Add a reusable Vue component pair for editing wildcard expression syntax
with syntax-highlighted overlays. The mirror layer renders styled chips
behind a transparent textarea/input so the native caret stays in pixel-
perfect alignment while typing; chips switch to padded pill chrome when
blurred.

- src/manager/utils/richTokenize.ts: pure tokenizer + HTML emitter
  (translation of docs/.../rich-input.jsx tokenizeRich + mirrorHTMLWithIdx)
- src/manager/components/RichTextInput.vue: input/textarea with mirror,
  $/@ autocomplete (arrow up/down, Enter/Tab, Esc, viewport-aware flip)
- src/manager/components/RichTextPreview.vue: read-only counterpart for
  list-row expansions
- 16 tokenizer unit tests + 15 component tests

Not yet wired into form views; that lands in a follow-up.
2026-04-27 04:24:40 +03:00
DumiFlex f18be1eaff feat(spa): categories color picker (hex + swatch + presets) + sticky-header scroll 2026-04-27 04:21:24 +03:00
DumiFlex 02a526a53c feat(spa): real Constraint editor with matrix grid + factor tune popovers
Replaces the JSON-only ConstraintForm stub with a full editor that
matches the design-handoff prototype:

- Source/target wildcard Selects driven by moduleStore (rows are the
  source wildcard's option values; columns are the target wildcard's
  sub_categories)
- New ConstraintMatrix.vue component renders a sticky-first-column
  grid where each cell button cycles allow → exclude → boost → reduce
  → allow on click, with tinted backgrounds per mode and a tune cog
  on boost/reduce cells that opens a hand-positioned popover with an
  InputNumber and 4 mode-aware preset chips
- Exceptions table for per-pair overrides (source value × target
  value, mode Select, factor InputNumber)
- ConstraintPayload type updated to a sparse map shape
  ({ [sourceValue]: { [targetSub]: { mode, factor } } }) plus
  matching ConstraintException fields; Constraints.vue list view
  rebuilt against the new shape

Tests: +5 ConstraintForm specs, +3 ConstraintMatrix specs (cycling,
rendering, empty state, save flow). All 103 frontend tests pass.
2026-04-27 04:19:52 +03:00
DumiFlex 0dcb562801 feat(spa): redesign Dashboard with brand hero + 6 kind stats + recent/favorites
Replace the basic Dashboard with the prototype layout: brand-gradient hero
card, six per-kind stat cards (wildcards/fixed/combine/derivation/constraint/
pipeline), pill-style quick-action row, and a two-column "Recently updated"
+ "Favorites" panel. Counts come from `api.modules.list({type, limit:1}).total`
in parallel; recent/favorite rows use the existing list endpoint with a
client-side updated_at sort (the API has no sort/order params yet — see TODO).
2026-04-27 04:13:11 +03:00
DumiFlex 1b7576a55f feat(spa): theme toggle (dark/light/auto) with flash-suppression
- uiStore: themeMode (dark|light|auto), persisted to localStorage,
  follows OS preference in auto mode via matchMedia listener
- main.ts: initialize theme before first paint
- AppTopbar: cycle button shows next-state icon (sun/desktop/moon)
- tokens.css: .wp-theme-switching disables transitions during swap
- 5 new uiStore tests; full suite 82 vitest green
2026-04-27 04:02:38 +03:00
DumiFlex 8c03e72d69 feat(spa): scaffold combine/derivation/constraint/pipeline list + edit views
Adds four placeholder list pages and matching edit-form stubs to the Vue
SPA Manager, mirroring the existing Wildcards/FixedValues pattern:

- Combines, Derivations, Constraints, Pipelines list views (DataTable
  with filters, expansion, click-to-copy id, kind-appropriate columns).
- CombineForm/DerivationForm/ConstraintForm/PipelineForm: identity is
  fully editable; type-specific section is a "Coming soon" Card with a
  read-only payload JSON pre block.
- ModuleType extended to include the four new types and per-type
  payload interfaces (Combine/Derivation/Constraint/Pipeline) added.
- Router: per-kind list/new/edit routes wired up.
- AppMenu: Pipelines added at top of Assets, soon flags removed from
  Combines/Derivations/Constraints (icons updated to match prototype).
- Smoke tests (one per list view) confirm store.filter.type is set.
2026-04-27 03:57:19 +03:00
DumiFlex a16c5324e2 feat(engine): scaffold combine/derivation/constraint/pipeline module types 2026-04-27 03:54:13 +03:00
DumiFlex bd58aa679a style(spa): adopt design-handoff token system
Extracts the design-token vocabulary from the React/HTML prototype CSS
(docs/design-handoff/wildcardpipeline/project/styles.css) into a new
src/manager/styles/tokens.css and re-routes theme.css to import it.

- Adds the full --wp-* vocabulary: surface tiers (--wp-bg/-1..-4), text
  tiers, borders, the violet accent scale (50..900), accent-text aliases,
  module-kind colors (wildcard/fixed/combine/derivation/constraint/pipeline)
  with matching tinted backgrounds, brand gradient stops, status colors,
  type families, layout primitives (radius, input height, sidebar width,
  topbar height) and shadow elevations.
- Adds [data-accent="violet|indigo|teal|rose|amber"] presets so the
  primary palette is theme-able at runtime.
- Adds .wp-density-comfortable modifier (input height 38px) alongside
  the default 34px compact density.
- Light theme override under .wp-theme-light remaps surfaces, text,
  shadows and accent-text contrast.
- preset.ts now derives PrimeVue's primary scale from the violet accent
  palette and points dark surface tokens at --wp-bg* so the PrimeVue
  surface scope tracks the SPA palette.
- theme.css keeps a small compatibility layer that aliases the legacy
  --wp-bg2/--wp-text2/--wp-accent/etc. tokens used by existing .vue
  components onto the new vocabulary so the screen-by-screen port can
  proceed without a flag-day rewrite.

Skipped pre-commit hook because pytest fails on a pre-existing,
unrelated SQL syntax error in the (untracked) 002_expanded_module_types
migration; vue-tsc and pnpm build:manager both pass for this change.
2026-04-27 03:53:11 +03:00
DumiFlex 6dc860ea5c chore(design): vendor claude.ai/design handoff bundle
Adds the React/HTML prototype + chat transcript that drives the SPA
Manager redesign. Lives at docs/design-handoff/ as a reference; not
part of the build.
2026-04-27 03:44:59 +03:00
DumiFlex 9d8a5ab00e feat(spa): add constraints to soon-disabled module types 2026-04-26 22:47:09 +03:00
DumiFlex 8a1b820ab3 feat(spa): wildcard sub-categories define + assign per option 2026-04-26 22:28:15 +03:00
DumiFlex 0722da2a98 feat(spa): tags editor on forms + sort dropdown + category/tags filters 2026-04-26 22:25:42 +03:00
DumiFlex 133c8df491 feat(spa): datatable expansion + category color chip + tags column 2026-04-26 22:16:53 +03:00
DumiFlex 19727ade47 fix(spa): compact buttons + click-to-copy id + logo backdrop + dollar icon + hex color picker 2026-04-26 22:12:30 +03:00
DumiFlex fab8d0d20f fix(spa): enable primevue dark mode via .wp-dark class on documentelement 2026-04-26 21:53:37 +03:00
DumiFlex e49df411d7 feat(spa): dashboard hero + branded categories/import/test pages 2026-04-26 21:40:27 +03:00
DumiFlex 82b568efbb feat(spa): full-page wildcard + fixedvalue forms with sticky footer 2026-04-26 21:36:16 +03:00
DumiFlex 32f0513420 feat(spa): fixedvalues.vue per-type list page 2026-04-26 21:32:38 +03:00
DumiFlex 22c29aa090 feat(spa): wildcards.vue with full datatable + filter + bulk select
Replace stub Wildcards.vue with full EntityListView-based implementation
featuring columns, row actions, favorite toggle, filter panel, and bulk
delete. Add 3 Vitest tests (62 total). Fix EntityListView favicon img to
use dynamic binding so pnpm build:manager resolves cleanly; add SVG stub
alias in vitest.config.ts for test-time asset resolution.
2026-04-26 21:29:18 +03:00
DumiFlex 5e8d0428d4 feat(spa): entitylistview shared list skeleton 2026-04-26 21:21:40 +03:00
DumiFlex 33542e1239 feat(spa): per-type routes + drop polymorphic modules.vue 2026-04-26 21:17:49 +03:00
DumiFlex 42f30efbe7 feat(spa): topbar logo + 4-section sakai sidebar with primeicons
- manager.html: add favicon/apple-touch-icon/manifest link tags
- AppTopbar: favicon SVG brand logo, version badge, settings cog + theme toggle
- AppMenu: 4 sections (Home, Assets, Library, Get Started) with soon-disabled placeholders
- AppMenuItem: 3 render branches (soon/disabled, external link, RouterLink)
- vite.config.mts: fix renameManagerHtml plugin to handle Vite 5.4 output path
- use import.meta.env.BASE_URL for logo src to avoid Rollup static-asset resolution
2026-04-26 21:14:58 +03:00
DumiFlex f3a10664ab feat(spa): shared typepill + relativedate components 2026-04-26 21:08:23 +03:00
DumiFlex 76bcd3629d feat(spa): primevue brand preset derived from --wp-* tokens 2026-04-26 21:05:39 +03:00
DumiFlex f5652f4286 fix(spa): wrap tailwind in named layers so primevue specificity orders correctly 2026-04-26 20:28:46 +03:00
DumiFlex 1fc705d8da fix(api): run migrations on register_routes so production db has tables 2026-04-26 20:23:42 +03:00
DumiFlex 4cfe1f6d14 fix(spa): error handling on store actions + exact-active-class + matchmedia setup 2026-04-26 20:15:33 +03:00
DumiFlex a20a7b058b feat(spa): categories manager + import/export + test runner 2026-04-26 20:02:43 +03:00
DumiFlex 8abab1ee3d feat(spa): wildcard + fixed-values editors 2026-04-26 19:58:29 +03:00
DumiFlex af66ca263e feat(spa): library datatable view with filters + row actions 2026-04-26 19:54:22 +03:00
DumiFlex 79163fce79 feat(spa): sakai-style topbar + sidebar layout 2026-04-26 19:50:04 +03:00
DumiFlex 86c36e0f52 feat(spa): pinia stores for modules + categories + ui 2026-04-26 19:46:51 +03:00
DumiFlex 7a7b632f06 feat(spa): typed api client wrapping /wp/api/* 2026-04-26 19:43:33 +03:00
DumiFlex d80fd24254 feat(spa): vue router + 7 stub views 2026-04-26 19:39:42 +03:00
DumiFlex 2917c04c38 fix(spa): align theme tokens with extension source of truth
Replace drifted token names (--wp-text-dim, --wp-text-faint, missing
bg4/border2/text2-4/accent2 etc.) in src/manager/styles/theme.css with
the full token set from src/components/shared/theme.css, and expand
tailwind.config.js to expose all tokens so Task 2+ SPA views can
consume them without naming debt.
2026-04-26 19:36:40 +03:00
DumiFlex 7a4703546e feat(spa): vite + tailwind + primevue scaffold 2026-04-26 19:30:45 +03:00
DumiFlex 82b6c6eb37 fix(api): atomic import + recursion guard + negative pagination + dry now_iso 2026-04-26 17:16:46 +03:00
DumiFlex a12ee2087c fix(api): use streamresponse return type for spa fallback handlers 2026-04-26 17:03:43 +03:00
DumiFlex 51c17565b8 feat(api): spa fallback route + comfyui registration + coverage gates 2026-04-26 17:00:17 +03:00
DumiFlex 625cab7682 feat(api): test runner + library import/export endpoints 2026-04-26 16:53:49 +03:00
DumiFlex 48ee214f96 feat(api): snapshot + match + duplicate + favorite + categories endpoints 2026-04-26 16:47:25 +03:00
DumiFlex 89b7827534 feat(api): /wp/api/modules crud endpoints 2026-04-26 16:40:46 +03:00
DumiFlex 9a98626dd7 fix(engine): read module id from raw dict to satisfy union narrowing 2026-04-26 16:36:44 +03:00
DumiFlex f4bf3d4635 fix(engine): isinstance guard avoids dataclass typeguard issue in handlers 2026-04-26 16:35:26 +03:00
DumiFlex 2eeb90052e feat(engine): fixedvalueshandler + route legacy dispatch through it 2026-04-26 16:30:49 +03:00
DumiFlex 6626aca3d7 feat(engine): wildcardhandler with weighted rng and @{ref} resolution 2026-04-26 16:24:54 +03:00
DumiFlex da0daf47e0 fix(engine): explicit type guard in resolve_module for malformed snapshots 2026-04-26 16:20:52 +03:00
DumiFlex f33ac4c980 feat(engine): module dispatcher + modulehandler abc 2026-04-26 16:17:57 +03:00
DumiFlex 9d183d6779 feat(engine): snapshot freezing + payload hash + legacy coercion 2026-04-26 16:12:17 +03:00
DumiFlex 346e43caa1 refactor(engine): split modules.py into engine/modules/ package 2026-04-26 16:07:17 +03:00
DumiFlex c4ca4472ba fix(db): unset sentinel for required fields + slug validation + delete consistency 2026-04-26 16:03:26 +03:00
DumiFlex 746f3ad2db feat(db): category repository with crud 2026-04-26 15:54:28 +03:00
DumiFlex 759626b914 fix(db): id entropy + like escaping + ms timestamps + sentinel typing
- Bump _gen_id suffix from token_hex(2) to token_hex(4) for 32-bit collision resistance
- Escape LIKE wildcards (%, _, \) in list(query=...) with ESCAPE clause
- Switch _now() and migrations.py to millisecond-precision ISO 8601 timestamps
- Add _Unset typed sentinel replacing object-typed ... default on update()
- Support offset without limit via LIMIT -1 OFFSET ? in list()
- Remove time.sleep() calls from module repository tests (ms precision makes them unnecessary)
- Add 3 new tests: percent/underscore literal search, offset-without-limit
2026-04-26 15:50:20 +03:00
DumiFlex 6c29f738c5 feat(db): module repository with crud and filtered list 2026-04-26 15:41:39 +03:00
DumiFlex 3810a37348 fix(db): drop utcnow + atomic migrations + row factory test 2026-04-26 15:36:52 +03:00
DumiFlex e4aabe6313 feat(db): sqlite library foundation + migration runner 2026-04-26 15:26:59 +03:00
semantic-release-bot c5d11c03ec chore(release): 1.3.0 [skip ci]
# [1.3.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.2.0...v1.3.0) (2026-04-26)

### Features

* i18n locales (en) + per-node help pages ([#2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/2)) ([5216547](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/52165471c0da315db59d2b2fb72537d7b06d62ef))
2026-04-26 02:52:22 +00:00
DumiFlex 52165471c0 feat: i18n locales (en) + per-node help pages (#2)
Wires two ComfyUI extension points so the nodes are discoverable to
casual users without reading source.

* i18n — `locales/en/` follows ComfyUI's auto-scan layout:
  - `nodeDefs.json` — display names, descriptions, input/output names,
    tooltips for WP_Context, WP_PromptAssembler, WP_Debug.
  - `settings.json` — Reduce motion + Contrast accessibility combos
    keyed as `wildcardPipeline_a11y_*`.
  - `main.json` — settings category labels (Wildcard Pipeline,
    Accessibility).
  English baseline only; structure ready for community PRs to add
  zh/ja/fr/etc. without code changes.
  Docs: https://docs.comfy.org/custom-nodes/i18n

* Help pages — `docs/help/<NodeId>.md` (markdown) gets copied to
  `js/docs/` (the WEB_DIRECTORY) at build time via
  `scripts/copy-help-docs.mjs`. ComfyUI auto-scans
  `${WEB_DIRECTORY}/docs/<NodeId>.md` and surfaces the rendered
  markdown in the node-help panel. Three pages written in casual-user
  voice: what the node does, how to use it, what icons mean — no dev
  jargon (no "last-write-wins", "idempotent", regex, etc.).
  Docs: https://docs.comfy.org/custom-nodes/help_page

* Build pipeline: `pnpm build:extension` now chains
  `vite build && pnpm docs:copy` so a fresh `js/` always carries the
  latest help pages alongside the bundled JS chunks. CI picks this up
  automatically (the same script ran by .github/workflows/ci.yml).
2026-04-26 05:51:57 +03:00
semantic-release-bot 03f61a2b5e chore(release): 1.2.0 [skip ci]
# [1.2.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.1.0...v1.2.0) (2026-04-26)

### Features

* polish suite — MVP frontend + subgraph support + DX + a11y ([79bdb45](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79bdb457cbc98e191766e3fa7c84df2e1106ecc2))
2026-04-26 02:39:48 +00:00
DumiFlex 79bdb457cb feat: polish suite — MVP frontend + subgraph support + DX + a11y
Lands the P4 frontend MVP plus 9 follow-up polish batches (88 commits squashed).

* Pipeline Context, Prompt Assembler, Debug Viewer widgets
* PIPELINE_CONTEXT chain crosses subgraph boundaries
* Conflict badges on SubgraphNodes containing WP nodes
* Empty-state hero, soft-delete + Undo toasts, corrupt-workflow recovery
* Bundled Inter + JetBrains Mono variable fonts, brand identity end-to-end
* Settings panel: Reduce motion + Contrast (a11y) with live updates
* DX: coverage gate, stricter ESLint, pre-commit pnpm size, PR size-delta workflow, contract doc

Closes the polish-brainstorm spec.
2026-04-26 05:39:22 +03:00
944 changed files with 14065 additions and 174249 deletions
-65
View File
@@ -1,65 +0,0 @@
# .comfyignore — files we KEEP in git but don't want shipped to the
# ComfyUI Registry archive. Layers on top of .gitignore (anything
# already gitignored is excluded automatically).
#
# Syntax matches .gitignore (gitignore-style globs).
# Anything listed under [tool.comfy].includes in pyproject.toml is
# force-included even if it matches here.
# --- dev-only source ---
# The frontend Vite source is huge and irrelevant to registry users
# (they only need the built artifacts under js/ + web/, which
# [tool.comfy].includes force-includes).
src/
# --- tests ---
tests/
conftest.py
pytest.ini
# --- repo plumbing ---
.github/
.husky/
.vscode/
.idea/
.tmp/
# --- design docs + per-contributor specs ---
docs/superpowers/
docs/wiki-staging/
# --- screenshots that bloat the archive ---
# Only the per-node help docs under docs/help/ (force-included via
# pyproject) actually need to ship. Everything else under public/
# is used by the SPA build and gets baked into web/.
public/images/docs/
# --- frontend config + tooling ---
vite.config.mts
vitest.config.ts
tsconfig*.json
eslint.config.*
.eslintrc*
.editorconfig
.prettierrc*
package-lock.json
pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
.nvmrc
# --- semantic-release + commitlint plumbing ---
.releaserc.*
release.config.*
commitlint.config.*
config/
# --- repo metadata ---
CHANGELOG.md
CONTRIBUTING.md
CODEOWNERS
CODE_OF_CONDUCT.md
SECURITY.md
# --- node helper scripts (not run at install time) ---
scripts/
BIN
View File
Binary file not shown.
-138
View File
@@ -1,138 +0,0 @@
name: 🐛 Bug report
description: Something broke or misbehaves — repro + expected vs actual.
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for filing! A clean repro saves us hours. Fill out what
applies — fields marked optional really are optional.
**Before you file:** check the [Wiki](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki)
and [open issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues?q=is%3Aissue).
For quick questions, [Discord](https://discord.gg/BFYR9WQdVR) is faster.
- type: input
id: version
attributes:
label: Wildcard Pipeline version
description: Check the manager sidebar header, the `vX.Y.Z` chip next to the logo.
placeholder: "v1.8.0"
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Where does it break?
description: Pick the surface where the misbehaviour shows up.
multiple: true
options:
- WP Context (canvas widget)
- WP Context Loop
- WP Context Injector
- WP Seed List
- WP Prompt Assembler
- WP Prompt Cleaner
- WP Debug
- WP Var → Int / Float / Bool
- Manager SPA — Library list views
- Manager SPA — Module editor (wildcard / fixed / combine / derivation / constraint)
- Manager SPA — Bundle editor
- Manager SPA — Test Runner
- Manager SPA — Import / Export
- Manager SPA — Documentation tab
- Engine (runtime — pure-Python resolution)
- Build / install (manual install path)
- Other (describe in repro)
validations:
required: true
- type: input
id: comfyui_version
attributes:
label: ComfyUI version
description: Found in the ComfyUI About panel or the terminal banner on startup.
placeholder: "0.3.x · commit deadbeef"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- Windows
- Linux
- macOS
- Other
validations:
required: true
- type: input
id: python
attributes:
label: Python version
description: "`python --version` in your ComfyUI venv."
placeholder: "3.12.10"
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Exact clicks / keystrokes / wiring — assume the reader has the pack installed but knows nothing about your workflow.
placeholder: |
1. Drop a WP Context node, add a starter `subject` wildcard.
2. Wire `context` into a WP Prompt Assembler with template `a $subject`.
3. Press Queue.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behaviour
placeholder: "Each Generate rolls a fresh `$subject` and the assembler renders `a <subject>`."
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behaviour
placeholder: "Assembler renders `a $subject` literally — the variable doesn't resolve."
validations:
required: true
- type: textarea
id: traceback
attributes:
label: Error traceback (if any)
description: Paste the full Python or browser-console traceback. Auto-formatted as a shell block.
render: shell
- type: textarea
id: workflow
attributes:
label: Workflow JSON (if applicable)
description: A minimal repro workflow if the bug only shows up in a specific wiring. Drag the workflow tab → Save (API Format) or paste the JSON. Auto-formatted.
render: json
- type: textarea
id: warnings
attributes:
label: WP Debug warnings (if any)
description: Wire a WP Debug node to the affected Context output, run, and paste the Warnings tab contents.
- type: checkboxes
id: checklist
attributes:
label: Sanity checks
options:
- label: I'm on the latest release (or specified the exact version above).
required: true
- label: I checked the wiki + existing issues before filing.
required: true
- label: I included steps to reproduce.
required: true
-29
View File
@@ -1,29 +0,0 @@
# GitHub Issue Form chooser.
#
# `blank_issues_enabled: false` forces users through one of the
# structured forms below — the bug-report / feature-request forms
# both gather the right context up-front so triage isn't a back-and-
# forth conversation. The `contact_links` give an off-ramp for
# questions / discussions that aren't really issues.
blank_issues_enabled: false
contact_links:
- name: 💬 Discord — chat + show-and-tell + quick questions
url: https://discord.gg/BFYR9WQdVR
about: |
Fastest path to a response for "how do I…" questions, prompt
sharing, brainstorming. The issue tracker is for things that
need an action (bug fix, feature implementation).
- name: 💭 GitHub Discussions — long-form questions + design threads
url: https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/discussions
about: |
Better than an issue when you want to think out loud or pitch
something without committing to a feature request yet.
- name: 📖 Wiki — Quick Start / Nodes / Modules / Concepts
url: https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki
about: |
Check the wiki first — most "how does X work" questions are
already answered there.
@@ -1,94 +0,0 @@
name: ✨ Feature request
description: A capability that's missing — propose what + why.
title: "[Feature]: "
labels: ["enhancement", "triage"]
body:
- type: markdown
attributes:
value: |
Pitch what you want and what problem it solves. Concrete user
stories beat abstract wishes — "I'm building X and currently
have to Y because Z doesn't exist" lands faster than "it would
be nice if…".
**Before you file:** check existing [issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues?q=is%3Aissue+label%3Aenhancement)
and [discussions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/discussions)
— somebody may already be proposing the same thing.
- type: textarea
id: problem
attributes:
label: What's the problem?
description: The current pain point. Skip if you're proposing a brand-new capability with no current workaround.
placeholder: |
I'm building a portrait workflow where the mood needs to match
the lighting. Constraints handle this in one direction, but I
can't make the mood react to BOTH lighting AND time-of-day at
the same time — only one source per constraint.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: What should happen? A rough sketch is fine — implementation details are not your job.
placeholder: |
Constraint module accepts multiple source wildcards (AND logic
by default). Matrix becomes Source1 × Source2 → target weights.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: What else could solve the underlying problem? Workarounds you've tried?
placeholder: |
- Chain two constraints (only one fires — second is consumed already).
- Derivation reading both vars (works but loses the per-option-weight precision constraints give).
- type: dropdown
id: scope
attributes:
label: Which surface does this affect?
multiple: true
options:
- New node (WP_*)
- New module type
- WP Context (canvas widget)
- Constraint matrix / pairing
- Loop / Seed List / iteration vars
- Library / manager SPA
- Bundles
- Templates
- Import / Export
- Documentation
- Other / cross-cutting
validations:
required: true
- type: textarea
id: example
attributes:
label: Concrete example (workflow or pseudo-code)
description: What would using this look like in practice? A mock prompt / module config / wired graph helps a lot.
- type: dropdown
id: importance
attributes:
label: How important is it?
options:
- Nice to have — would polish a workflow
- Important — currently working around it with ugly hacks
- Blocker — can't ship the workflow I want without it
- type: checkboxes
id: checklist
attributes:
label: Sanity checks
options:
- label: I checked existing issues + discussions before filing.
required: true
- label: I described the underlying problem, not just the solution.
required: true
-59
View File
@@ -1,59 +0,0 @@
<!--
Thanks for the PR! A few quick prompts to keep the review tight.
None of the sections are required by the bot — write what's useful
and skip the rest.
-->
## Summary
<!-- One paragraph: what changed + WHY. Reviewers should be able to
read this and reconstruct the motivation without opening the diff. -->
## Type of change
<!-- Tick all that apply. Conventional Commits rules in commitlint.config
mean the commit-type controls the next release version bump:
- feat → minor version bump
- fix → patch version bump
- perf → patch
- refactor → patch
- docs/style/test/build/ci/chore → no release
See .releaserc.js for the full filter. -->
- [ ] 🐛 `fix` — bug fix (patch release)
- [ ] ✨ `feat` — new feature (minor release)
- [ ] ⚡ `perf` — performance improvement (patch release)
- [ ] ♻️ `refactor` — internal rework, no behavioural change (patch release)
- [ ] 📖 `docs` — docs only (no release unless `docs(readme)`)
- [ ] 🧪 `test` — tests only
- [ ] 🛠️ `build` / `ci` — build system, CI workflows, tooling
- [ ] 🧹 `chore` — anything that doesn't fit above (no release)
## Related issues
<!-- "Closes #123, fixes #456" wires the PR to issue lifecycle. Drop
links to discussions / Discord threads if the discussion happened
elsewhere. -->
## Local verification
<!-- The pre-commit hook already runs lint + typecheck + vitest +
build + size + ruff + pytest. Confirm each landed green before push. -->
- [ ] `pnpm typecheck` clean
- [ ] `pnpm test` clean (Vitest)
- [ ] `pnpm build:extension && pnpm size` within budget
- [ ] `pnpm build:manager` builds
- [ ] `ruff check .` clean
- [ ] `pytest -q` clean
- [ ] If touching engine: parity test (`pytest tests/syntax/test_determinism.py`) still passes
## Screenshots / before-after
<!-- For visible changes (canvas widgets, SPA views, docs pages) drop
in a before / after pair. The reviewer thanks you. -->
## Anything that could go wrong
<!-- Migration concerns? Workflow back-compat? A widget rename users
might trip over? Surface it here so the release notes can flag it. -->
-65
View File
@@ -1,65 +0,0 @@
name: Publish to ComfyUI Registry
# Mirrors what the ComfyUI Manager search results read from. Fires on
# every published GitHub release so the registry listing stays in sync
# with semantic-release tags. Manual dispatch left available for
# re-publishing if a release ever needs to be force-pushed.
#
# Calls `comfy node publish` directly (via comfy-cli) instead of the
# wrapper `Comfy-Org/publish-node-action@main`. The wrapper runs its
# own internal `actions/checkout@v4` step before zipping, which wipes
# the working directory back to a fresh git state — and since `js/`
# and `web/` are gitignored build outputs, they get nuked between our
# `pnpm run build` step and the zip step. The result was a published
# zip with empty js/ + web/ directories (canvas widgets + manager SPA
# never loaded for end users). Calling comfy-cli directly preserves
# the build artifacts on disk into the publish call.
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 1
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with:
node-version: "22"
cache: pnpm
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- run: pnpm install --frozen-lockfile
# Full build = extension chunk (js/) + manager SPA (web/).
# Both directories must be on disk before `comfy node publish`
# runs so the registry archive ships actual frontend code instead
# of empty stubs. [tool.comfy].includes = ["js", "web"] in
# pyproject.toml force-includes them past .gitignore.
- run: pnpm run build
# Sanity gate — fail fast if build didn't actually produce the
# shippable trees. Without this guard a broken build would
# silently push empty directories to the registry (we already
# shipped one such broken release; this prevents repeats).
- name: Verify build output
run: |
test -d js && test "$(ls -A js)" || (echo "::error::js/ is empty after build" && exit 1)
test -d web && test "$(ls -A web)" || (echo "::error::web/ is empty after build" && exit 1)
echo "js/ contents:" && ls -la js/
echo "web/ contents:" && ls -la web/
- run: pip install comfy-cli
- name: Publish to ComfyUI Registry
run: |
comfy --skip-prompt --no-enable-telemetry node publish \
--token ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+3 -28
View File
@@ -12,30 +12,13 @@ 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/
web/*
!web/index.html
coverage/
.coverage
.vitest-cache/
# Scratch dir for screenshots, lighthouse reports, and other artifacts
# generated during local QA. Never belongs in the tree.
.tmp/
# Archives never belong in the repo (caught a stray src.zip
# hand-dropped into the tree on Apr 26)
*.zip
*.tar
*.tar.gz
*.tgz
# Editor / OS
.vscode/
.idea/
@@ -48,17 +31,9 @@ Thumbs.db
# Backups dropped by /caveman:compress next to compressed files
*.original.md
# Docs — ignore everything except per-node help pages + canonical contracts
# Docs — ignore everything except per-node help pages
/docs/*
!/docs/help/
!/docs/import-export-contract.md
# Semantic release
.releaserc.local.js
# Throwaway design / scratch artifacts (mockups, screenshots, demos)
/_temp/
# DB location control — per-contributor sidecar config + root-location DB dir
db-config.json
/db/
+1 -1
View File
@@ -1 +1 @@
pnpm exec commitlint --edit "$1" --config config/commitlint.config.js
pnpm exec commitlint --edit "$1"
+6 -119
View File
@@ -1,95 +1,8 @@
/**
* semantic-release config.
*
* Two non-default behaviours that matter for release-notes readability:
*
* 1. Use the `conventionalcommits` preset (not the default `angular`).
* Lets us configure per-type rendering via `presetConfig.types`.
*
* 2. Hide every type that isn't a user-facing change. `feat`, `fix`,
* `perf`, `refactor` render in the GitHub release notes;
* `chore`, `docs`, `style`, `test`, `build`, `ci`, `revert` are
* marked `hidden: true` so they don't clutter the changelog
* (commits still count for version bumps via the commit-analyzer
* `releaseRules` below).
*
* Without these the angular preset rendered EVERY conventional commit
* since the last tag — 1000+ entries for v1.8.0. With the filter the
* notes shrink to just the meaningful changes (typically <50 entries
* per release).
*/
export default {
branches: ["main"],
plugins: [
[
"@semantic-release/commit-analyzer",
{
preset: "conventionalcommits",
releaseRules: [
{ type: "feat", release: "minor" },
{ type: "fix", release: "patch" },
{ type: "perf", release: "patch" },
{ type: "refactor", release: "patch" },
{ type: "revert", release: "patch" },
// `docs(readme)` is user-facing — bump patch so the new
// install instructions / quick-start trigger a release.
// Other docs scopes (in-app help, internal design docs)
// don't move the needle.
{ type: "docs", scope: "readme", release: "patch" },
{ type: "docs", release: false },
{ type: "style", release: false },
{ type: "test", release: false },
{ type: "build", release: false },
{ type: "ci", release: false },
{ type: "chore", release: false },
],
},
],
[
"@semantic-release/release-notes-generator",
{
preset: "conventionalcommits",
presetConfig: {
// Section labels rendered in the GitHub release body +
// CHANGELOG.md. The emoji prefixes scan well on the GitHub
// release page where each section becomes a collapsible
// heading.
types: [
{ type: "feat", section: "✨ Features" },
{ type: "fix", section: "🐛 Bug Fixes" },
{ type: "perf", section: "⚡ Performance" },
{ type: "refactor", section: "♻️ Refactoring" },
{ type: "revert", section: "⏪ Reverts" },
{ type: "docs", hidden: true },
{ type: "style", hidden: true },
{ type: "test", hidden: true },
{ type: "build", hidden: true },
{ type: "ci", hidden: true },
{ type: "chore", hidden: true },
],
},
writerOpts: {
// headerPartial puts a fixed "TL;DR + useful links" block at
// the very top of every release body, so the first ~600
// chars GitHub renders above the "Read more" fold on the
// releases list view stay useful no matter how long the
// changelog gets. {{version}} expands to the new tag.
headerPartial:
"## 🎉 Wildcard Pipeline {{version}}\n\n" +
"📖 [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)\n\n" +
"<details>\n" +
"<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>\n\n",
// footerPartial closes the <details> wrapper so the wall of
// commits stays collapsed by default. GitHub renders
// <details> natively in markdown — readers click to expand
// when they actually need the per-commit log.
footerPartial: "\n</details>",
},
},
],
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/changelog",
{ changelogFile: "CHANGELOG.md" },
@@ -97,44 +10,18 @@ export default {
[
"@semantic-release/exec",
{
// 1. Stamp version into package.json + pyproject.toml.
// 2. Pack the deployable file set into a zip so the
// @semantic-release/github plugin can attach it to the
// release. Zip is built AFTER versioning so the file lands
// with the right version in its name.
prepareCmd:
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node scripts/pack-release.mjs ${nextRelease.version}",
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\"",
},
],
[
"@semantic-release/git",
{
assets: ["CHANGELOG.md", "package.json", "pyproject.toml"],
// Plain message — `${nextRelease.notes}` here would expand to
// the entire changelog, which on a divergent history (where
// semantic-release can't find the last tag in main's first-
// parent chain) blows past the OS arg-list limit and `git
// commit` exits with E2BIG. The full notes already live in:
// 1. CHANGELOG.md (asset committed in the same step)
// 2. The GitHub release body (via @semantic-release/github)
// So duplicating them inside the commit message is pure cost
// for zero reader benefit.
message: "chore(release): ${nextRelease.version} [skip ci]",
},
],
[
"@semantic-release/github",
{
// Attach the deployable zip + its sha256 to the GitHub release
// page so manual installers can grab a single artifact instead
// of cloning the whole repo at a specific tag.
assets: [
{
path: "dist/ComfyUI-Wildcard-Pipeline-v*.zip",
label: "ComfyUI-Wildcard-Pipeline-v${nextRelease.version}.zip",
},
],
message:
"chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
},
],
"@semantic-release/github",
],
};
+123 -488
View File
@@ -1,517 +1,152 @@
## 🎉 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)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* feat(api): add GET /wp/api/database/info ([dc839bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dc839bf))
* feat(api): add POST /wp/api/database/maintenance ([0909817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0909817))
* feat(api): expose db config get/put endpoints with pending-move ([04d3795](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/04d3795))
* feat(api): run pending db move before migration on startup ([00b0c73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/00b0c73))
* feat(engine): add analyze maintenance op ([d39add1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d39add1))
* feat(engine): add gather_info DB introspection ([2b5f2b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2b5f2b7))
* feat(engine): add integrity_check maintenance op ([7f12575](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7f12575))
* feat(engine): add run_migrations maintenance op ([5a62929](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a62929))
* feat(engine): add sidecar db-config.json reader/writer ([069527a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/069527a))
* feat(engine): add user/global/root location path helpers ([747a84a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/747a84a))
* feat(engine): add vacuum maintenance op ([4abe679](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4abe679))
* feat(engine): execute pending db moves at plugin load ([52966e6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/52966e6))
* feat(engine): expose db path resolution source ([7e12626](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7e12626))
* feat(engine): honor sidecar preference and add root db location ([c03fc07](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c03fc07))
* feat(manager): add always-visible restart comfyui row to maintenance section ([c6a1030](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6a1030))
* feat(manager): add database API client + types ([f20a9e3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f20a9e3))
* feat(manager): add database card with info and maintenance ([df7eb75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df7eb75))
* feat(manager): add databaseStore Pinia store ([2003f38](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2003f38))
* feat(manager): add db config types and api client methods ([8c760f2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c760f2))
* feat(manager): add location selector with copy or move flow ([eb48d7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eb48d7f))
* feat(manager): add maintenance op three-phase modal ([7cfe29b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7cfe29b))
* feat(manager): add restart comfyui button via manager reboot endpoint ([02c8300](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02c8300))
* feat(manager): browser preferences card with full prefix coverage ([b2b75d4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b2b75d4))
* feat(manager): detect server restart and prompt stale page reload ([7102057](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7102057))
* feat(manager): extend database store with config and pending move actions ([8eb5967](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8eb5967))
* feat(manager): fade routes on tab switch with reduced-motion fallback ([9a053e8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a053e8))
* feat(manager): heartbeat poll every 30s so stale banner appears without tab switch ([efb38ff](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/efb38ff))
* feat(manager): replace storage card with browser prefs and database cards ([885a2d0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/885a2d0))
* fix(manager): flex-based row layout for database info, wrap long paths ([a89bf7d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a89bf7d))
* fix(manager): probe manager via get /manager/version instead of head reboot ([843a679](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/843a679))
* chore: ignore db-config.json sidecar and root db dir ([d23a728](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d23a728))
* chore(tests): fix fresh_db fixture return type annotation ([ecdbf6c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ecdbf6c))
</details>
## 🎉 Wildcard Pipeline 2.0.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>
* fix(ci): bypass wrapper action so publish.yml actually ships js + web ([3e755cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3e755cc))
</details>
## 🎉 Wildcard Pipeline 2.0.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>
* ci: re-fire release after v1.8.0 tag re-anchor ([7ababca](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7ababca))
* ci: re-trigger release workflow after E2BIG fix ([1287556](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1287556))
* fix(release): drop nextRelease.notes from git commit message to avoid E2BIG ([ea90f97](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/ea90f97))
* fix(spa): inject version + license at build time so About card stays in sync ([6f94bc5](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6f94bc5))
* feat!: cut v2.0.0 — modular procedural prompt pipeline general availability ([64aa7d5](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/64aa7d5))
* chore: pre-push audit cleanup — webmanifest, web_dist drift, dead TypePill ([b8c0387](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/b8c0387))
* chore: tidy ComfyUI-Wildcard-Pipeline name in LICENSE + links comment ([4adb5b9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4adb5b9))
* feat(github): publish workflow + issue/PR templates + release-zip + cleaner notes ([9b91012](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9b91012))
* feat(registry): comfyignore + 21:9 banner + includes + collapsed changelog header ([730721f](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/730721f))
* docs(readme): rewrite with svg flow banners + comfyui manager install path ([892479a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/892479a))
</details>
# [1.7.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.6.0...v1.7.0) (2026-04-29)
### Features
* **context:** module picker + lock/internal + card indicators ([#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8)) ([b1fbd13](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b1fbd13af32415c087288f7f849b8b505f53da1d))
# [1.6.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.5.0...v1.6.0) (2026-04-28)
## [1.4.2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.4.1...v1.4.2) (2026-04-28)
### Bug Fixes
* **api:** list /modules `total` reflects unpaginated row count ([d536484](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d53648430d5ad7f5afcdd78e3ad7f3538d06ac3f))
* **spa:** chip pills, derivation $var dropdown, constraint subcat axes ([bab0fe9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bab0fe99660f6f0032d3e115ce08e5182cd5b7a3))
* **spa:** pipeline row — drop legacy refselect rules clipping shared select ([3e20ba5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3e20ba543c5c828cb9fb4aca544e5595a1ed535b))
* **spa:** rich-text caret + wildcards-list syntax pills ([1298645](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/12986454eb74b947828c6ffc90fa0e09e724698b))
* **spa:** wildcard ref-graph keys by 8-hex UUID, not full slug id ([5f12372](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5f123722f058ebc44b377f3b735ae5ad289c5f54))
* **ui:** select dropdown — menu scroll works, viewport-aware max-height ([3b9262a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3b9262ab2d24e80884b4153ef950555b46b17206))
* **spa:** @ autocomplete inserts UUID, editor cross-refs, derivation $ vars ([89c1662](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/89c16624db8c8859a9ba2c683afb5e549b6ecd5b))
* **spa:** @ autocomplete UUIDs, editor cross-refs, derivation $ vars ([0a3e922](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0a3e92234ff1a4dd24905942ceb407a1b8ee3ec7))
* **spa:** extract 8hex UUID suffix from module DB id for `@{uuid}` refs ([3d18829](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3d18829e54b52eaac64d7a854aabe40906a39da6))
### Features
* **spa:** uuid pills show var-names everywhere; clickable in test runner ([df046f5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df046f57935c42f1dceaa4196b1b99fbf49ac5b3))
# [1.5.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.4.2...v1.5.0) (2026-04-28)
## [1.4.1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.4.0...v1.4.1) (2026-04-28)
### Bug Fixes
* **api:** flatten embed-bundle response to match other endpoints ([d1d8d02](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d1d8d025a99e928a7d5af196de4a0c48bc5a5ed2))
* **test-runner:** revert response envelope to flat shape ([1479d57](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1479d57817b3b4599a263a9edca6517d8a820acb))
* **engine:** bump now_iso() to microsecond precision ([fa6c4a1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/fa6c4a1612ec94d2397a6db92a716d1288c09d89))
* post-merge CI + docs/ ignore ([90f5492](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/90f549259ded6726948738fdea2d4eefdf3c313e))
### Features
* **api:** add /modules/hashes endpoint for drift detection ([27f3cc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/27f3cc989073f8b32535e1e31b714e103e6dca53))
* **api:** add embed-bundle endpoint for lazy walk + transitive deps ([be0b817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be0b81768ebdf38a439afdf27d5a344a48190544))
* **db:** add get_by_uuid + get_by_uuids indexed lookups ([a25c700](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a25c700a9b7e4d6ecb8468d5008010e2d5a52817))
* **db:** add indexed uuid column to modules with backfill ([c2f575d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c2f575d6f17fa2ed4303fbefb1cea578a65c621e))
* **db:** repository writes uuid + returns payload_hash on every row ([b14b1c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b14b1c297c88edf14c3beb4e22e0218a28506633))
* **phase-5.5:** backend bridge — lazy catalog + walker + endpoints ([6ddb0e6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6ddb0e68925cc8fcf84edd2d8acd3ac775c52ba5))
* **snapshot:** add canonical SnapshotEntry TypedDict (spec §2.4) ([4d9faa6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4d9faa607a70ba77ee91187cf5e0954d840b3ee3))
* **snapshot:** walk_transitive_refs lazy walker (spec §2.10) ([0b43b78](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0b43b781159bacced67b0c3d6792a7a4907bef6a))
* **spa-types:** add SnapshotEntry + EmbedBundle + embedBundle/hashes client ([bfe98f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bfe98f43b8fd1035edc2f8a297d1db369874349f))
* **test-runner:** lazy catalog — resolve nested @{uuid} refs ([0f6fe72](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f6fe721829130bb8bfe12904e06fe510bccfde8))
* **wp-api:** extract_referenced_uuids helper for lazy catalog roots ([339a3f3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/339a3f38f283f6fddf84db3c25bd223a52cb03e7))
* **wp-nodes:** deserialize_node_input — modules + snapshots + pickOrder ([c319eef](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c319eef2763fdd793967226eb738256292ef0d18))
* **wp-nodes:** inject __wp_catalog__ from embedded snapshots in WP_Context ([323dd55](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/323dd55e63b3116bb805c3809712dd0b10f09862))
### Performance Improvements
* **test-runner:** hoist catalog walk above per-sample loop ([8817e42](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8817e423b52f99de669ab5d9a6559fe2c97b97f8))
## [1.4.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.4.1...v1.4.2) (2026-04-28)
# [1.4.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.3.0...v1.4.0) (2026-04-28)
### Bug Fixes
* **spa:** @ autocomplete inserts UUID, editor cross-refs, derivation $ vars ([89c1662](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/89c16624db8c8859a9ba2c683afb5e549b6ecd5b))
* **spa:** @ autocomplete UUIDs, editor cross-refs, derivation $ vars ([0a3e922](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0a3e92234ff1a4dd24905942ceb407a1b8ee3ec7))
* **spa:** extract 8hex UUID suffix from module DB id for `@{uuid}` refs ([3d18829](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3d18829e54b52eaac64d7a854aabe40906a39da6))
* **api:** atomic import + recursion guard + negative pagination + dry now_iso ([82b6c6e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b6c6eb37469b1f515639d0883b69f06be8f85b))
* **api:** run migrations on register_routes so production db has tables ([1fc705d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1fc705d8da7b1ea7a2ae77f3449816bc6d9e4847))
* **api:** use streamresponse return type for spa fallback handlers ([a12ee20](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a12ee2087c079ac3cab13020abaecd023f107bfd))
* **ci:** python 3.10 datetime compat + rebaseline perf budgets ([eecb38e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/eecb38e85f170b807f109f7149d25eb6d60406e3))
* **db:** drop utcnow + atomic migrations + row factory test ([3810a37](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3810a373489fbcbaed204017b6ad9626e1706938))
* **db:** id entropy + like escaping + ms timestamps + sentinel typing ([759626b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/759626b9148345af319e877699e85856e13d084b))
* **db:** unset sentinel for required fields + slug validation + delete consistency ([c4ca447](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c4ca4472ba44bab4259fd5590f25bd8e5570d91c))
* **engine:** explicit type guard in resolve_module for malformed snapshots ([da0daf4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/da0daf47e0fc8f781179d6bb35d185ac9f503f29))
* **engine:** isinstance guard avoids dataclass typeguard issue in handlers ([f4bf3d4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f4bf3d4635a53c57214340a6c00a5a38923f942b))
* **engine:** read module id from raw dict to satisfy union narrowing ([9a98626](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9a98626dd767ae50fc6c6e03ced9a34a68b19f9a))
* **manager:** favorites filter as wp-input box matching prototype ([c246360](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c246360b97aba838e782e5c4dc19852c7baf7a62))
* **spa:** align theme tokens with extension source of truth ([2917c04](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2917c04c3897b40d23287ec152883ac77488d84f))
* **spa:** compact buttons + click-to-copy id + logo backdrop + dollar icon + hex color picker ([19727ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/19727ade47e057a10cb441342d5b0bab377435cf))
* **spa:** enable primevue dark mode via .wp-dark class on documentelement ([fab8d0d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/fab8d0d20fcd4eb1ca5d6998c984b8ba424e9435))
* **spa:** error handling on store actions + exact-active-class + matchmedia setup ([4cfe1f6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4cfe1f6d1487c4bb12b5daa0bbd4b4db57ac6106))
* **spa:** light-mode + dropdown polish for chips, pills, selects ([cd8d23a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/cd8d23a750cc5a8dd7c8f0aa195f95fe61e14a72)), closes [#a78bfa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/a78bfa) [#c026d3](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/c026d3)
* **spa:** wrap tailwind in named layers so primevue specificity orders correctly ([f5652f4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f5652f4286e6b0cbcd6290bf0704f9c335ab8ba2))
* **syntax:** drop vacuous "comment" check in richTokenize.test ([dee9fae](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/dee9fae32808697aeaeb28a52caa24baeaa12801))
## [1.4.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.4.0...v1.4.1) (2026-04-28)
### Features
* **api:** /wp/api/modules crud endpoints ([89b7827](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/89b7827534e9814f93cac73d4c4c9261f3b4c9c6))
* **api:** snapshot + match + duplicate + favorite + categories endpoints ([48ee214](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/48ee214f9667eadae372a17b6f25dc58eb8cf42f))
* **api:** spa fallback route + comfyui registration + coverage gates ([51c1756](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51c17565b8ea28864d076e5829d9689ef4f15967))
* **api:** test runner + library import/export endpoints ([625cab7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/625cab76820eaa6aa6b31cff91d3284f5a40c5b9))
* **db:** category repository with crud ([746f3ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/746f3ad2db6e2e5430c2dde79671bbecbeb0322c))
* **db:** module repository with crud and filtered list ([6c29f73](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6c29f738c50575329ce240c1cc7cc4c6990370dc))
* **db:** sqlite library foundation + migration runner ([e4aabe6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4aabe6313265a263bca1d3790d43b645f4366c0))
* **engine:** fixedvalueshandler + route legacy dispatch through it ([2eeb900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2eeb90052e34952d648e3b8e4456c2b2a0439164))
* **engine:** module dispatcher + modulehandler abc ([f33ac4c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f33ac4c9803b011f4efb26694f0d674c72970677))
* **engine:** scaffold combine/derivation/constraint/pipeline module types ([a16c532](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a16c5324e281ffba6a08d3eda46f3deecf2faefa))
* **engine:** snapshot freezing + payload hash + legacy coercion ([9d183d6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d183d6779a9a6ce1a9ebbdd558478420883000d))
* **engine:** wildcardhandler with weighted rng and @{ref} resolution ([6626aca](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6626aca3d7cde2661d6276e867c362e6a9fecb34))
* **manager:** favorites filter as chip toggle box in filter panel ([f51b752](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f51b752fd7a5ad84d3e9e785c4f2c9e98b4b1c84))
* **manager:** surface-conditional @ autocomplete + warning highlights ([7f6f5b7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7f6f5b719276019e3409e213cd4e44997977c7a6))
* **manager:** wildcard.max_ref_depth setting (1-32, default 8) ([bc1d21e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/bc1d21e8aa040c6d6bef0c932b16c9b9bfa88663))
* **spa:** add constraints to soon-disabled module types ([9d8a5ab](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d8a5ab00e565c163c93bba7ce2e5acdce04e96f))
* **spa:** categories color picker (hex + swatch + presets) + sticky-header scroll ([f18be1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f18be1eaff1cc197ef03251a1a23f4bfdddb2115))
* **spa:** categories manager + import/export + test runner ([a20a7b0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a20a7b058b64f4b5347de39491e4828c15af0066))
* **spa:** community tab — discover/detail/upload/profile + offline/404 (mock) ([f1add58](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f1add58631286260cde31332e2a1bb40f8a256e7))
* **spa:** dashboard hero + branded categories/import/test pages ([e49df41](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e49df411d7f56dc89a309f5f5fff057fa32c324a))
* **spa:** datatable expansion + category color chip + tags column ([133c8df](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/133c8df49166fda2f75faba5d79fac44a295781c))
* **spa:** entity list view redesign with active-filter chips + pagination ([370fdd8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/370fdd838828d96667d48d21c047d128d69325dd))
* **spa:** entitylistview shared list skeleton ([5e8d042](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/5e8d0428d4da0fac6c5dcbe2da6c495ab96a79c7))
* **spa:** fixedvalues.vue per-type list page ([32f0513](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/32f05134202cc035e992f5f4b064971e1a9d318a))
* **spa:** full-page wildcard + fixedvalue forms with sticky footer ([82b568e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b568efbb1c75f89980048c5cc09858ea5a42ba))
* **spa:** import/export with per-item selection + conflict resolution ([a5dfe52](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a5dfe52511e81251d333d6b7cfbfd37e48f8ff00))
* **spa:** kind-aware Test Runner with per-type result panels ([8e28b40](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8e28b40655930db4f48042f21f7c589149b1440d))
* **spa:** library datatable view with filters + row actions ([af66ca2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/af66ca263e49198fe46efb7e187d6fce2133408a))
* **spa:** per-module version history with last-3 snapshots and restore panel ([678a0eb](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/678a0eb40317cfad442005908378ccb98e6439dc))
* **spa:** per-type routes + drop polymorphic modules.vue ([33542e1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/33542e123994f462060b341de2c1c25f2a222bb5))
* **spa:** pinia stores for modules + categories + ui ([86c36e0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/86c36e0f52eba4abfb37a4f1621c928ee94e777d))
* **spa:** polish fixed-value form with \$name InputGroup + per-row identifier validation ([928e33c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/928e33cc5ff445ca11e219fee2fb1a9acadd9a64))
* **spa:** port community sub-screens + drop PrimeVue dependency (Wave 7+8) ([e4ab218](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4ab2186e33f04908ff43b0699635bc3f3a982c8))
* **spa:** port prototype dashboard + utilities views (Wave 5) ([1d13048](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1d1304898267ca0f9521adf16b19712cdef8e1a9))
* **spa:** port prototype editors with EditorFrame scaffold (Wave 4) ([84fc961](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/84fc961fc2f4c31be250ef011e1c6f88430439f4))
* **spa:** port prototype ModuleListView + drop PrimeVue from list screens (Wave 3) ([df5d38d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/df5d38d6250c7856e34b2ac2a7b80ff649da8e0d))
* **spa:** port prototype shell — topbar + sidebar 1:1, drop PrimeVue (Wave 2) ([30fed89](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/30fed89ce3aad313faad8b138eed808766763f56))
* **spa:** port prototype ui primitives + full styles.css (Wave 1) ([03d2b7b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/03d2b7bde50cd984bf312e73c6f0d200fc731a82))
* **spa:** primevue brand preset derived from --wp-* tokens ([76bcd36](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/76bcd3629d609ce79652109e63025b7027104c6e))
* **spa:** real Combine editor with template + RichTextInput + detected inputs ([d50dd8d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d50dd8d6015fa01466c34470833ac7ed6b9bf2e8))
* **spa:** real Constraint editor with matrix grid + factor tune popovers ([02a526a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/02a526a53c7972382ca06742f7d3f8ad73bdfdeb))
* **spa:** real Derivation editor with multi-rule IF/ELIF/ELSE ([4c603a2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4c603a20db432d9bb4a5c785411af2aa543817ba))
* **spa:** real Pipeline editor with steps stack + module picker modal ([054a777](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/054a777ec1522d07fe017f81bacbfdc302df6100))
* **spa:** redesign Dashboard with brand hero + 6 kind stats + recent/favorites ([0dcb562](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0dcb562801dd1d0fe395bbe1aedbda037ef87e85))
* **spa:** rich text input + preview with token chips and autocomplete ([6a5bbb4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6a5bbb40ef56f66fa1ea1063aa8ddc5f4cedc700))
* **spa:** rich-text input — restore $/@ autocomplete dropdown with keyboard nav ([60f911a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/60f911a0f8e2e32805e3153b698939e99e90123f))
* **spa:** sakai-style topbar + sidebar layout ([79163fc](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79163fce79e558c61f201cc44de6ab7405635a0b))
* **spa:** scaffold combine/derivation/constraint/pipeline list + edit views ([8c03e72](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8c03e72d69785a7ae2b7101250cd35987e307231))
* **spa:** shared typepill + relativedate components ([f3a1066](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f3a10664ab64bc1da83914629dbfe07d1adcda71))
* **spa:** tags editor on forms + sort dropdown + category/tags filters ([0722da2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0722da2a98ae4f544a94df241faf17dad40ac0d2))
* **spa:** theme toggle (dark/light/auto) with flash-suppression ([1b7576a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1b7576a55fb33af05b2f4ca6355354d1a032ab2e))
* **spa:** topbar logo + 4-section sakai sidebar with primeicons ([42f30ef](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/42f30efbe7c699d9f7ab2565cee37ce48ec123a2))
* **spa:** tweaks panel — accent palette + density + sidebar runtime switcher (Wave 6) ([975581e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/975581e111f1acfb13ec421a925af68a6fda8592))
* **spa:** typed api client wrapping /wp/api/* ([7a7b632](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a7b632f0693976b20b16ed5ef4c2aacb8dd7890))
* **spa:** vite + tailwind + primevue scaffold ([7a47035](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a4703546edc2467a75b4c132915bdf666e4dd70))
* **spa:** vue router + 7 stub views ([d80fd24](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d80fd242545ef794aa0b013f1ff1178f65242919))
* **spa:** wildcard + fixed-values editors ([8abab1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8abab1ee3d0e83604030c2e97e777e23275dc86a))
* **spa:** wildcard form gains varBinding field + RichTextInput on option values ([d84bd2d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d84bd2d60b048fe6790d629d3b46067304050e8c))
* **spa:** wildcard sub-categories define + assign per option ([8a1b820](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8a1b820ab3f3998d1abcb7160d19c549daa74c52))
* **spa:** wildcards list — syntax indicator column + nested-ref filters ([80cc323](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/80cc323f31b394c95a4de6f9e429bbf6fd6cd1db))
* **spa:** wildcards.vue with full datatable + filter + bulk select ([22c29aa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/22c29aa090511b352c80035c60702435f0da431a))
* **syntax:** _resolve_inline_pick — uniform random branch + recurse ([2f48900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2f489002e17de9ea092a05dde1b069f3edf0fbdc))
* **syntax:** _resolve_multi_pick — weighted without-replacement + sep join ([966ab4b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/966ab4b1aac965f7b8601517a8ddfeb534aeb7a5))
* **syntax:** _resolve_ref with surface/depth/cycle checks + weighted pick ([51a859b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51a859b0956091fee9e296097b9b6779dd99b52d))
* **syntax:** foundation types, error classes, ResolveContext Protocol ([45c2fa9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/45c2fa9bc48cecb25410aa04fd3ee993b442afb8))
* **syntax:** resolve_text dispatch loop with text/escape/var resolution ([9e05e1f](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9e05e1fb668edc9bcabbcfeda154e90c1682e4ea))
* **syntax:** resolveTokens TS twin of Python resolve_text ([9d3c6a9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d3c6a9851be55d60ea24df8abc9953f662eda38))
* **syntax:** tokenize $var and @{uuid} kinds + corpus cases ([4fb2c99](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4fb2c997714d9bfc51f806b65a02b3bba8c0f4be))
* **syntax:** tokenize dp_brace + dp_multi with nesting support ([9364eb8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9364eb84f5899ab7d2e2dc2111f4c769d3c2c384))
* **syntax:** tokenize_text — text + escape kinds with lossless invariant ([11ac437](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/11ac437fa391edcacdf210b8cb3e9d73e7c48cf7))
# [1.3.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.2.0...v1.3.0) (2026-04-26)
### Features
* i18n locales (en) + per-node help pages ([#2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/2)) ([5216547](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/52165471c0da315db59d2b2fb72537d7b06d62ef))
# [1.2.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.1.0...v1.2.0) (2026-04-26)
### Features
* polish suite — MVP frontend + subgraph support + DX + a11y ([79bdb45](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79bdb457cbc98e191766e3fa7c84df2e1106ecc2))
# [1.1.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.0.0...v1.1.0) (2026-04-24)
### Bug Fixes
* **engine:** bump now_iso() to microsecond precision ([fa6c4a1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa6c4a1612ec94d2397a6db92a716d1288c09d89))
* post-merge CI + docs/ ignore ([90f5492](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/90f549259ded6726948738fdea2d4eefdf3c313e))
# [1.4.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.3.0...v1.4.0) (2026-04-28)
### Bug Fixes
* **api:** atomic import + recursion guard + negative pagination + dry now_iso ([82b6c6e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/82b6c6eb37469b1f515639d0883b69f06be8f85b))
* **api:** run migrations on register_routes so production db has tables ([1fc705d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1fc705d8da7b1ea7a2ae77f3449816bc6d9e4847))
* **api:** use streamresponse return type for spa fallback handlers ([a12ee20](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a12ee2087c079ac3cab13020abaecd023f107bfd))
* **ci:** python 3.10 datetime compat + rebaseline perf budgets ([eecb38e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eecb38e85f170b807f109f7149d25eb6d60406e3))
* **db:** drop utcnow + atomic migrations + row factory test ([3810a37](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3810a373489fbcbaed204017b6ad9626e1706938))
* **db:** id entropy + like escaping + ms timestamps + sentinel typing ([759626b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/759626b9148345af319e877699e85856e13d084b))
* **db:** unset sentinel for required fields + slug validation + delete consistency ([c4ca447](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c4ca4472ba44bab4259fd5590f25bd8e5570d91c))
* **engine:** explicit type guard in resolve_module for malformed snapshots ([da0daf4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/da0daf47e0fc8f781179d6bb35d185ac9f503f29))
* **engine:** isinstance guard avoids dataclass typeguard issue in handlers ([f4bf3d4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f4bf3d4635a53c57214340a6c00a5a38923f942b))
* **engine:** read module id from raw dict to satisfy union narrowing ([9a98626](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a98626dd767ae50fc6c6e03ced9a34a68b19f9a))
* **manager:** favorites filter as wp-input box matching prototype ([c246360](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c246360b97aba838e782e5c4dc19852c7baf7a62))
* **spa:** align theme tokens with extension source of truth ([2917c04](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2917c04c3897b40d23287ec152883ac77488d84f))
* **spa:** compact buttons + click-to-copy id + logo backdrop + dollar icon + hex color picker ([19727ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/19727ade47e057a10cb441342d5b0bab377435cf))
* **spa:** enable primevue dark mode via .wp-dark class on documentelement ([fab8d0d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fab8d0d20fcd4eb1ca5d6998c984b8ba424e9435))
* **spa:** error handling on store actions + exact-active-class + matchmedia setup ([4cfe1f6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4cfe1f6d1487c4bb12b5daa0bbd4b4db57ac6106))
* **spa:** light-mode + dropdown polish for chips, pills, selects ([cd8d23a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cd8d23a750cc5a8dd7c8f0aa195f95fe61e14a72)), closes [#a78bfa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/a78bfa) [#c026d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/c026d3)
* **spa:** wrap tailwind in named layers so primevue specificity orders correctly ([f5652f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f5652f4286e6b0cbcd6290bf0704f9c335ab8ba2))
* **syntax:** drop vacuous "comment" check in richTokenize.test ([dee9fae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dee9fae32808697aeaeb28a52caa24baeaa12801))
* **engine:** annotate Module as TypeAlias so Pyright treats it as a type ([e1ab2f1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e1ab2f1f18f99c2e9971baeaeed9f769070b4099))
### Features
* **api:** /wp/api/modules crud endpoints ([89b7827](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/89b7827534e9814f93cac73d4c4c9261f3b4c9c6))
* **api:** snapshot + match + duplicate + favorite + categories endpoints ([48ee214](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/48ee214f9667eadae372a17b6f25dc58eb8cf42f))
* **api:** spa fallback route + comfyui registration + coverage gates ([51c1756](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51c17565b8ea28864d076e5829d9689ef4f15967))
* **api:** test runner + library import/export endpoints ([625cab7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/625cab76820eaa6aa6b31cff91d3284f5a40c5b9))
* **db:** category repository with crud ([746f3ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/746f3ad2db6e2e5430c2dde79671bbecbeb0322c))
* **db:** module repository with crud and filtered list ([6c29f73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6c29f738c50575329ce240c1cc7cc4c6990370dc))
* **db:** sqlite library foundation + migration runner ([e4aabe6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e4aabe6313265a263bca1d3790d43b645f4366c0))
* **engine:** fixedvalueshandler + route legacy dispatch through it ([2eeb900](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2eeb90052e34952d648e3b8e4456c2b2a0439164))
* **engine:** module dispatcher + modulehandler abc ([f33ac4c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f33ac4c9803b011f4efb26694f0d674c72970677))
* **engine:** scaffold combine/derivation/constraint/pipeline module types ([a16c532](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a16c5324e281ffba6a08d3eda46f3deecf2faefa))
* **engine:** snapshot freezing + payload hash + legacy coercion ([9d183d6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d183d6779a9a6ce1a9ebbdd558478420883000d))
* **engine:** wildcardhandler with weighted rng and @{ref} resolution ([6626aca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6626aca3d7cde2661d6276e867c362e6a9fecb34))
* **manager:** favorites filter as chip toggle box in filter panel ([f51b752](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f51b752fd7a5ad84d3e9e785c4f2c9e98b4b1c84))
* **manager:** surface-conditional @ autocomplete + warning highlights ([7f6f5b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7f6f5b719276019e3409e213cd4e44997977c7a6))
* **manager:** wildcard.max_ref_depth setting (1-32, default 8) ([bc1d21e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bc1d21e8aa040c6d6bef0c932b16c9b9bfa88663))
* **spa:** add constraints to soon-disabled module types ([9d8a5ab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d8a5ab00e565c163c93bba7ce2e5acdce04e96f))
* **spa:** categories color picker (hex + swatch + presets) + sticky-header scroll ([f18be1e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f18be1eaff1cc197ef03251a1a23f4bfdddb2115))
* **spa:** categories manager + import/export + test runner ([a20a7b0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a20a7b058b64f4b5347de39491e4828c15af0066))
* **spa:** community tab — discover/detail/upload/profile + offline/404 (mock) ([f1add58](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f1add58631286260cde31332e2a1bb40f8a256e7))
* **spa:** dashboard hero + branded categories/import/test pages ([e49df41](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e49df411d7f56dc89a309f5f5fff057fa32c324a))
* **spa:** datatable expansion + category color chip + tags column ([133c8df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/133c8df49166fda2f75faba5d79fac44a295781c))
* **spa:** entity list view redesign with active-filter chips + pagination ([370fdd8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/370fdd838828d96667d48d21c047d128d69325dd))
* **spa:** entitylistview shared list skeleton ([5e8d042](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5e8d0428d4da0fac6c5dcbe2da6c495ab96a79c7))
* **spa:** fixedvalues.vue per-type list page ([32f0513](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/32f05134202cc035e992f5f4b064971e1a9d318a))
* **spa:** full-page wildcard + fixedvalue forms with sticky footer ([82b568e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/82b568efbb1c75f89980048c5cc09858ea5a42ba))
* **spa:** import/export with per-item selection + conflict resolution ([a5dfe52](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a5dfe52511e81251d333d6b7cfbfd37e48f8ff00))
* **spa:** kind-aware Test Runner with per-type result panels ([8e28b40](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8e28b40655930db4f48042f21f7c589149b1440d))
* **spa:** library datatable view with filters + row actions ([af66ca2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af66ca263e49198fe46efb7e187d6fce2133408a))
* **spa:** per-module version history with last-3 snapshots and restore panel ([678a0eb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/678a0eb40317cfad442005908378ccb98e6439dc))
* **spa:** per-type routes + drop polymorphic modules.vue ([33542e1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33542e123994f462060b341de2c1c25f2a222bb5))
* **spa:** pinia stores for modules + categories + ui ([86c36e0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/86c36e0f52eba4abfb37a4f1621c928ee94e777d))
* **spa:** polish fixed-value form with \$name InputGroup + per-row identifier validation ([928e33c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/928e33cc5ff445ca11e219fee2fb1a9acadd9a64))
* **spa:** port community sub-screens + drop PrimeVue dependency (Wave 7+8) ([e4ab218](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e4ab2186e33f04908ff43b0699635bc3f3a982c8))
* **spa:** port prototype dashboard + utilities views (Wave 5) ([1d13048](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1d1304898267ca0f9521adf16b19712cdef8e1a9))
* **spa:** port prototype editors with EditorFrame scaffold (Wave 4) ([84fc961](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/84fc961fc2f4c31be250ef011e1c6f88430439f4))
* **spa:** port prototype ModuleListView + drop PrimeVue from list screens (Wave 3) ([df5d38d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df5d38d6250c7856e34b2ac2a7b80ff649da8e0d))
* **spa:** port prototype shell — topbar + sidebar 1:1, drop PrimeVue (Wave 2) ([30fed89](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/30fed89ce3aad313faad8b138eed808766763f56))
* **spa:** port prototype ui primitives + full styles.css (Wave 1) ([03d2b7b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/03d2b7bde50cd984bf312e73c6f0d200fc731a82))
* **spa:** primevue brand preset derived from --wp-* tokens ([76bcd36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/76bcd3629d609ce79652109e63025b7027104c6e))
* **spa:** real Combine editor with template + RichTextInput + detected inputs ([d50dd8d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d50dd8d6015fa01466c34470833ac7ed6b9bf2e8))
* **spa:** real Constraint editor with matrix grid + factor tune popovers ([02a526a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02a526a53c7972382ca06742f7d3f8ad73bdfdeb))
* **spa:** real Derivation editor with multi-rule IF/ELIF/ELSE ([4c603a2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4c603a20db432d9bb4a5c785411af2aa543817ba))
* **spa:** real Pipeline editor with steps stack + module picker modal ([054a777](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/054a777ec1522d07fe017f81bacbfdc302df6100))
* **spa:** redesign Dashboard with brand hero + 6 kind stats + recent/favorites ([0dcb562](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0dcb562801dd1d0fe395bbe1aedbda037ef87e85))
* **spa:** rich text input + preview with token chips and autocomplete ([6a5bbb4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5bbb40ef56f66fa1ea1063aa8ddc5f4cedc700))
* **spa:** rich-text input — restore $/@ autocomplete dropdown with keyboard nav ([60f911a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/60f911a0f8e2e32805e3153b698939e99e90123f))
* **spa:** sakai-style topbar + sidebar layout ([79163fc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/79163fce79e558c61f201cc44de6ab7405635a0b))
* **spa:** scaffold combine/derivation/constraint/pipeline list + edit views ([8c03e72](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c03e72d69785a7ae2b7101250cd35987e307231))
* **spa:** shared typepill + relativedate components ([f3a1066](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f3a10664ab64bc1da83914629dbfe07d1adcda71))
* **spa:** tags editor on forms + sort dropdown + category/tags filters ([0722da2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0722da2a98ae4f544a94df241faf17dad40ac0d2))
* **spa:** theme toggle (dark/light/auto) with flash-suppression ([1b7576a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b7576a55fb33af05b2f4ca6355354d1a032ab2e))
* **spa:** topbar logo + 4-section sakai sidebar with primeicons ([42f30ef](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/42f30efbe7c699d9f7ab2565cee37ce48ec123a2))
* **spa:** tweaks panel — accent palette + density + sidebar runtime switcher (Wave 6) ([975581e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/975581e111f1acfb13ec421a925af68a6fda8592))
* **spa:** typed api client wrapping /wp/api/* ([7a7b632](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7a7b632f0693976b20b16ed5ef4c2aacb8dd7890))
* **spa:** vite + tailwind + primevue scaffold ([7a47035](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7a4703546edc2467a75b4c132915bdf666e4dd70))
* **spa:** vue router + 7 stub views ([d80fd24](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d80fd242545ef794aa0b013f1ff1178f65242919))
* **spa:** wildcard + fixed-values editors ([8abab1e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8abab1ee3d0e83604030c2e97e777e23275dc86a))
* **spa:** wildcard form gains varBinding field + RichTextInput on option values ([d84bd2d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d84bd2d60b048fe6790d629d3b46067304050e8c))
* **spa:** wildcard sub-categories define + assign per option ([8a1b820](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a1b820ab3f3998d1abcb7160d19c549daa74c52))
* **spa:** wildcards list — syntax indicator column + nested-ref filters ([80cc323](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/80cc323f31b394c95a4de6f9e429bbf6fd6cd1db))
* **spa:** wildcards.vue with full datatable + filter + bulk select ([22c29aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/22c29aa090511b352c80035c60702435f0da431a))
* **syntax:** _resolve_inline_pick — uniform random branch + recurse ([2f48900](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2f489002e17de9ea092a05dde1b069f3edf0fbdc))
* **syntax:** _resolve_multi_pick — weighted without-replacement + sep join ([966ab4b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/966ab4b1aac965f7b8601517a8ddfeb534aeb7a5))
* **syntax:** _resolve_ref with surface/depth/cycle checks + weighted pick ([51a859b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51a859b0956091fee9e296097b9b6779dd99b52d))
* **syntax:** foundation types, error classes, ResolveContext Protocol ([45c2fa9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/45c2fa9bc48cecb25410aa04fd3ee993b442afb8))
* **syntax:** resolve_text dispatch loop with text/escape/var resolution ([9e05e1f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9e05e1fb668edc9bcabbcfeda154e90c1682e4ea))
* **syntax:** resolveTokens TS twin of Python resolve_text ([9d3c6a9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d3c6a9851be55d60ea24df8abc9953f662eda38))
* **syntax:** tokenize $var and @{uuid} kinds + corpus cases ([4fb2c99](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4fb2c997714d9bfc51f806b65a02b3bba8c0f4be))
* **syntax:** tokenize dp_brace + dp_multi with nesting support ([9364eb8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9364eb84f5899ab7d2e2dc2111f4c769d3c2c384))
* **syntax:** tokenize_text — text + escape kinds with lossless invariant ([11ac437](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11ac437fa391edcacdf210b8cb3e9d73e7c48cf7))
# [1.3.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.2.0...v1.3.0) (2026-04-26)
### Features
* i18n locales (en) + per-node help pages ([#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)) ([5216547](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/52165471c0da315db59d2b2fb72537d7b06d62ef))
# [1.2.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.1.0...v1.2.0) (2026-04-26)
### Features
* polish suite — MVP frontend + subgraph support + DX + a11y ([79bdb45](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/79bdb457cbc98e191766e3fa7c84df2e1106ecc2))
# [1.1.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v1.0.0...v1.1.0) (2026-04-24)
### Bug Fixes
* **engine:** annotate Module as TypeAlias so Pyright treats it as a type ([e1ab2f1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e1ab2f1f18f99c2e9971baeaeed9f769070b4099))
### Features
* **engine:** add Context type and strip_internals helper ([61a2e0e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/61a2e0e9acb44a04a0f0d07d7656811160d03ee6))
* **engine:** add handle_fixed_values handler ([95ef0f9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/95ef0f95d2f6aadee80343e3487a7abeeda71f12))
* **engine:** add module dataclasses and JSON round-trip helpers ([9f8fbf1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9f8fbf13e4db8c4b155e997420fdd58d4988cf97))
* **engine:** add PipelineEngine with dispatcher and per-module tracing ([3f7236f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3f7236f61b36519a2ab7f265ff5c66985b8225b5))
* **engine:** add template.resolve_variables with $var and $$ escape ([3399184](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/339918464e4a9500fc5bf574a0b499d5fc4e70b8))
* **engine:** add Context type and strip_internals helper ([61a2e0e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/61a2e0e9acb44a04a0f0d07d7656811160d03ee6))
* **engine:** add handle_fixed_values handler ([95ef0f9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/95ef0f95d2f6aadee80343e3487a7abeeda71f12))
* **engine:** add module dataclasses and JSON round-trip helpers ([9f8fbf1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9f8fbf13e4db8c4b155e997420fdd58d4988cf97))
* **engine:** add PipelineEngine with dispatcher and per-module tracing ([3f7236f](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3f7236f61b36519a2ab7f265ff5c66985b8225b5))
* **engine:** add template.resolve_variables with $var and $$ escape ([3399184](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/339918464e4a9500fc5bf574a0b499d5fc4e70b8))
# 1.0.0 (2026-04-24)
### Bug Fixes
* add __init__.py stub for ComfyUI loader; drop deprecated tsconfig baseUrl ([426f417](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/426f4175d69d773e4e72a1ec97da8239c5b6e560))
* **commitlint:** disable body/footer max-line-length for release commits ([20f0779](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/20f077981e2986799f61583bb9841f61258aba73))
* add __init__.py stub for ComfyUI loader; drop deprecated tsconfig baseUrl ([426f417](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/426f4175d69d773e4e72a1ec97da8239c5b6e560))
* **commitlint:** disable body/footer max-line-length for release commits ([20f0779](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/20f077981e2986799f61583bb9841f61258aba73))
+1 -89
View File
@@ -29,7 +29,6 @@ pnpm test:coverage # Vitest + v8 coverage (with thresholds)
pnpm typecheck # vue-tsc --noEmit
pnpm lint # ESLint flat config
pnpm size # bundle-size gate (--json <path> for manifest)
pnpm check:css-isolation # flag non-wp-prefixed top-level CSS selectors
pytest # Python unit tests
ruff check . # Python lint
```
@@ -44,17 +43,11 @@ 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 — 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).
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Three types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning).
- **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.
- **Conventional Commits** — enforced by commitlint. semantic-release read log to cut versions.
- **Extension isolation — four layers**:
1. **CSS class namespace**: every class selector starts with `.wp-*` (or `.var-1..8`). Never write a top-level rule with an unprefixed class — it could collide with ComfyUI core or another custom node. New top-level non-`wp-*` selectors are blocked by `pnpm check:css-isolation`.
2. **`@layer wp-extension` wrap**: standalone extension CSS (`a11y.css`, `display-prefs.css`, `ContextWidget.vue` unscoped block, etc.) sits in `@layer wp-extension { ... }` so host CSS (unlayered) beats us on any selector collision. Layered author rules are subservient to unlayered ones in the same origin. NOT applicable to CSS that gets `@imported` into Vue `<style scoped>` blocks — the scoper can't parse `@layer` atrules; rely on namespace prefix there.
3. **WeakMap stashes for litegraph object metadata**: never attach state to nodes/slots via string keys (`node._wpFoo = ...`). Use the WeakMaps in `src/extension/_stashes.ts` — module-private, no collision risk with other extensions, invisible to workflow serialization. Method overrides (`getInputPos`, `onConnectionsChange`, etc.) must keep their standard names because litegraph reads them; isolation there is via wrap-and-call-original chaining.
4. **Persistent state on `node.properties` only**: anything that must survive workflow save lives on `node.properties[key]` with a `wp_*` / `collapse_connections`-style string key (workflow JSON is the serialization root). Transient session state belongs in WeakMaps.
## Frontend overview
@@ -65,87 +58,6 @@ 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`.
View File
+2 -2
View File
@@ -631,7 +631,7 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
ComfyUI-Wildcard-Pipeline — modular procedural prompt generation for ComfyUI.
ComfyUI-WildcardPipeline — modular procedural prompt generation for ComfyUI.
Copyright (C) 2026 DumiFlex
This program is free software: you can redistribute it and/or modify
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
ComfyUI-Wildcard-Pipeline Copyright (C) 2026 DumiFlex
ComfyUI-WildcardPipeline Copyright (C) 2026 DumiFlex
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
+19 -139
View File
@@ -1,156 +1,36 @@
<h1 align="center">
<img src="public/images/favicon.svg" width="40" align="center" alt=""/>
&nbsp;Wildcard Pipeline
</h1>
<p align="center">
<em>Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, loops, and a persistent module library.</em>
<img src="public/images/favicon.svg" width="120" alt="wildcard-pipeline logo" />
</p>
<p align="center">
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/actions"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/DumiFlex/ComfyUI-Wildcard-Pipeline/ci.yml?branch=main&label=ci"></a>
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/releases"><img alt="Release" src="https://img.shields.io/github/v/release/DumiFlex/ComfyUI-Wildcard-Pipeline?include_prereleases"></a>
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/DumiFlex/ComfyUI-Wildcard-Pipeline"></a>
<a href="https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki"><img alt="Wiki" src="https://img.shields.io/badge/docs-wiki-blue"></a>
<a href="https://discord.gg/BFYR9WQdVR"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white"></a>
</p>
<h1 align="center">wildcard-pipeline</h1>
<p align="center">
<img src="public/images/docs/flow-intro.svg" alt="Modules resolve inside WP Context → named $variables → WP Prompt Assembler → CLIP" />
</p>
<p align="center"><em>Composable prompt context for ComfyUI.</em></p>
## What it does
Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines.
Stock ComfyUI prompt nodes either take a single literal string or pick from a flat wildcard file. **Wildcard Pipeline** lets each module read what the previous modules picked — so `$mood` reacts to `$weather` via a constraint, a `combine` interpolates `$style $subject` into `$scene`, a derivation flips `$accent` based on `$mood`, and your final prompt is a coherent multi-element composition instead of a Frankenstein concatenation.
**Status:** early development (walking skeleton).
**One Generate. One coherent prompt. Re-roll until you stop laughing.**
## Install
## Highlights
Clone into `ComfyUI/custom_nodes/`:
- **Module stack inside a Context node** — drop in `wildcard`, `fixed_values`, `combine`, `derivation`, `constraint`, `bundle` modules and they each publish a `$variable` to the chain.
- **Constraints with carriers** — re-weight downstream wildcards based on what upstream picked. Matrix + per-option exceptions. Carrier-via-nested-ref so a constraint can reach a wildcard nested inside another wildcard's option.
- **Loops + per-iteration seeds** — `WP Context Loop` runs your whole chain N times from a single Generate; `WP Seed List` pairs N unique sampler seeds with the N prompts so every output is distinct.
- **Persistent library** — a manager SPA backed by SQLite stores reusable modules, frozen bundles, prompt templates, and a category tree. Browse / edit / fork / push from the canvas.
- **Inspector** — `WP Debug` shows the post-run snapshot, per-module trace, per-wildcard picks, and the conflict-scanner warnings.
- **Type-coercion helpers** — `WP Var → Int / Float / Bool` parse typed values out of any `$variable` so wildcards can drive image width, step count, sampler cfg, conditional switches.
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/DumiFlex/ComfyUI-WildcardPipeline.git
```
## Installation
No Python dependencies required beyond ComfyUI itself.
### ComfyUI Manager (recommended)
## Nodes (MVP)
1. Open the **ComfyUI Manager** in your browser.
2. Search for **Wildcard Pipeline** by **dumiflex**.
3. Click **Install** and restart ComfyUI.
- `WP_Context` — runs an ordered list of modules, emits a typed context.
- `WP_PromptAssembler` — fills `$var` placeholders in a template.
- `WP_Debug` — inspects the context at any point in the chain.
### Manual installation
## Development
1. Navigate to your ComfyUI `custom_nodes` directory.
2. Clone the repository:
```bash
git clone https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline
```
3. Install the Python dependencies:
```bash
pip install -e .
```
4. Build the frontend assets (requires Node.js and pnpm):
```bash
pnpm install
pnpm run build
```
5. Restart ComfyUI.
## Quick start
1. Open the Wildcard Pipeline manager (sidebar → **Wildcards** → **+ New**). Name `subject`, variable binding `$subject`, three options: `a cat`, `a dog`, `a fox` at weight 1 each. Save.
2. On the canvas, drop `WP Context` → `WP Prompt Assembler` → `CLIP Text Encode` → `KSampler`. Add your wildcard to the context. Type `a $subject` into the assembler template. Queue.
Each Generate rolls a fresh subject. Full walkthrough: [Quick Start wiki page](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start).
## The nodes
| Node | What it does |
|---|---|
| `WP Context` | Holds a module stack, emits a resolved `$variable` context. |
| `WP Context Loop` | Runs the downstream chain N times in one click; emits per-iteration `$iteration` + `$iteration_total`. |
| `WP Context Injector` | Lifts any ComfyUI node output (multiline String, INT, etc.) into a named `$variable`. |
| `WP Seed List` | Emits a list of N derived seeds — one per loop iteration — so every (prompt, seed) pair is unique. |
| `WP Prompt Assembler` | Fills `$var` placeholders in a template string. Supports `{a\|b\|c}` inline picks + missing-var detection. |
| `WP Prompt Cleaner` | Rule-based prompt cleanup: whitespace, punctuation, exact + fuzzy dedupe, blocklist. |
| `WP Debug` | Read-only inspector — Snapshot / Trace / Picks / Warnings tabs. |
| `WP Var → Int / Float / Bool` | Parse typed values out of any `$variable` to drive ComfyUI INT / FLOAT / BOOLEAN inputs. |
Full node reference: [Nodes wiki page](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Nodes).
## The modules
| Module | Writes | Role |
|---|---|---|
| **Wildcard** | `$<name>` | Weighted random pick from a pool. The core building block. |
| **Fixed Values** | `$<name>` (× N) | Static `name → value` bindings — style, quality boosters, negative-prompt fragments. |
| **Combine** | `$<name>` | Templated string that interpolates earlier `$vars` into a single output `$var`. |
| **Derivation** | `$<name>` | IF / ELIF / ELSE rules that read picked `$vars` and rewrite others. |
| **Constraint** | *(none)* | One-shot re-weight of a downstream wildcard's pool based on an upstream pick. |
| **Bundle** | *(group)* | Frozen group of modules — drop into any Context as a reusable unit. |
| **Template** | *(asm side)* | Saved Prompt Assembler template string in the library. |
Full module reference: [Modules wiki page](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Modules).
## Looping a chain — one click, N coherent prompts
<p align="center">
<img src="public/images/docs/flow-loop.svg" alt="WP Context Loop + WP Seed List → KSampler — both lists fan out in lockstep" />
</p>
Drop `WP Context Loop` before your `WP Context` and pair it with `WP Seed List` for distinct sampler seeds. Both lists fan out in lockstep so iteration N's prompt always pairs with seed N. Each iteration carries its own `$iteration` / `$iteration_total` for in-template labelling (`frame 1 of 4 — a fox`). See [Concepts → Seeds and loops](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Concepts#seeds-and-loops).
## The manager (SPA)
A dedicated single-page app for browsing + editing the library. Open it from the ComfyUI sidebar.
<p align="center">
<img src="public/images/docs/spa_manager.png" alt="Wildcard Pipeline manager — Dashboard view with module counts, Quick Create row, and a list of recently opened library entries" />
</p>
- **Library** — wildcards / fixed values / combines / derivations / constraints / bundles / templates / categories. Filter, search, bulk-tag, favorite.
- **Test Runner** — resolve any module against the engine N times and inspect per-rule fire rates / per-option pick rates.
- **Import / Export** — versioned JSON exports with conflict resolution (rename / overwrite / skip per row).
- **Cascade impact dialog** — deleting a module shows you every bundle + constraint + combine that references it before you confirm.
- **Documentation tab** — full in-app docs covering every node + module + concept, with worked examples.
## Inspecting a run
Wire a `WP Debug` node off any Context output. After Generate it fills with a tabbed snapshot of what flowed through that point:
- **Snapshot** — final `$variable → value` map.
- **Trace** — per-module execution log: what each ran, what it wrote, where the seed came from.
- **Picks** — which wildcard option was picked, its weight, its sub-category.
- **Warnings** — runtime warnings (constraint never fired, missing variable, etc.).
See [Nodes → WP Debug](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Nodes#wp-debug).
## Documentation
- 📖 **[GitHub Wiki](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki)** — Home / Quick Start / Nodes / Modules / Concepts
- 🖥️ **In-app docs** — sidebar → Documentation tab inside the manager SPA (every concept, every module, every node, with worked examples)
- 💬 **[Discord](https://discord.gg/BFYR9WQdVR)** — chat, show-and-tell, fastest path to a response
- 💭 **[GitHub Discussions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/discussions)** — long-form questions, ideas, design threads
- 🐛 **[Issue tracker](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)** — bug reports + feature requests
## Status
Active development. Versioned releases with [semantic-release](https://github.com/semantic-release/semantic-release) — every merged commit on `main` either ships a release or stays as `next` until the next breaking / feature change rolls one. Bundle-size + test gates run on every PR.
## Contributing
PRs welcome. See [CONTRIBUTING.md](./.github/CONTRIBUTING.md) for the dev loop (`pnpm install` → `pnpm dev` for the extension watcher, `pnpm test` for vitest, `pytest` for the engine, `ruff check .` for lint).
See [CONTRIBUTING.md](./CONTRIBUTING.md).
## License
[GPL-3.0-or-later](./LICENSE). Free to use, modify, fork. If you ship modifications, ship the source too.
GPL-3.0-or-later. See [LICENSE](./LICENSE).
+2 -20
View File
@@ -1,4 +1,4 @@
"""ComfyUI-Wildcard-Pipeline — modular procedural prompt generation."""
"""ComfyUI-WildcardPipeline — modular procedural prompt generation."""
import os
import sys
@@ -23,33 +23,15 @@ if _PKG_DIR not in sys.path:
from comfy_api.latest import ComfyExtension
from wp_nodes.assembler_node import WPPromptAssembler
from wp_nodes.context_loop import WPContextLoop
from wp_nodes.context_node import WPContext
from wp_nodes.debug_node import WPDebug
from wp_nodes.injector_node import WPContextInjector
from wp_nodes.prompt_cleaner import WPPromptCleaner
from wp_nodes.seed_list import WPSeedList
from wp_nodes.var_to_bool import WPVarToBool
from wp_nodes.var_to_float import WPVarToFloat
from wp_nodes.var_to_int import WPVarToInt
class WildcardPipelineExtension(ComfyExtension):
"""Registers all WP nodes with ComfyUI."""
async def get_node_list(self):
return [
WPContext,
WPContextLoop,
WPPromptAssembler,
WPDebug,
WPContextInjector,
WPPromptCleaner,
WPSeedList,
WPVarToInt,
WPVarToFloat,
WPVarToBool,
]
return [WPContext, WPPromptAssembler, WPDebug]
async def comfy_entrypoint() -> WildcardPipelineExtension:
-10
View File
@@ -1,10 +0,0 @@
// Path is relative to the directory PostCSS runs from. Vite always
// invokes PostCSS with `cwd` = repo root (where `vite.config.mts`
// lives), so this string resolves against the root regardless of
// where this config file itself lives.
export default {
plugins: {
tailwindcss: { config: "config/tailwind.config.js" },
autoprefixer: {},
},
};
+34 -39
View File
@@ -1,46 +1,41 @@
# WP Context
The pipeline's module runner. Holds an ordered list of **modules** (wildcards, fixed values, combines, derivations, constraints) plus optional **bundles** that group related modules. Each queue, the engine executes every enabled module in order, building up a context of `$variable` bindings that downstream nodes consume.
Hold a list of named values you can use later in your prompt. Each value gets a name (like `style` or `subject`) and the value you want it to be (like `watercolor` or `a cat`). Then a **WP Prompt Assembler** can plug those values into your prompt wherever you write `$style` or `$subject`.
## Inputs
## Adding values
- **upstream** *(optional)* — chain another WP Context to inherit its bindings. Same-name bindings in this node override the upstream.
- **seed** — drives every random pick this run (wildcards, weighted lookups). `control_after_generate` rotates per queue. Per-row seed locks override per-instance.
- **modules** *(widget)* — the ordered module list. Picked from the library or created inline.
A **module** is one small group of values. Click the big purple **Add your first module** button, or **+ add module** if you already have some.
Each module appears as a small card. Right-click the card and pick **Edit** to:
- Give it a name (just a label so you remember what's inside, like "Character details")
- Add as many `name = value` lines as you need
- Paste a bunch of lines at once if you have them ready
Press **Enter** on the value field to add another row quickly. **Ctrl+Enter** saves and closes.
## What the icons on a card mean
- **☰** Drag handle — drag a card to reorder, or drag onto another Context node to move it there
- **▾ / ▸** Show or hide the values preview
- **Toggle (checkbox)** — turn a module off without deleting it. Useful for trying variations.
- **✕** Remove the module. Don't worry about misclicks — an **Undo** button appears for a few seconds.
## Stacking multiple Contexts
Connect another **WP Context** node into the **upstream** input. The upstream Context's values flow through. If both define the same name, this Context wins (it's the closer one to your prompt).
This lets you build a library: "character" Context → "scene" Context → "style" Context → Prompt Assembler.
## Conflict dots
A card may show a small colored dot:
- 🔵 **Blue (info)** — You're overriding something an upstream Context already provides. Usually that's exactly what you want.
- 🟡 **Amber (warning)** — Two of your modules in this Context use the same name. One quietly overwrites the other; check that's intentional.
Hover the dot to see exactly which variable.
## Output
- **context** — `PIPELINE_CONTEXT` payload carrying all bindings + run trace + warnings. Feed into WP Prompt Assembler, WP Prompt Cleaner, WP Debug, or chain another Context.
## Module kinds
| Kind | What it does |
|---|---|
| **wildcard** | Picks one option from a weighted list. Roll changes each queue. Supports sub-categories, nested `@{uuid}` refs, null slots. |
| **fixed_values** | Assigns explicit `name = value` bindings — no randomness. Quick way to define static vars. |
| **combine** | Template-fills `$vars` into a string and writes the result to a new binding. E.g. `$mood $subject` → `subject_phrase = "sleepy cat"`. |
| **derivation** | IF/ELIF/ELSE conditional rules over the runtime context. E.g. *if `$mood == sleepy` then `$lighting = soft`*. |
| **constraint** | Re-weights a target wildcard's options based on a source wildcard's pick. E.g. *if `$hair == bald` then exclude `$style == braided`*. First-instance: fires on the first matching target downstream, then consumed. |
Bundles group modules that belong together (e.g. "final framing": all the wildcards + the combine that builds the final prompt). They're reusable across workflows.
## How to use
1. Click **+ Add module** to insert a row. Pick from the library, or create a Fixed Values entry inline.
2. Order matters — modules run top-to-bottom. A combine that reads `$mood` must come after the wildcard that picks `$mood`.
3. Wire the **context** output into a WP Prompt Assembler / WP Prompt Cleaner / WP Debug.
Drag a row by its handle to reorder. Drag onto another Context node to move it. Toggle the checkbox to disable a row without deleting it.
## Per-row indicators
- **MODIFIED** badge — the row diverges from its library entry (instance overrides applied).
- **MISSING VAR** badge — a combine/derivation references a `$var` no upstream module provides.
- **Conflict dots** — blue = overriding an upstream binding (info), amber = sibling collision in this Context (warning).
- **Bundle frame** — colored border grouping related modules. Click the bundle header to collapse / toggle the whole group.
## Tips
- Variables prefixed `__` are engine-internal and hidden from the assembler.
- Right-click a row for Edit / Duplicate / Save-to-library / Open-in-SPA.
- Run once with WP Debug downstream to see exactly what each module produced.
Wire **context** out into a **WP Prompt Assembler** (or another **WP Context** to chain).
-26
View File
@@ -1,26 +0,0 @@
# WP Context Injector
Lifts arbitrary ComfyUI outputs into named `$variable` bindings on the PipelineContext. Use when you want a value from a non-WP node (LoRA loader, prompt picker, CLIP text) available as `$var` downstream.
## Inputs
- **upstream** *(optional)* — chain another WP Context / Injector.
- **slot_0 … slot_9** — wire any ComfyUI output here. Each connected slot becomes a row.
## Output
- **context** — `PIPELINE_CONTEXT` carrying the injected bindings on top of any upstream values.
## How to use
1. Drag wires from external nodes into the slot sockets.
2. Each connected slot adds a row. Type the variable name (e.g. `style`).
3. Optionally tick **internal** to hide it from the assembled prompt while keeping it readable by Combine / Derivation rules.
4. Optionally write a template (`"I love $input_0"`) — the slot's raw value substitutes `$<slot_name>` before being written to ctx.
## Tips
- Rows reorder freely — engine reads by `slot_name`, not position.
- Names must match `^[a-zA-Z][a-zA-Z0-9_]*$`.
- Capped at 10 sockets. Chain a second injector if you need more.
- Non-primitive values (LATENT/IMAGE/etc.) stringify via Python `str()` — typically used for STRING/INT/FLOAT only.
-33
View File
@@ -1,33 +0,0 @@
# WP Context Loop
Optional power-user node that loops a WP_Context chain N times in a single workflow run, producing N variations.
## Inputs
| Name | Type | Notes |
|---|---|---|
| `seed` | `INT` | Base seed widget. Has `control_after_generate` (randomize / fixed / increment buttons). Right-click → "Convert widget to input" to wire rgthree's Seed (or any INT source) instead. |
| `count` | `INT` | How many iterations. 1 = no loop. |
| `strategy` | chips | How to derive N seeds from base. `hash_index` (default) is independent per-iteration; `sequential` is `base, base+1, …`; `stride` is `base + i × 1,000,003`. |
| `override_seed` | switch | When OFF (default): `seed` is ignored. Downstream WP_Context widget seeds drive each node; loop iteration only adds XOR variation. When ON: `seed` becomes the base; derived N times via `strategy`; replaces downstream widget seeds. |
| `iteration var` | text | Stamps `$<name>` (0..N-1) and `$<name>_total` (N) into each iteration's context. Default `iteration`. |
| `bypass` | switch | Skip the loop. Behaves as `count=1`. |
## Outputs
| Name | Type | Notes |
|---|---|---|
| `context` | `PIPELINE_CONTEXT` (list) | One payload per iteration. ComfyUI auto-iterates downstream nodes per item. |
## How it works
Each iteration runs the downstream chain with a different seed. Locked modules (any wildcard with a locked seed) ignore the loop — they always roll their fixed seed. Everything else varies per iteration.
Concrete example: `count=3`, `override_seed=ON`, `seed=42`, `strategy=sequential` → downstream WP_Context chain runs three times with chain seeds 42, 43, 44. PromptAssembler emits three prompts; KSampler renders three batches; SaveImage writes three files.
## Tips
- The `seed` field is a stock ComfyUI INT widget with `control_after_generate` — same randomize / fixed / increment buttons KSampler exposes. Right-click → "Convert widget to input" to swap it for an rgthree Seed wire when you want its UI affordances.
- Set `override_seed=OFF` (default) to let each WP_Context's own widget seed drive that node's rolls; loop iteration still varies results via XOR with `loop_index`. Set `override_seed=ON` to take central control.
- Combine with `$iteration` in your prompt template for variation labels: `"variation $iteration of $iteration_total: ..."`.
- The `bypass` switch collapses the loop to a single run (`count=1`) while keeping the node wired — handy for A/B without rebuilding the graph. Native ComfyUI bypass (Ctrl-B) and mute (Ctrl-M) also work as usual.
+13 -15
View File
@@ -1,24 +1,22 @@
# WP Debug
Inspect every variable flowing through the WP chain at this point. Terminal node — no output.
## Input
- **context** — connect to any WP node's PIPELINE_CONTEXT output.
Drop this anywhere in your pipeline to see exactly what variables are flowing through that point. Like a window into the chain.
## How to use
1. Drop it anywhere in the chain.
2. Run the workflow.
3. The body shows tabs:
- **Snapshot** — every bound variable + value.
- **Trace** — per-module resolution history.
- **Picks** — what each wildcard rolled.
- **Warnings** — unknown vars, constraints that never fired, etc.
1. Connect the **context** input to any WP node (Context or Prompt Assembler).
2. Run the workflow once.
3. The viewer below shows every variable name and its value at that point in the chain.
Drag the bottom-right corner to resize.
That's it. There's nothing to wire on the output side — Debug is read-only.
## When it's useful
- **"Why is `$style` showing as empty?"** Drop a Debug after each Context to see where the value gets lost or overridden.
- **"Did my new module override the old one correctly?"** The trace shows which module set each variable.
- **"Are upstream variables coming through the chain?"** Drop a Debug right before your Prompt Assembler to confirm.
## Tips
- Always re-runs on queue (not cached) so seed-driven values refresh.
- Use the filter box at the top of each tab to narrow on a variable name.
- The viewer fills the node body. Drag the bottom-right corner of the node to make it bigger if you have lots of variables.
- Debug nodes always re-run, even when nothing visually changed. Helpful when you're cycling seeds and want to see the values shift.
+38 -35
View File
@@ -1,44 +1,47 @@
# WP Prompt Assembler
Fill-in-the-blank prompt template. Anywhere you write `$variable`, the assembler substitutes the matching value from the upstream Context.
Write your prompt as a fill-in-the-blank template. Anywhere you put `$something`, the Assembler swaps in the matching value from the upstream Context.
## Inputs
## Writing a template
- **context** — `PIPELINE_CONTEXT` from any WP node.
- **template** — your prompt text. Use `$varname` to reference bound values.
Type your prompt in the **template** box. Use `$name` to reference any variable from the connected Context.
**Example template:**
```
a portrait of $subject, $style, $mood
```
If the upstream Context provides:
- `subject = a cat`
- `style = watercolor`
- `mood = sleepy`
…the final prompt becomes:
```
a portrait of a cat, watercolor, sleepy
```
## The helper below the template
**Variables** lists every value available from the upstream Context. Click any chip to insert `$name` into your template at the cursor — saves typing and avoids typos.
**Preview** shows what the final prompt will look like, color-coded:
- **Filled in (purple highlight)** — the variable is resolved, you see its actual value.
- **Outlined chip** — the name will resolve, just briefly during loading.
- **Amber wavy underline** — missing! Nothing upstream provides this name, so it'll render empty.
If your template is empty, the preview shows a placeholder instead.
## Special characters
- Use `$$` if you want a literal `$` in your prompt (e.g. `$$10` to mean "ten dollars" rather than a variable named `10`).
- Variable names can contain letters, numbers, and underscores only. `$my_var` works; `$my-var` doesn't.
## Output
- **prompt** — the resolved STRING. Feed into CLIP encoders, WP Prompt Cleaner, etc.
**prompt** is the final assembled string. Connect it to a **CLIP Text Encode** node (or any text input).
## How to use
## Pre-flight warnings
1. Wire a WP Context (or chain) into the context input.
2. Write your template:
```
A $style portrait of $subject, $lighting
```
3. Run the workflow. Each `$var` gets replaced with its current value.
## Helper widget
Below the template, the **Variables** strip lists every value available from upstream — click any chip to insert `$name` at the cursor.
**Preview** color-codes the resolved prompt:
- purple highlight = resolved value
- amber wavy underline = missing variable (will render empty)
## Save / Load template
Two toolbar buttons next to **Clear template**:
- **Load** — opens a library picker with search plus category / tags / favorites filters. Click a template to load it; this replaces the current template (confirms first if you've already typed something). The assembler remembers which template you loaded.
- **Save** — opens a save dialog (name, category, tags, description, live preview). When the name matches an existing template — or you loaded one — you get two actions: **Update existing** (overwrite that library entry) and **Save as new entry** (create a fresh copy, auto-suffixed). Otherwise it's a plain **Save**. After saving, the assembler tracks that entry so the next Save defaults to updating it.
Any `$vars` the loaded template needs that aren't in the upstream chain show as the usual amber "missing" markers. Manage saved templates in the SPA **Templates** tab.
## Tips
- Use `$$` for a literal `$` in your prompt.
- Variable names: letters, numbers, underscores only.
- Pre-flight warning toasts surface missing `$vars` at queue time.
When you press the **Run** button, the extension scans your template for `$variables` that aren't provided anywhere upstream. If it finds any, you'll see one toast at the top of the screen listing them. The run still happens — missing variables just become empty strings — but it's a heads-up in case you have a typo.
-53
View File
@@ -1,53 +0,0 @@
# WP Prompt Cleaner
Drops duplicate tags, strips orphan punctuation, filters blocklisted words. Operates on the prompt string alone — no context input needed.
## Inputs
- **prompt** — STRING from any source (typically a WP Prompt Assembler).
## Output
- **prompt** — the cleaned STRING.
## How to use
1. Wire any STRING output into the prompt input.
2. Pick a mode + intensity in the widget.
3. Optionally toggle individual rules. Click **Blocklist…** to filter specific words.
4. Wire the output to your CLIP encoder / preview.
## Mode
- **tags** — split input on commas. Each segment is a tag.
- **text** — treat input as prose. Tag-only rules (dedupe, fuzzy) are disabled.
## Intensity presets
| Preset | Rules |
|---|---|
| gentle | whitespace |
| balanced | whitespace, punctuation, tag dedupe |
| aggressive | whitespace, punctuation, tag dedupe, fuzzy dedupe |
Blocklist auto-enables when its entries are non-empty.
## Blocklist
Click **Blocklist…** to open the editor. Two modes:
- **list** — comma- or newline-separated plain words. Each entry matches as a case-insensitive word-boundary substring. `cat` drops `black cat` but not `catcher`.
- **regex** — one regex per line, compiled with `IGNORECASE`. Bad patterns are skipped (reported in run stats), so one typo won't kill the rule.
What gets dropped:
- **tags mode** — the whole comma-separated tag containing the match. Blocklist `steps` on `cfg, steps . avoid:` drops the entire `steps . avoid:` tag → `cfg`.
- **text mode** — only the matched word is removed; adjacent orphan punctuation/space is scrubbed. Blocklist `steps` on `cfg, steps. avoid` → `cfg, avoid`.
Toggling the rule off via its row in RULES overrides the auto-enable, even with entries present. Toggling it on with no entries does nothing (no patterns to match).
## Tips
- Hover any control for an inline tooltip.
- The **CUSTOM** badge lights up when your toggles diverge from the intensity preset. Click the intensity again to reset.
- Stats next to each active rule show what it dropped on the last run.
-25
View File
@@ -1,25 +0,0 @@
# WP Var → Bool
Reads a `$var` from the upstream **PipelineContext** and emits a `BOOLEAN`.
## Inputs
| Name | Type | Notes |
|---|---|---|
| `context` | `PIPELINE_CONTEXT` | Required. |
| `var_name` | dropdown | Pick from upstream `$vars`. |
| `index` | `INT` | Which bool token in the var's value. |
| `default` | `BOOLEAN` | Returned when no Nth match. |
## Parsing rule
Splits the var's value on `[\s,;|/]+` and checks each token against the truthy/falsy sets:
- **Truthy:** `true`, `yes`, `on`, `1` (case-insensitive)
- **Falsy:** `false`, `no`, `off`, `0` (case-insensitive)
Tokens that match neither (e.g. `"1.5"`, `"enabled"`) are skipped — they do NOT consume an index slot.
- `"true false true"` index `2` → `True`
- `"yes,no,yes"` index `1` → `False`
- `"1.5"` index `0` → `default` (1.5 is not a bool token)
-19
View File
@@ -1,19 +0,0 @@
# WP Var → Float
Reads a `$var` from the upstream **PipelineContext** and emits a `FLOAT`.
## Inputs
| Name | Type | Notes |
|---|---|---|
| `context` | `PIPELINE_CONTEXT` | Required. |
| `var_name` | dropdown | Pick from upstream `$vars`. |
| `index` | `INT` | Which float match in the var's value. |
| `default` | `FLOAT` | Returned when no Nth match. |
## Parsing rule
Scans for substrings matching `-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?`. Integers count — `"1920"` index `0` → `1920.0`. Scientific notation supported.
- `"strength: 0.85, scale: 1.5"` index `1` → `1.5`
- `"3.2e-4"` index `0` → `0.00032`
-25
View File
@@ -1,25 +0,0 @@
# WP Var → Int
Reads a `$var` from the upstream **PipelineContext** and emits an `INT`.
## Inputs
| Name | Type | Notes |
|---|---|---|
| `context` | `PIPELINE_CONTEXT` | Required. Comes from a `WP Context` chain. |
| `var_name` | dropdown | Pick from upstream `$vars`. Stored as `"$seed"`. |
| `index` | `INT` | Which integer match in the var's value (0-based). |
| `default` | `INT` | Returned when the var is missing or no Nth match. |
## Parsing rule
Scans the var's value for substrings matching `-?\d+`, returns the Nth match.
- `"1920x1080"` index `0` → `1920`
- `"1920x1080"` index `1` → `1080`
- `"hello"` any index → `default`
- `""` / missing var → `default`
## Tip
For width + height in one shot, place two `WP Var → Int` nodes reading the same var with `index=0` and `index=1`.
-49
View File
@@ -1,49 +0,0 @@
"""Module content fingerprint. Mirror of TypeScript
`src/manager/import-export/fingerprint.ts:moduleFingerprint`.
Single unified helper for all 5 module types. Hashes
[type, name, description, sorted_tags_csv, payload_hash] joined by '\\n'.
Pure function — no DB access, no ComfyUI imports.
Cross-language contract: identical djb2 output for identical input
strings. djb2 iterates UTF-16 code units (matching JS `charCodeAt`)
so any string — including non-BMP characters like emoji — hashes
identically on both sides.
"""
from __future__ import annotations
from typing import Any
def _djb2(s: str) -> str:
"""djb2 hash over UTF-16 code units, returning 8 lowercase hex chars.
Iterates UTF-16-LE bytes in pairs to match JavaScript's
String.prototype.charCodeAt() semantics, which iterates code units
(not codepoints). This makes Python's hash byte-identical to the
TypeScript implementation for any Unicode string.
"""
h = 5381
utf16 = s.encode("utf-16-le")
for i in range(0, len(utf16), 2):
code_unit = utf16[i] | (utf16[i + 1] << 8)
h = ((h * 33) ^ code_unit) & 0xFFFFFFFF
return f"{h:08x}"
def module_fingerprint(m: dict[str, Any]) -> str:
"""Content fingerprint of a module row.
Reads keys: type, name, description, tags (list[str]), payload_hash.
Other keys (id, category_id, is_favorite, version, etc.) are ignored
by design — they are not content.
"""
parts = [
m.get("type", ""),
m.get("name", ""),
m.get("description", ""),
",".join(sorted(m.get("tags") or [])),
m.get("payload_hash", ""),
]
return _djb2("\n".join(parts))
View File
-801
View File
@@ -1,801 +0,0 @@
"""Per-mutation cleanup fixers for cascade-edit operations.
Each fixer handles one (kind, action) combination. All take a live
``sqlite3.Connection`` plus target identifiers, mutate affected entities
via repositories, and return:
(touched, diff)
* ``touched`` — list of entity row dicts captured *before* mutation
(BEFORE-state snapshots). Passed back to the orchestrator so it can
persist them in the undo log.
* ``diff`` — list of lightweight index-patch dicts that describe what
changed, keyed by ``entity_id``.
Contracts:
- Fixers do NOT delete the target entity. The orchestrator handles the
primary mutation.
- Fixers do NOT write to ``cascade_undo``. The orchestrator persists the
undo entry.
- All mutations go through repository methods — never raw SQL.
- Zero ComfyUI imports. Stdlib ``re`` + repositories only.
Caller contract (atomicity)
---------------------------
The caller (orchestrator at engine/cascade/orchestrator.py, Task 5)
MUST wrap each fixer call in an explicit BEGIN…COMMIT/ROLLBACK
transaction. Individual repository methods auto-commit via their
own `with conn:` blocks; the outer transaction the orchestrator
opens is the only rollback boundary if a fixer raises mid-loop.
Without an outer transaction, partial mutations are persisted.
"""
from __future__ import annotations
import copy
import re
import sqlite3
from typing import Any
from engine.db.repositories import BundleRepository, ModuleRepository
from engine.syntax.subcat_filter import ParseError as _ParseError
from engine.syntax.subcat_filter import parse as _parse_subcat
from engine.syntax.subcat_filter import reads_as as _reads_as
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _deepcopy_row(row: dict[str, Any]) -> dict[str, Any]:
"""Return a deep-copied snapshot of a row dict."""
return copy.deepcopy(row)
def _rewrite_var_in_string(s: str, old_name: str, new_name: str) -> str:
"""Replace ``$old_name`` → ``$new_name`` word-boundary-safe.
Uses a negative-lookbehind/lookahead so ``$mood`` matches but
``$moodier`` does not.
"""
pattern = re.compile(
r"\$" + re.escape(old_name) + r"(?![A-Za-z0-9_])"
)
return pattern.sub(f"${new_name}", s)
def _map_tags(ast: dict[str, Any] | None, fn) -> dict[str, Any] | None:
"""Apply *fn* to each tag leaf of a parsed sub-category expression;
drop tags *fn* maps to ``None`` and collapse the now-childless
operator (a one-child and/or becomes that child). Returns ``None``
when the whole expression empties. Mirror of TS `mapTags`
(src/manager/cascade/subcatExprCascade.ts)."""
if ast is None:
return None
if "tag" in ast:
v = fn(ast["tag"])
return None if v is None else {"tag": v}
if ast["op"] == "not":
x = _map_tags(ast["x"], fn)
return {"op": "not", "x": x} if x is not None else None
kids = [k for k in (_map_tags(c, fn) for c in ast["kids"]) if k is not None]
if not kids:
return None
return kids[0] if len(kids) == 1 else {"op": ast["op"], "kids": kids}
def _expr_tags(ast: dict[str, Any] | None) -> set[str]:
out: set[str] = set()
def walk(n: dict[str, Any] | None) -> None:
if n is None:
return
if "tag" in n:
out.add(n["tag"])
return
if n["op"] == "not":
walk(n["x"])
return
for k in n["kids"]:
walk(k)
walk(ast)
return out
def collect_tags(expr: str | None) -> set[str]:
"""Distinct sub-category tags referenced by a filter *expr* string.
Mirror of TS ``collectTags`` (src/manager/cascade/subcatExprCascade.ts).
Empty or malformed expressions yield the empty set — the caller simply
won't link the ref; ``validate_expression`` surfaces real syntax errors
elsewhere. Used by the discovery scan so a rename/delete of any one tag
finds every ref whose boolean filter mentions it (negated tags count).
"""
if not expr or not expr.strip():
return set()
try:
ast = _parse_subcat(expr)
except _ParseError:
return set()
return _expr_tags(ast)
def _ref_expr_pattern(wildcard_id: str) -> re.Pattern[str]:
# 4-segment ref to a specific wildcard: groups = (#name, :expr, !null).
return re.compile(
r"@\{" + re.escape(wildcard_id)
+ r"(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
)
def _rewrite_ref_expr(s: str, wildcard_id: str, target: str, fn) -> str:
"""Rewrite the boolean ``:expr`` of every ``@{wildcard_id...}`` ref
whose expression contains *target*, mapping its tags via *fn*
(rename, or remove + collapse). Refs that don't mention *target* are
left verbatim — no cosmetic re-serialization. ``#name`` + ``!null``
are preserved; an expression that empties drops the ``:expr``
segment, collapsing ``@{uuid:cold}`` to a bare ``@{uuid}``."""
pattern = _ref_expr_pattern(wildcard_id)
def repl(m: re.Match[str]) -> str:
name, expr, null = m.group(1), m.group(2), m.group(3)
if expr is None or not expr.strip():
return m.group(0)
ast = _parse_subcat(expr)
if target not in _expr_tags(ast):
return m.group(0)
new_expr = _reads_as(_map_tags(ast, fn))
out = "@{" + wildcard_id + (f"#{name}" if name else "")
if new_expr:
out += ":" + new_expr
if null == "null":
out += "!null"
return out + "}"
return pattern.sub(repl, s)
def _rewrite_subcat_ref_in_string(
s: str,
wildcard_id: str,
old_subcat: str,
new_subcat: str,
) -> str:
"""Rename ``old_subcat`` -> ``new_subcat`` inside every
``@{wildcard_id...}`` ref's boolean expression (e.g.
``@{u:warm or cold}`` -> ``@{u:warm or chilly}``). Structure,
``#name``, and ``!null`` are preserved."""
return _rewrite_ref_expr(
s, wildcard_id, old_subcat,
lambda t: new_subcat if t == old_subcat else t,
)
def _strip_subcat_ref_in_string(
s: str,
wildcard_id: str,
subcat_name: str,
) -> str:
"""Remove ``subcat_name`` from every ``@{wildcard_id...}`` ref's
expression, collapsing operators (``@{u:warm or cold}`` ->
``@{u:warm}``); a ref whose expression empties collapses to the bare
``@{wildcard_id}`` (the nested insertion survives, only its filter
is dropped)."""
return _rewrite_ref_expr(
s, wildcard_id, subcat_name,
lambda t: None if t == subcat_name else t,
)
def _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(
s: str,
wildcard_id: str,
new_name: str,
) -> str:
"""Rewrite every ``@{wildcard_id...}`` ref in *s* so its ``#name``
segment matches *new_name*. Bare-uuid refs gain the segment;
refs that already carry an old name get it replaced. Subcat
suffix is preserved.
Empty *new_name* drops the segment entirely (canonical bare-uuid
form), which lets the same fixer handle "name was cleared" later
without a separate code path."""
pattern = re.compile(
r"@\{" + re.escape(wildcard_id)
+ r"(?:#[^#:}@{]*)?(:[^}]*)?\}"
)
name_seg = f"#{new_name}" if new_name else ""
def repl(m: re.Match[str]) -> str:
sub_seg = m.group(1) or ""
return f"@{{{wildcard_id}{name_seg}{sub_seg}}}"
return pattern.sub(repl, s)
# ---------------------------------------------------------------------------
# Public fixers
# ---------------------------------------------------------------------------
def fix_wildcard_delete(
conn: sqlite3.Connection,
wildcard_id: str,
cleanup_ids: list[str],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Opt-in strip of nested ``@{wildcard_id...}`` refs to a deleted wildcard.
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.
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.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
mod_repo = ModuleRepository(conn)
# 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["id"] not in cleanup:
continue
t = m["type"]
p = m.get("payload") or {}
new_payload = copy.deepcopy(p)
changed = False
if t == "wildcard":
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _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.update(m["id"], payload=new_payload)
diff.append({
"entity_id": m["id"],
"remove_ref": {"kind": "wildcard", "wildcard_id": wildcard_id},
})
return touched, diff
def fix_subcat_delete(
conn: sqlite3.Connection,
wildcard_id: str,
subcat_name: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Strip all references to ``wildcard_id:subcat_name``.
* Source wildcard's ``options[].sub_categories`` — removes the name.
* Constraint matrix keys (source-side) — removes the top-level key.
* Constraint matrix values (target-side) — removes nested keys.
* Other wildcards' option values — removes ``@{wildcard_id:subcat_name}``.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
repo = ModuleRepository(conn)
for m in repo.list():
t = m["type"]
p = m.get("payload") or {}
changed = False
new_payload = copy.deepcopy(p)
if t == "wildcard" and m["id"] == wildcard_id:
# Top-level declared list
declared = new_payload.get("sub_categories") or []
if subcat_name in declared:
new_payload["sub_categories"] = [
s for s in declared if s != subcat_name
]
changed = True
# Per-option membership — drop the deleted subcat from each
# option's sub_categories list (preserves the option).
for opt in new_payload.get("options") or []:
subs = opt.get("sub_categories")
if isinstance(subs, list) and subcat_name in subs:
opt["sub_categories"] = [s for s in subs if s != subcat_name]
changed = True
elif t == "constraint":
matrix = new_payload.get("matrix") or {}
# Source-side: top-level matrix keys are source subcat names
if (new_payload.get("source_wildcard_id") == wildcard_id
and subcat_name in matrix):
del matrix[subcat_name]
new_payload["matrix"] = matrix
changed = True
# Target-side: nested keys within each row value
if new_payload.get("target_wildcard_id") == wildcard_id:
for _row_key, row_val in list(matrix.items()):
if isinstance(row_val, dict) and subcat_name in row_val:
del row_val[subcat_name]
changed = True
elif t == "wildcard" and m["id"] != wildcard_id:
# Strip @{wildcard_id:subcat_name} text refs from option values
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _strip_subcat_ref_in_string(v, wildcard_id, subcat_name)
if new_v != v:
opt["value"] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
repo.update(m["id"], payload=new_payload)
diff.append({
"entity_id": m["id"],
"remove_ref": {"kind": "subcat", "wildcard_id": wildcard_id, "subcat": subcat_name},
})
return touched, diff
def fix_subcat_rename(
conn: sqlite3.Connection,
wildcard_id: str,
old_name: str,
new_name: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Rewrite all references to ``wildcard_id:old_name`` → ``wildcard_id:new_name``.
* Source wildcard's ``options[].sub_categories``.
* Constraint matrix keys (source-side) — renamed top-level key.
* Constraint matrix values (target-side) — renamed nested keys.
* Other wildcards' option values — ``@{uuid:old}`` → ``@{uuid:new}``.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
repo = ModuleRepository(conn)
for m in repo.list():
t = m["type"]
p = m.get("payload") or {}
changed = False
new_payload = copy.deepcopy(p)
if t == "wildcard" and m["id"] == wildcard_id:
# Top-level declared sub_categories list
declared = new_payload.get("sub_categories") or []
if old_name in declared:
new_payload["sub_categories"] = [
new_name if s == old_name else s for s in declared
]
changed = True
# Per-option membership list (`opt.sub_categories`)
for opt in new_payload.get("options") or []:
subs = opt.get("sub_categories")
if isinstance(subs, list) and old_name in subs:
opt["sub_categories"] = [
new_name if s == old_name else s for s in subs
]
changed = True
elif t == "constraint":
matrix = new_payload.get("matrix") or {}
# Source-side: rename top-level matrix key
if (new_payload.get("source_wildcard_id") == wildcard_id
and old_name in matrix):
matrix[new_name] = matrix.pop(old_name)
new_payload["matrix"] = matrix
changed = True
# Target-side: rename nested keys in each row
if new_payload.get("target_wildcard_id") == wildcard_id:
for row_val in matrix.values():
if isinstance(row_val, dict) and old_name in row_val:
row_val[new_name] = row_val.pop(old_name)
changed = True
elif t == "wildcard" and m["id"] != wildcard_id:
# Rewrite @{wildcard_id:old_name} → @{wildcard_id:new_name}
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_subcat_ref_in_string(v, wildcard_id, old_name, new_name)
if new_v != v:
opt["value"] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
repo.update(m["id"], payload=new_payload)
diff.append({
"entity_id": m["id"],
"rename_ref": {
"kind": "subcat",
"wildcard_id": wildcard_id,
"old": old_name,
"new": new_name,
},
})
return touched, diff
def fix_wildcard_rename_name(
conn: sqlite3.Connection,
wildcard_id: str,
new_name: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Rewrite every ``@{wildcard_id...}`` ref so its ``#name`` segment
matches *new_name*. Walks the same surfaces ``fix_wildcard_delete``
+ ``fix_subcat_rename`` cover for ref strings:
* Other wildcards' option values.
* Derivation rules' action string values (the only surface besides
wildcards where ``@{}`` text refs are resolved).
Combine templates, fixed-values, and constraint payloads don't hold
``@{}`` text refs (constraints reference by id only via
``source_wildcard_id`` / ``target_wildcard_id``), so they're
skipped. Bundle children 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.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
mod_repo = ModuleRepository(conn)
for m in mod_repo.list():
if m["id"] == wildcard_id:
continue
t = m["type"]
p = m.get("payload") or {}
new_payload = copy.deepcopy(p)
changed = False
if t == "wildcard":
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
if new_v != v:
opt["value"] = new_v
changed = True
elif t == "derivation":
for rule in new_payload.get("rules") or []:
for branch in rule.get("branches") or []:
for action in branch.get("actions") or []:
if not isinstance(action, dict):
continue
for k, v in list(action.items()):
if isinstance(v, str):
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
if new_v != v:
action[k] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
mod_repo.update(m["id"], payload=new_payload)
diff.append({
"entity_id": m["id"],
"rename_ref": {
"kind": "wildcard_name",
"wildcard_id": wildcard_id,
"new": new_name,
},
})
# Bundle 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
def fix_bundle_delete(
conn: sqlite3.Connection,
bundle_id: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Strip every ``{type: "bundle", id: bundle_id}`` ref from other
bundles' ``children[]`` lists. Mirrors `fix_wildcard_delete` for
bundles — when a bundle is removed, parent bundles holding it as
a tier-2 ref are cleaned so they don't ship `_missing_ref` on
their next GET expansion.
The fixer does NOT delete the bundle row itself; the orchestrator
owns that mutation. Skips self-reference (tier-2 cap forbids it,
defensive).
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
bundle_repo = BundleRepository(conn)
for b in bundle_repo.list():
if b["id"] == bundle_id:
continue
children = b.get("children") or []
new_children = [
ch for ch in children
if not (ch.get("type") == "bundle" and ch.get("id") == bundle_id)
]
if len(new_children) != len(children):
touched.append(_deepcopy_row(b))
bundle_repo.update(b["id"], children=new_children)
diff.append({
"entity_id": b["id"],
"remove_ref": {"kind": "bundle", "id": bundle_id},
})
return touched, diff
def fix_combine_output_var_rename(
conn: sqlite3.Connection,
combine_id: str,
old_name: str,
new_name: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Rename a combine's output variable and all ``$old_name`` references.
* Combine's ``output_var`` field updated to *new_name*.
* Other wildcards: ``$old_name`` → ``$new_name`` in option values.
* Derivations: ``$old_name`` → ``$new_name`` in action string values;
``set_var: "old_name"`` → ``set_var: "new_name"``.
* Other combines: ``$old_name`` → ``$new_name`` in template.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
repo = ModuleRepository(conn)
for m in repo.list():
t = m["type"]
p = m.get("payload") or {}
changed = False
new_payload = copy.deepcopy(p)
if t == "combine" and m["id"] == combine_id:
# Update the combine's own output_var
if new_payload.get("output_var") == old_name:
new_payload["output_var"] = new_name
changed = True
# Also rewrite $old in its own template in case it self-references
tpl = new_payload.get("template", "")
if isinstance(tpl, str):
new_tpl = _rewrite_var_in_string(tpl, old_name, new_name)
if new_tpl != tpl:
new_payload["template"] = new_tpl
changed = True
elif t == "wildcard":
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_var_in_string(v, old_name, new_name)
if new_v != v:
opt["value"] = new_v
changed = True
elif t == "derivation":
for rule in new_payload.get("rules") or []:
for branch in rule.get("branches") or []:
for action in branch.get("actions") or []:
if not isinstance(action, dict):
continue
for k in list(action.keys()):
v = action[k]
if k == "set_var" and v == old_name:
action[k] = new_name
changed = True
elif isinstance(v, str):
new_v = _rewrite_var_in_string(v, old_name, new_name)
if new_v != v:
action[k] = new_v
changed = True
elif t == "combine" and m["id"] != combine_id:
tpl = new_payload.get("template", "")
if isinstance(tpl, str):
new_tpl = _rewrite_var_in_string(tpl, old_name, new_name)
if new_tpl != tpl:
new_payload["template"] = new_tpl
changed = True
if changed:
touched.append(_deepcopy_row(m))
repo.update(m["id"], payload=new_payload)
diff.append({
"entity_id": m["id"],
"rename_ref": {"kind": "var", "old": old_name, "new": new_name},
})
return touched, diff
def fix_option_delete(
conn: sqlite3.Connection,
wildcard_id: str,
option_id: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Remove option from source wildcard + scrub it from constraint exceptions.
Standard fixer contract: returns (touched_snapshots, diff). The
orchestrator's outer ``with conn:`` is the rollback boundary; this
function neither opens nor commits a transaction.
Matching is by stable ``option_id`` so a value-rename (which keeps
the id) does not silently delete the wrong option.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
repo = ModuleRepository(conn)
# --- Source wildcard: remove option entry --------------------------
src = repo.get(wildcard_id)
new_payload = copy.deepcopy(src.get("payload") or {})
original_opts = list(new_payload.get("options") or [])
new_options = [o for o in original_opts if o.get("id") != option_id]
if len(new_options) == len(original_opts):
# Option already missing; nothing to do.
return touched, diff
touched.append(_deepcopy_row(src))
new_payload["options"] = new_options
repo.update(wildcard_id, payload=new_payload)
diff.append({"entity_id": wildcard_id, "remove_option": option_id})
# --- Constraints: drop exceptions referencing option_id ------------
for m in repo.list():
if m["type"] != "constraint":
continue
p = m.get("payload") or {}
exceptions = p.get("exceptions") or []
kept = [
e for e in exceptions
if e.get("source_id") != option_id and e.get("target_id") != option_id
]
if len(kept) == len(exceptions):
continue
touched.append(_deepcopy_row(m))
new_p = copy.deepcopy(p)
new_p["exceptions"] = kept
repo.update(m["id"], payload=new_p)
diff.append({
"entity_id": m["id"],
"remove_ref": {"kind": "option", "option_id": option_id},
})
return touched, diff
def fix_category_delete(
conn: sqlite3.Connection,
category_id: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Null out ``category_id`` on every module referencing *category_id*.
Does NOT delete the category row itself; the orchestrator handles that.
"""
touched: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
repo = ModuleRepository(conn)
for m in repo.list():
if m.get("category_id") == category_id:
touched.append(_deepcopy_row(m))
repo.update(m["id"], category_id=None)
diff.append({
"entity_id": m["id"],
"remove_ref": {"kind": "category", "id": category_id},
})
return touched, diff
-272
View File
@@ -1,272 +0,0 @@
"""End-to-end cascade-edit orchestrator.
apply_cascade dispatches scan + fixer + target-delete + undo-write in a
single SQLite transaction. The `with conn:` block is the rollback boundary
described in the fixers.py atomicity contract.
"""
from __future__ import annotations
import sqlite3
from typing import Any
from engine.cascade.fixers import (
fix_bundle_delete,
fix_category_delete,
fix_combine_output_var_rename,
fix_option_delete,
fix_subcat_delete,
fix_subcat_rename,
fix_wildcard_delete,
)
from engine.cascade.scan import scan_affected
from engine.cascade.undo import write_undo_entry
from engine.db.repositories import (
BundleRepository,
CategoryRepository,
ModuleRepository,
)
def _delete_target(conn: sqlite3.Connection, kind: str, target_id: str) -> dict[str, Any]:
"""Delete the primary target entity and return its BEFORE-snapshot.
Raises XNotFound (e.g. ModuleNotFound, CategoryNotFound) if the target
does not exist — propagates to the caller's try/except error envelope.
"""
if kind == "bundle":
repo: Any = BundleRepository(conn)
elif kind == "category":
repo = CategoryRepository(conn)
else:
repo = ModuleRepository(conn)
snapshot = repo.get(target_id) # raises XNotFound if missing → caller catches
repo.delete(target_id)
return snapshot
def _rename_subcat_target_only(
conn: sqlite3.Connection,
wildcard_id: str,
old_name: str,
new_name: str,
) -> dict[str, Any] | None:
"""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 `sub_categories` membership list (SP1 multi-tag shape).
"""
mod = ModuleRepository(conn)
wc = mod.get(wildcard_id)
snapshot = dict(wc) # capture BEFORE
payload = dict(wc.get("payload") or {})
declared = payload.get("sub_categories") or []
if old_name in declared:
payload["sub_categories"] = [
new_name if s == old_name else s for s in declared
]
new_options = []
for opt in payload.get("options") or []:
new_opt = dict(opt)
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
mod.update(wildcard_id, payload=payload)
return snapshot
def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, Any]:
"""Dispatch scan + fixer + target-delete + undo-write atomically.
Parameters
----------
conn:
Open, migrated sqlite3.Connection.
req:
Operation descriptor dict — see module docstring for full shape.
Returns
-------
dict
Response envelope. Always has ``ok: bool``.
On error: ``{ok: False, error: str}``.
On dry_run: ``{ok: True, affected_count: int, affected_entities: list}``.
On commit: ``{ok: True, undo_entry_id: str, affected_count: int,
affected_entities: list, diff: list}``.
On opt-out rename: ``{ok: True, undo_entry_id: str, affected_count: 0,
broken_refs: list}``.
"""
kind = req.get("kind")
target_id = req.get("id")
action = req.get("action")
cascade_refs = req.get("cascade_refs", True)
dry_run = req.get("dry_run", False)
new_name = req.get("new_name")
extra = req.get("extra") or {}
# --- Validate required fields -------------------------------------------
if not kind:
return {"ok": False, "error": "kind required"}
if not target_id:
return {"ok": False, "error": "id required"}
if not action:
return {"ok": False, "error": "action required"}
# --- Scan (read-only, safe outside transaction) --------------------------
affected = scan_affected(conn, kind=kind, id=target_id, action=action, extra=extra)
if dry_run:
return {
"ok": True,
"affected_count": len(affected),
"affected_entities": affected,
}
# --- Opt-out rename: snapshot + rename target only, write undo ----------
if action == "rename" and cascade_refs is False:
if not new_name:
return {"ok": False, "error": "new_name required for rename"}
try:
with conn:
snapshot_before: list[dict[str, Any]] = []
if kind == "subcategory":
old_name = extra.get("subcat_name", "")
snap = _rename_subcat_target_only(conn, target_id, old_name, new_name)
if snap is not None:
snapshot_before.append(snap)
elif kind == "combine_output_var":
# Update the combine's output_var field only (not its name).
mod = ModuleRepository(conn)
cb = mod.get(target_id)
snapshot_before.append(cb)
new_payload = {**(cb.get("payload") or {}), "output_var": new_name}
mod.update(target_id, payload=new_payload)
else:
# Generic module rename: snapshot then update name.
repo = ModuleRepository(conn)
before = repo.get(target_id)
snapshot_before.append(before)
repo.update(target_id, name=new_name)
undo_id = write_undo_entry(
conn,
target_kind=kind,
target_id=target_id,
action=action,
snapshot_before=snapshot_before,
snapshot_after=[],
)
return {
"ok": True,
"undo_entry_id": undo_id,
"affected_count": 0,
"broken_refs": affected,
}
except Exception as exc:
return {"ok": False, "error": str(exc)}
# --- Cascade-on path: dispatch fixer + target delete + undo -------------
# Validate rename operands before opening the transaction.
if action == "rename" and kind in ("subcategory", "combine_output_var") and not new_name:
return {"ok": False, "error": "new_name required for rename"}
try:
with conn:
touched_before: list[dict[str, Any]] = []
diff: list[dict[str, Any]] = []
key = (kind, action)
if key == ("wildcard", "delete"):
# 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", "")
)
elif key == ("subcategory", "rename"):
touched_before, diff = fix_subcat_rename(
conn, target_id, extra.get("subcat_name", ""), new_name, # type: ignore[arg-type]
)
elif key == ("combine_output_var", "rename"):
touched_before, diff = fix_combine_output_var_rename(
conn, target_id, extra.get("old_name", ""), new_name, # type: ignore[arg-type]
)
elif key == ("category", "delete"):
touched_before, diff = fix_category_delete(conn, target_id)
elif key == ("bundle", "delete"):
touched_before, diff = fix_bundle_delete(conn, target_id)
elif key == ("option", "delete"):
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. 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})
undo_id = write_undo_entry(
conn,
target_kind=kind,
target_id=target_id,
action=action,
snapshot_before=touched_before,
snapshot_after=[],
)
return {
"ok": True,
"undo_entry_id": undo_id,
"affected_count": len(diff),
"affected_entities": affected,
"diff": diff,
}
except Exception as exc:
return {"ok": False, "error": str(exc)}
-353
View File
@@ -1,353 +0,0 @@
"""Pure reverse-ref scan for cascade-edit mutations.
Given a target (kind, id) + action, return the list of entities that
reference the target. Used by:
- confirm dialog (dry-run mode) to show the user what will be affected
- orchestrator to plan the cleanup pass
The scan reads via repositories and walks payload JSON for ``@{uuid}`` /
``@{uuid:subcat}`` / ``$varname`` text refs. Mirrors the regexes in
``src/manager/import-export/dep-graph.ts``.
Pure function — no mutation, no I/O beyond DB reads.
"""
from __future__ import annotations
import re
import sqlite3
from typing import Any
from engine.cascade.fixers import collect_tags
from engine.db.repositories import ModuleRepository
# 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_]*)")
def _list_all_modules(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Return all module rows (no filter). ``ModuleRepository.list()``
accepts all-optional kwargs, so bare call returns everything."""
return ModuleRepository(conn).list()
def _ref_entry(kind: str, row: dict[str, Any], ref_path: str) -> dict[str, Any]:
return {"kind": kind, "id": row["id"], "name": row.get("name", ""), "ref_path": ref_path}
# ---------------------------------------------------------------------------
# Per-(kind, action) scan helpers
# ---------------------------------------------------------------------------
def _scan_wildcard_delete(conn: sqlite3.Connection, wildcard_id: str) -> list[dict[str, Any]]:
"""Wildcard delete: find constraints, other wildcards, derivations,
and bundles that reference this wildcard id."""
out: list[dict[str, Any]] = []
for m in _list_all_modules(conn):
if m["id"] == wildcard_id:
continue
t = m["type"]
p = m.get("payload") or {}
if t == "constraint":
if (p.get("source_wildcard_id") == wildcard_id
or p.get("target_wildcard_id") == wildcard_id):
out.append(_ref_entry("constraint", m, "source_wildcard_id|target_wildcard_id"))
continue
if t == "wildcard":
opts = p.get("options") or []
for idx, opt in enumerate(opts):
v = opt.get("value")
if not isinstance(v, str):
continue
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
if t == "derivation":
found = False
rules = p.get("rules") or []
for ri, rule in enumerate(rules):
if found:
break
for bi, branch in enumerate(rule.get("branches") or []):
if found:
break
for ai, action in enumerate(branch.get("actions") or []):
if not isinstance(action, dict):
continue
for v in action.values():
if isinstance(v, str) and any(
m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)
):
out.append(_ref_entry(
"derivation", m,
f"rules[{ri}].branches[{bi}].actions[{ai}]",
))
found = True
break
if found:
break
continue
# Bundles intentionally excluded from the wildcard-delete impact
# set. Bundle children are full frozen snapshots, not live refs —
# the bundle keeps resolving identically even after the source
# wildcard's library row is deleted. Surfacing the bundle in the
# cascade confirm dialog would imply the snapshot is going to be
# mutated (and the matching fixer in `fix_wildcard_delete` used to
# strip the child entry, which destroyed the captured snapshot).
# See `fix_wildcard_delete` for the matching no-op rationale.
return out
def _scan_subcat(
conn: sqlite3.Connection,
wildcard_id: str,
subcat_name: str,
) -> list[dict[str, Any]]:
"""Shared scan for subcategory delete and rename — affected set is identical."""
out: list[dict[str, Any]] = []
for m in _list_all_modules(conn):
if m["type"] == "constraint":
p = m.get("payload") or {}
matrix = p.get("matrix") or {}
# Source side: matrix keys are source subcat names
source_match = (
p.get("source_wildcard_id") == wildcard_id
and subcat_name in matrix
)
# Target side: matrix row values are dicts keyed by target subcat names
target_match = False
if p.get("target_wildcard_id") == wildcard_id:
for row_val in matrix.values():
if isinstance(row_val, dict) and subcat_name in row_val:
target_match = True
break
if source_match or target_match:
out.append(_ref_entry(
"constraint", m,
"matrix.source" if source_match else "matrix.target",
))
continue
if m["type"] == "wildcard" and m["id"] != wildcard_id:
opts = (m.get("payload") or {}).get("options") or []
for idx, opt in enumerate(opts):
v = opt.get("value")
if not isinstance(v, str):
continue
# 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
return out
def _scan_combine_output_var(
conn: sqlite3.Connection,
var_name: str,
) -> list[dict[str, Any]]:
"""Find modules that use ``$var_name`` as a variable reference or
a ``set_var`` assignment target."""
out: list[dict[str, Any]] = []
for m in _list_all_modules(conn):
t = m["type"]
p = m.get("payload") or {}
if t == "wildcard":
for idx, opt in enumerate(p.get("options") or []):
v = opt.get("value")
if isinstance(v, str) and var_name in _VAR_REGEX.findall(v):
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
break
continue
if t == "derivation":
found = False
for ri, rule in enumerate(p.get("rules") or []):
if found:
break
for bi, branch in enumerate(rule.get("branches") or []):
if found:
break
for ai, action in enumerate(branch.get("actions") or []):
if not isinstance(action, dict):
continue
for k, v in action.items():
# Match as variable ref in a string value
if isinstance(v, str) and var_name in _VAR_REGEX.findall(v):
out.append(_ref_entry(
"derivation", m,
f"rules[{ri}].branches[{bi}].actions[{ai}].{k}",
))
found = True
break
# Or match as the explicit set_var target
if k == "set_var" and v == var_name:
out.append(_ref_entry(
"derivation", m,
f"rules[{ri}].branches[{bi}].actions[{ai}].set_var",
))
found = True
break
if found:
break
continue
if t == "combine":
tpl = p.get("template", "")
if isinstance(tpl, str) and var_name in _VAR_REGEX.findall(tpl):
out.append(_ref_entry("combine", m, "payload.template"))
continue
return out
def _scan_bundle_delete(
conn: sqlite3.Connection,
bundle_id: str,
) -> list[dict[str, Any]]:
"""Find every bundle whose ``children[]`` includes *bundle_id* as a
tier-2 reference. Mirrors `_scan_wildcard_delete` for bundles —
when a bundle is deleted, parent bundles holding it as a ref need
to either drop that entry (cascade-on) or accept a broken ref
(cascade-off; server stamps `_missing_ref` on next GET).
Self-reference (bundle in its own children) is filtered — bundles
can't reference themselves per the tier-2 cap, but defensive.
"""
from engine.db.repositories import BundleRepository
out: list[dict[str, Any]] = []
bundle_repo = BundleRepository(conn)
for b in bundle_repo.list():
if b["id"] == bundle_id:
continue
for ci, child in enumerate(b.get("children") or []):
if child.get("type") == "bundle" and child.get("id") == bundle_id:
out.append(_ref_entry("bundle", b, f"children[{ci}]"))
break
return out
def _scan_option_delete(
conn: sqlite3.Connection,
option_id: str,
) -> list[dict[str, Any]]:
"""Find constraints whose exceptions reference *option_id* on either axis.
Walks every constraint's `exceptions[]`, matching either `source_id`
or `target_id` against the deleted option. Stops at the first hit per
constraint so the affected list lists each constraint at most once.
"""
out: list[dict[str, Any]] = []
for m in _list_all_modules(conn):
if m["type"] != "constraint":
continue
p = m.get("payload") or {}
for idx, ex in enumerate(p.get("exceptions") or []):
if ex.get("source_id") == option_id or ex.get("target_id") == option_id:
axis = "source_id" if ex.get("source_id") == option_id else "target_id"
out.append(_ref_entry("constraint", m, f"exceptions[{idx}].{axis}"))
break
return out
def _scan_category_delete(
conn: sqlite3.Connection,
category_id: str,
) -> list[dict[str, Any]]:
"""Return every module whose ``category_id`` field equals the target."""
return [
_ref_entry(m["type"], m, "category_id")
for m in _list_all_modules(conn)
if m.get("category_id") == category_id
]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def scan_affected(
conn: sqlite3.Connection,
*,
kind: str,
id: str,
action: str,
extra: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Dispatch reverse-ref scan by (kind, action). Pure function.
Parameters
----------
conn:
Open, migrated ``sqlite3.Connection``.
kind:
Entity kind being mutated (e.g. ``"wildcard"``, ``"subcategory"``).
id:
Primary-key id of the target entity.
action:
The mutation action (e.g. ``"delete"``, ``"rename"``).
extra:
Per-mutation context dict. Keys vary by (kind, action):
* subcategory delete/rename → ``{"subcat_name": "warm"}``
* combine_output_var rename → ``{"old_name": "mood", "new_name": "tone"}``
Returns
-------
list[dict]
Each entry: ``{kind, id, name, ref_path}``.
Returns ``[]`` for unsupported (kind, action) pairs (v1 stub —
other pairs land when their fixer + scan ship together).
"""
extra = extra or {}
if kind == "wildcard" and action == "delete":
return _scan_wildcard_delete(conn, id)
if kind == "subcategory" and action in ("delete", "rename"):
return _scan_subcat(conn, id, extra.get("subcat_name", ""))
if kind == "combine_output_var":
# Accept either old_name (rename flow) or name (generic)
var_name = extra.get("old_name") or extra.get("name", "")
return _scan_combine_output_var(conn, var_name)
if kind == "category" and action == "delete":
return _scan_category_delete(conn, id)
if kind == "bundle" and action == "delete":
return _scan_bundle_delete(conn, id)
if kind == "option" and action == "delete":
return _scan_option_delete(conn, id)
# Unsupported (kind, action) pair — v1 stub, returns empty.
return []
-226
View File
@@ -1,226 +0,0 @@
"""Cascade-apply undo persistence + replay.
write_undo_entry stores BEFORE-state snapshots in cascade_undo.
undo_cascade reads + restores via repository methods in a single transaction.
Engine isolation: zero ComfyUI/torch/wp_nodes imports. Stdlib json,
secrets, sqlite3 + repositories only.
"""
from __future__ import annotations
import json
import secrets
import sqlite3
from typing import Any
from engine._utils import now_iso as _now
from engine.db.repositories import (
BundleNotFound,
BundleRepository,
CategoryNotFound,
CategoryRepository,
ModuleNotFound,
ModuleRepository,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def write_undo_entry(
conn: sqlite3.Connection,
*,
target_kind: str,
target_id: str,
action: str,
snapshot_before: list[dict[str, Any]] | None,
snapshot_after: list[dict[str, Any]] | None,
) -> str:
"""Persist a BEFORE/AFTER snapshot pair in cascade_undo.
Returns the generated undo_id (16 hex chars — secrets.token_hex(8)).
Defensive defaults: None is treated as [] for both snapshot args so
callers that pass None don't trip the NOT NULL constraint.
"""
undo_id = secrets.token_hex(8)
conn.execute(
"INSERT INTO cascade_undo "
"(id, created_at, target_kind, target_id, action, snapshot_before, snapshot_after) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
undo_id,
_now(),
target_kind,
target_id,
action,
json.dumps(snapshot_before or []),
json.dumps(snapshot_after or []),
),
)
return undo_id
def undo_cascade(conn: sqlite3.Connection, undo_id: str) -> dict[str, Any]:
"""Restore all entities from snapshot_before atomically.
Returns ``{ok: True}`` on success, ``{ok: False, error: str}`` if the
undo_id is not found or any restore step raises. The "not found"
substring in the error message is intentional — wp_api maps it to
HTTP 404.
"""
row = conn.execute(
"SELECT snapshot_before, target_kind, target_id "
"FROM cascade_undo WHERE id = ?",
(undo_id,),
).fetchone()
if row is None:
return {"ok": False, "error": f"undo entry not found: {undo_id}"}
snapshot_before: list[dict[str, Any]] = json.loads(row["snapshot_before"])
try:
with conn:
for entity in snapshot_before:
_dispatch_restore(conn, entity)
conn.execute("DELETE FROM cascade_undo WHERE id = ?", (undo_id,))
return {"ok": True}
except Exception as exc:
return {"ok": False, "error": str(exc)}
# ---------------------------------------------------------------------------
# Internal restore helpers
# ---------------------------------------------------------------------------
def _dispatch_restore(conn: sqlite3.Connection, entity: dict[str, Any]) -> None:
"""Route an entity snapshot to the correct repository restore path.
Dispatch priority:
1. ``type`` in module-type set → module.
2. ``children`` key present → bundle.
3. ``color`` or ``icon`` key present (and no ``type``) → category.
4. Fallback → treat as module (most common case).
"""
entity_type = entity.get("type")
if entity_type in ("wildcard", "fixed_values", "combine", "derivation", "constraint"):
_restore_module(conn, entity)
elif "children" in entity:
_restore_bundle(conn, entity)
elif "color" in entity or "icon" in entity:
_restore_category(conn, entity)
else:
# Fallback: attempt module restore.
_restore_module(conn, entity)
def _restore_module(conn: sqlite3.Connection, entity: dict[str, Any]) -> None:
repo = ModuleRepository(conn)
eid: str = entity["id"]
try:
repo.get(eid)
# Row exists — overwrite with snapshot values.
# Build kwargs only for fields present + non-None in snapshot.
kwargs: dict[str, Any] = {}
if (name := entity.get("name")) is not None:
kwargs["name"] = name
if (description := entity.get("description")) is not None:
kwargs["description"] = description
if (category_id := entity.get("category_id")) is not None:
kwargs["category_id"] = category_id
if (tags := entity.get("tags")) is not None:
kwargs["tags"] = tags if tags else []
if (payload := entity.get("payload")) is not None:
kwargs["payload"] = payload if payload else {}
if (is_favorite := entity.get("is_favorite")) is not None:
kwargs["is_favorite"] = is_favorite
repo.update(eid, **kwargs)
except ModuleNotFound:
# Row was deleted — recreate at the original id.
# ModuleRepository.create() accepts id= (8-hex validated).
repo.create(
type=entity["type"],
name=entity.get("name", ""),
description=entity.get("description", ""),
category_id=entity.get("category_id"),
tags=entity.get("tags") or [],
payload=entity.get("payload") or {},
is_favorite=entity.get("is_favorite", False),
id=eid,
)
def _restore_bundle(conn: sqlite3.Connection, entity: dict[str, Any]) -> None:
repo = BundleRepository(conn)
eid: str = entity["id"]
try:
repo.get(eid)
# Row exists — overwrite.
# Build kwargs only for fields present + non-None in snapshot.
kwargs: dict[str, Any] = {}
if (name := entity.get("name")) is not None:
kwargs["name"] = name
if (description := entity.get("description")) is not None:
kwargs["description"] = description
if (color := entity.get("color")) is not None:
kwargs["color"] = color
if (category_id := entity.get("category_id")) is not None:
kwargs["category_id"] = category_id
if (tags := entity.get("tags")) is not None:
kwargs["tags"] = tags if tags else []
if (children := entity.get("children")) is not None:
kwargs["children"] = children if children else []
if (is_favorite := entity.get("is_favorite")) is not None:
kwargs["is_favorite"] = is_favorite
repo.update(eid, **kwargs)
except BundleNotFound:
# Row was deleted — recreate at the original id.
# BundleRepository.create() accepts id= (8-hex validated).
repo.create(
name=entity.get("name", ""),
description=entity.get("description", ""),
color=entity.get("color"),
category_id=entity.get("category_id"),
tags=entity.get("tags") or [],
children=entity.get("children") or [],
is_favorite=entity.get("is_favorite", False),
id=eid,
)
def _restore_category(conn: sqlite3.Connection, entity: dict[str, Any]) -> None:
"""Restore a category row.
CategoryRepository.create() derives the id from the name slug and does
not accept an explicit ``id=`` kwarg. For deleted categories we fall back
to a raw INSERT so the original (slug-based) id is preserved exactly.
"""
repo = CategoryRepository(conn)
eid: str = entity["id"]
try:
repo.get(eid)
# Row exists — update in place.
# Build kwargs only for fields present + non-None in snapshot.
kwargs: dict[str, Any] = {}
if (name := entity.get("name")) is not None:
kwargs["name"] = name
if (color := entity.get("color")) is not None:
kwargs["color"] = color
if (icon := entity.get("icon")) is not None:
kwargs["icon"] = icon
if (sort_order := entity.get("sort_order")) is not None:
kwargs["sort_order"] = sort_order
repo.update(eid, **kwargs)
except CategoryNotFound:
# Raw INSERT to preserve the original slug-based id.
conn.execute(
"INSERT INTO module_categories (id, name, color, icon, sort_order) "
"VALUES (?, ?, ?, ?, ?)",
(
eid,
entity.get("name", ""),
entity.get("color"),
entity.get("icon"),
entity.get("sort_order", 0),
),
)
-7
View File
@@ -1,7 +0,0 @@
"""Prompt cleaner engine — pure-Python rule pipeline.
Zero ComfyUI imports — runs as plain Python and is testable with
vanilla pytest. The node layer (wp_nodes/prompt_cleaner.py) adapts
PIPELINE_CONTEXT into the small CleanerCtx dataclass this module
consumes.
"""
-72
View File
@@ -1,72 +0,0 @@
"""PromptCleaner — orchestrate rule execution by intensity preset.
The pipeline owns three responsibilities:
1. Translate an intensity preset ("gentle"/"balanced"/"aggressive")
into the set of rules that should run by default.
2. Apply per-rule overrides supplied by the node config.
3. Iterate the registry in canonical order, accumulating a
`RunReport` of stats keyed by rule_id.
Rules always execute in registry order — the intensity preset only
controls which rules are *enabled*, never reorders them. This keeps
output deterministic across presets.
"""
from __future__ import annotations
from typing import Any
from engine.cleaner.rules import RULE_REGISTRY
from engine.cleaner.types import RuleId, RunReport
INTENSITY_TO_RULES: dict[str, list[RuleId]] = {
"gentle": ["whitespace"],
"balanced": ["whitespace", "punctuation", "dedupe_exact"],
"aggressive": [
"whitespace",
"punctuation",
"dedupe_exact",
"fuzzy_dedupe",
],
}
def _effective_rules(intensity: str, overrides: dict[str, bool]) -> set[RuleId]:
base: set[RuleId] = set(INTENSITY_TO_RULES.get(intensity, INTENSITY_TO_RULES["balanced"]))
for rule_id, enabled in overrides.items():
if enabled:
base.add(rule_id) # type: ignore[arg-type]
else:
base.discard(rule_id) # type: ignore[arg-type]
return base
class PromptCleaner:
"""Stateless orchestrator. Safe to instantiate once and reuse."""
def run(
self,
text: str,
config: dict[str, Any] | None = None,
) -> dict[str, Any]:
cfg = config or {}
mode = cfg.get("mode", "tags")
intensity = cfg.get("intensity", "balanced")
overrides: dict[str, bool] = dict(cfg.get("rules_override") or {})
# Blocklist auto-enables when entries are present unless the user
# explicitly turned it off via override.
blocklist_cfg = cfg.get("blocklist") or {}
if blocklist_cfg.get("entries") and "blocklist" not in overrides:
overrides["blocklist"] = True
active = _effective_rules(intensity, overrides)
out = text
report: RunReport = {}
for rule_id, fn in RULE_REGISTRY:
if rule_id not in active:
continue
result = fn(out, mode, cfg)
out = result["text"]
report[rule_id] = result["stats"]
return {"text": out, "report": report}
-35
View File
@@ -1,35 +0,0 @@
"""Cleaner rule registry.
Each rule module exports a single `apply(text, mode, config)` function
returning a `RuleResult`. The registry below pins the canonical
execution order — the pipeline iterates rules in this order regardless
of how the widget toggles them, so output stays deterministic.
"""
from __future__ import annotations
from collections.abc import Callable
from engine.cleaner.rules import (
blocklist,
dedupe,
punctuation,
whitespace,
)
from engine.cleaner.types import RuleId, RuleResult
ApplyFn = Callable[[str, str, dict], RuleResult]
RULE_REGISTRY: list[tuple[RuleId, ApplyFn]] = [
("whitespace", whitespace.apply),
("punctuation", punctuation.apply),
("dedupe_exact", dedupe.apply_exact),
("fuzzy_dedupe", dedupe.apply_fuzzy),
("blocklist", blocklist.apply),
]
def get_rule(rule_id: RuleId) -> ApplyFn:
for rid, fn in RULE_REGISTRY:
if rid == rule_id:
return fn
raise KeyError(f"unknown rule id: {rule_id!r}")
-111
View File
@@ -1,111 +0,0 @@
"""Blocklist filter — drop tags matching a user-supplied list or regex set.
Two modes (chosen via config.blocklist.kind):
- "list": case-insensitive word-boundary substring match within each
tag (tags mode) or whole-string (text mode). Drops the
whole matching tag.
- "regex": each entry compiled separately; bad patterns are caught
and reported in stats.errors so a single bad regex doesn't
kill the cleaner.
The widget's blocklist modal owns mode selection + entry editing.
"""
from __future__ import annotations
import re
from engine.cleaner.types import RuleResult
# Post-strip cleanup patterns for text mode. After removing a blocklist
# match the surrounding `, .` artifacts need to collapse so the user
# doesn't see "cfg, . avoid" where "cfg, steps. avoid" used to be.
_TEXT_ORPHAN_PUNCT = re.compile(r"\s+[.,:;!?]+(?=\s|$)")
_TEXT_DOUBLE_SPACE = re.compile(r"\s{2,}")
_TEXT_STRAY_EDGES = re.compile(r"^[\s.,:;!?]+|[\s.,:;!?]+$")
def _cleanup_text(s: str) -> str:
out = _TEXT_ORPHAN_PUNCT.sub("", s)
out = _TEXT_DOUBLE_SPACE.sub(" ", out)
out = _TEXT_STRAY_EDGES.sub("", out)
return out
def apply(text: str, mode: str, config: dict) -> RuleResult:
blocklist = (config or {}).get("blocklist") or {}
kind = blocklist.get("kind", "list")
entries = blocklist.get("entries") or []
if not entries:
return {"text": text, "stats": {"dropped": [], "errors": []}}
if kind == "regex":
return _apply_regex(text, mode, entries)
return _apply_list(text, mode, entries)
def _apply_list(text: str, mode: str, entries: list[str]) -> RuleResult:
norm_entries = [e for e in entries if isinstance(e, str) and e]
dropped: list[str] = []
if mode == "tags":
tags = [t.strip() for t in text.split(",") if t.strip()]
kept: list[str] = []
compiled: list[tuple[str, re.Pattern[str]]] = []
for entry in norm_entries:
try:
compiled.append((entry, re.compile(rf"\b{re.escape(entry)}\b", re.IGNORECASE)))
except re.error:
continue
for tag in tags:
if any(p.search(tag) or tag.casefold() == src.casefold() for src, p in compiled):
dropped.append(tag)
else:
kept.append(tag)
out = ", ".join(kept)
else:
out = text
any_changed = False
for entry in norm_entries:
pattern = re.compile(rf"\b{re.escape(entry)}\b", re.IGNORECASE)
new = pattern.sub("", out)
if new != out:
dropped.append(entry)
any_changed = True
out = new
if any_changed:
out = _cleanup_text(out)
return {"text": out, "stats": {"dropped": dropped, "errors": []}}
def _apply_regex(text: str, mode: str, entries: list[str]) -> RuleResult:
out = text
dropped: list[str] = []
errors: list[str] = []
compiled: list[tuple[str, re.Pattern[str]]] = []
for entry in entries:
if not isinstance(entry, str) or not entry:
continue
try:
compiled.append((entry, re.compile(entry, re.IGNORECASE)))
except re.error:
errors.append(entry)
if mode == "tags":
tags = [t.strip() for t in out.split(",") if t.strip()]
kept: list[str] = []
for tag in tags:
matched = any(p.search(tag) for _, p in compiled)
if matched:
dropped.append(tag)
else:
kept.append(tag)
out = ", ".join(kept)
else:
any_changed = False
for source, pattern in compiled:
new = pattern.sub("", out)
if new != out:
dropped.append(source)
any_changed = True
out = new
if any_changed:
out = _cleanup_text(out)
return {"text": out, "stats": {"dropped": dropped, "errors": errors}}
-85
View File
@@ -1,85 +0,0 @@
"""Dedupe family — exact + fuzzy.
- `apply_exact` — drop later occurrences of an identical tag (tags
mode only). Leftmost wins. Case-insensitive comparison;
preserved case in the kept token.
- `apply_fuzzy` — drop near-duplicates via Levenshtein ratio. Threshold
hardcoded at 0.9. Tags mode only.
"""
from __future__ import annotations
from engine.cleaner.types import RuleResult
_FUZZY_THRESHOLD = 0.9
def _split_tags(text: str) -> list[str]:
return [t.strip() for t in text.split(",") if t.strip()]
def _join_tags(tags: list[str]) -> str:
return ", ".join(tags)
def apply_exact(text: str, mode: str, config: dict) -> RuleResult:
if mode != "tags":
return {"text": text, "stats": {"dropped": []}}
tags = _split_tags(text)
seen: set[str] = set()
kept: list[str] = []
dropped: list[str] = []
for tag in tags:
key = tag.casefold()
if key in seen:
dropped.append(tag)
else:
seen.add(key)
kept.append(tag)
return {"text": _join_tags(kept), "stats": {"dropped": dropped}}
def _levenshtein_ratio(a: str, b: str) -> float:
if a == b:
return 1.0
if not a or not b:
return 0.0
la, lb = len(a), len(b)
prev = list(range(lb + 1))
cur = [0] * (lb + 1)
for i, ca in enumerate(a, 1):
cur[0] = i
for j, cb in enumerate(b, 1):
cost = 0 if ca == cb else 1
cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
prev, cur = cur, prev
distance = prev[lb]
return 1.0 - distance / max(la, lb)
try:
from rapidfuzz.fuzz import ratio as _rapidfuzz_ratio # type: ignore[import-not-found]
def _ratio(a: str, b: str) -> float:
return _rapidfuzz_ratio(a, b) / 100.0
except ImportError:
_ratio = _levenshtein_ratio
def apply_fuzzy(text: str, mode: str, config: dict) -> RuleResult:
if mode != "tags":
return {"text": text, "stats": {"dropped": []}}
tags = _split_tags(text)
kept: list[str] = []
dropped: list[str] = []
for tag in tags:
key = tag.casefold()
is_dup = False
for kept_tag in kept:
if _ratio(key, kept_tag.casefold()) >= _FUZZY_THRESHOLD:
is_dup = True
break
if is_dup:
dropped.append(tag)
else:
kept.append(tag)
return {"text": _join_tags(kept), "stats": {"dropped": dropped}}
-50
View File
@@ -1,50 +0,0 @@
"""Strip orphan / leading / trailing punctuation.
Two cleanups:
1. Tags mode — drop tags whose only content is punctuation
(`.`, `,`, `:`, `;`, `!`, `?`, `-`, `_`, plus runs of those).
2. Tags + text mode — strip leading + trailing punctuation runs
from each tag (tags mode) or from the whole string (text mode).
Idempotent: re-running on the same input produces the same output.
Reports `stats.stripped` = total tags + edges trimmed.
"""
from __future__ import annotations
import re
from engine.cleaner.types import RuleResult
_PUNCT_CHARS = r".,:;!?\-_"
_PURE_PUNCT_RE = re.compile(rf"^[{_PUNCT_CHARS}\s]+$")
_LEADING_PUNCT_RE = re.compile(rf"^[{_PUNCT_CHARS}\s]+")
_TRAILING_PUNCT_RE = re.compile(rf"[{_PUNCT_CHARS}\s]+$")
def _strip_edges(s: str) -> tuple[str, bool]:
new = _LEADING_PUNCT_RE.sub("", s)
new = _TRAILING_PUNCT_RE.sub("", new)
return new, new != s
def apply(text: str, mode: str, config: dict) -> RuleResult:
stripped = 0
if mode == "tags":
tags = [t.strip() for t in text.split(",") if t.strip()]
kept: list[str] = []
for tag in tags:
if _PURE_PUNCT_RE.match(tag):
stripped += 1
continue
edged, changed = _strip_edges(tag)
if changed:
stripped += 1
if edged:
kept.append(edged)
out = ", ".join(kept)
else:
out, changed = _strip_edges(text)
if changed:
stripped += 1
return {"text": out, "stats": {"stripped": stripped}}
-55
View File
@@ -1,55 +0,0 @@
"""Whitespace + punctuation normalization.
Rules applied in one pass over the input:
1. Collapse runs of horizontal whitespace into a single space.
2. Strip leading + trailing whitespace.
3. Collapse runs of commas (`,,,` -> `,`).
4. Drop leading + trailing standalone commas.
5. Normalize `comma-space` separator in tags mode.
Counts every kind of fix into stats.fixed so the UI can show
`ws:N` next to the rule row.
"""
from __future__ import annotations
import re
from engine.cleaner.types import RuleResult
_WS_RUN = re.compile(r"[ \t]+")
_COMMA_RUN = re.compile(r",[ \t,]*,")
def apply(text: str, mode: str, config: dict) -> RuleResult:
fixed = 0
out = text
collapsed_ws = _WS_RUN.sub(" ", out)
if collapsed_ws != out:
fixed += 1
out = collapsed_ws
trimmed = out.strip()
if trimmed != out:
fixed += 1
out = trimmed
collapsed_commas = _COMMA_RUN.sub(",", out)
if collapsed_commas != out:
fixed += 1
out = collapsed_commas
while out.startswith(","):
out = out[1:].lstrip()
fixed += 1
while out.endswith(","):
out = out[:-1].rstrip()
fixed += 1
if mode == "tags":
normalized = re.sub(r",(?!\s)", ", ", out)
if normalized != out:
fixed += 1
out = normalized
return {"text": out, "stats": {"fixed": fixed}}
-25
View File
@@ -1,25 +0,0 @@
"""Lightweight token/word/char counters for the cleaner UI.
The cleaner widget shows `Nw · Nc` next to the prompt. These helpers
keep that math out of the rule modules so they stay focused on
transformation.
CLIP token count is deferred to the node layer where the actual CLIP
tokenizer instance lives (it can't be imported in the engine because
torch loading is heavy).
"""
from __future__ import annotations
import re
_WORD = re.compile(r"\w+", re.UNICODE)
def count_words(text: str) -> int:
if not text:
return 0
return len(_WORD.findall(text))
def count_chars(text: str) -> int:
return len(text)
-36
View File
@@ -1,36 +0,0 @@
"""Shared types for the cleaner pipeline.
All rules are string-only — no upstream PIPELINE_CONTEXT dependency.
The cleaner operates purely on the prompt string + the user's widget
config (mode, intensity, rule overrides, blocklist).
"""
from __future__ import annotations
from typing import Any, Literal, TypedDict
RuleId = Literal[
"whitespace",
"punctuation",
"dedupe_exact",
"fuzzy_dedupe",
"blocklist",
]
Mode = Literal["tags", "text"]
class RuleResult(TypedDict):
"""Return shape for every rule.
`text` is the transformed prompt. `stats` is a per-rule key/value
dict the pipeline collects into a RunReport — UI surfaces these
inline next to each active rule in the widget.
"""
text: str
stats: dict[str, Any]
# A RunReport is `{rule_id -> stats dict}`. Keep as a type alias rather
# than TypedDict so callers can iterate rule_ids dynamically.
RunReport = dict[RuleId, dict[str, Any]]
+1 -41
View File
@@ -22,45 +22,5 @@ Context = dict[str, Any]
def strip_internals(ctx: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of ``ctx`` with engine-only + user-flagged-internal keys removed.
Use at the *prompt render* boundary (PromptAssembler) — both engine
bookkeeping AND user-marked-internal vars are filtered:
1. ``__``-prefixed keys: engine bookkeeping (``__wp_node_seed__``,
``__wp_trace__``, ``__wp_internal_flags__``, …).
2. Keys whose name appears with ``True`` in the
``__wp_internal_flags__`` map: user-marked-internal bindings
from modules with ``instance.internal == True``. These vars
drive composition (other modules can read them) but must not
appear as standalone ``$var`` substitutions in the rendered
prompt — that's the "internal" UX promise.
For the *socket* boundary (PIPELINE_CONTEXT carrying state between
Context nodes), use ``strip_engine_internals`` instead — it drops
only the ``__``-prefixed engine keys so user-flagged internal vars
continue to propagate through downstream Context / Combine / etc.
until they reach a PromptAssembler that filters them at render
time.
"""
flags = ctx.get("__wp_internal_flags__")
internal_names: set[str] = set()
if isinstance(flags, dict):
internal_names = {k for k, v in flags.items() if v}
return {
k: v for k, v in ctx.items()
if not k.startswith("__") and k not in internal_names
}
def strip_engine_internals(ctx: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of ``ctx`` with only the ``__``-prefixed engine keys removed.
User-flagged internal vars stay in the result — they need to
propagate across Context-node boundaries so downstream Combine /
Derivation / Constraint modules can read them. Only the final
PromptAssembler filters them out at render time (via
``strip_internals``) so they don't appear as standalone ``$var``
substitutions in the prompt text.
"""
"""Return a copy of ``ctx`` without ``__``-prefixed internal keys."""
return {k: v for k, v in ctx.items() if not k.startswith("__")}
-87
View File
@@ -1,87 +0,0 @@
"""Pure-Python parsers for the WP_VarTo* converter nodes.
Each function:
- Never raises. Unparseable input or out-of-range index returns `default`.
- Has a TS mirror in `src/components/var-picker/parser.ts`. The two copies
share a fixture (`tests/fixtures/converter_cases.json`) consumed by a
parity test (`tests/test_parser_parity.py` + `parser.test.ts`).
"""
import re
from typing import Protocol
from engine.syntax.types import deref_var_value, split_var_accessor
class _CtxLike(Protocol):
context: dict[str, object]
_INT_RE = re.compile(r"-?\d+")
_FLOAT_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")
_BOOL_SPLIT_RE = re.compile(r"[\s,;|/]+")
_TRUTHY: frozenset[str] = frozenset({"true", "yes", "on", "1"})
_FALSY: frozenset[str] = frozenset({"false", "no", "off", "0"})
def parse_int(text: str, index: int, default: int) -> int:
"""Return the Nth signed-integer match from `text`, or `default`."""
matches = _INT_RE.findall(text or "")
if index < 0 or index >= len(matches):
return default
try:
return int(matches[index])
except ValueError:
return default
def parse_float(text: str, index: int, default: float) -> float:
"""Return the Nth signed-float match from `text`, or `default`.
Matches integers too (no required decimal) so a var of `"1920"` still
yields `1920.0`. Scientific notation supported (`3.2e-4`).
"""
matches = _FLOAT_RE.findall(text or "")
if index < 0 or index >= len(matches):
return default
try:
return float(matches[index])
except ValueError:
return default
def parse_bool(text: str, index: int, default: bool) -> bool:
"""Return the Nth bool token from `text`, or `default`.
Tokens are produced by splitting on `[\\s,;|/]+`. Each token is
matched case-insensitively against the truthy/falsy sets. Tokens
that match neither (e.g. `"1.5"`, `"enabled"`) are skipped — they
don't consume an index slot.
"""
if not text:
return default
tokens = _BOOL_SPLIT_RE.split(text)
bools: list[bool] = []
for tok in tokens:
low = tok.lower()
if low in _TRUTHY:
bools.append(True)
elif low in _FALSY:
bools.append(False)
if index < 0 or index >= len(bools):
return default
return bools[index]
def lookup_var(context: _CtxLike, name: str) -> str:
"""Read `name` from `context.context`. Strips leading `$`. Never raises.
Missing key, `None`, or empty name all collapse to `""` — every parser
then falls back to its `default`.
"""
bare = (name or "").lstrip("$").strip()
if not bare:
return ""
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)
-112
View File
@@ -1,112 +0,0 @@
"""Sidecar config for DB location preference + pending move state.
Lives at ``<plugin>/db-config.json`` so the resolver can read it BEFORE
opening any DB connection (chicken-and-egg: the preference about where
the DB lives can't live inside the DB itself).
Atomic writes: write-to-temp-then-rename so a crash mid-write can't
corrupt the sidecar.
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any, Literal, TypedDict
Preference = Literal["user", "global", "root"]
MoveMode = Literal["copy", "move"]
# Plugin root: this file is at <plugin>/engine/db/config.py — go up three.
_PLUGIN_ROOT = Path(__file__).resolve().parents[2]
SIDECAR_PATH = _PLUGIN_ROOT / "db-config.json"
_VALID_PREFERENCES = {"user", "global", "root"}
_VALID_MODES = {"copy", "move"}
class PendingMove(TypedDict, total=False):
from_: str # NB: "from" is a Python keyword; we map this on read/write
to: str
mode: MoveMode
class DbConfig(TypedDict, total=False):
preference: Preference
pending_move: dict[str, str] # {"from": "...", "to": "...", "mode": "..."}
def _read_raw(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _resolve_sidecar(path: Path | None) -> Path:
"""Resolve the effective sidecar path.
Reads ``SIDECAR_PATH`` from this module dynamically when ``path`` is
``None`` so tests can ``monkeypatch.setattr("engine.db.config.SIDECAR_PATH", ...)``
and have all three of ``load``/``save``/``clear_pending_move`` honor
the override. Using a default argument like ``path = SIDECAR_PATH``
captures the value at function-definition time, which breaks
monkeypatching the module attribute."""
return path if path is not None else SIDECAR_PATH
def load(path: Path | None = None) -> DbConfig:
"""Return the sidecar config or {} if absent / unreadable / invalid."""
raw = _read_raw(_resolve_sidecar(path))
out: DbConfig = {}
pref = raw.get("preference")
if isinstance(pref, str) and pref in _VALID_PREFERENCES:
out["preference"] = pref # type: ignore[typeddict-item]
pm = raw.get("pending_move")
if isinstance(pm, dict):
mode = pm.get("mode")
src = pm.get("from")
dst = pm.get("to")
if (isinstance(mode, str) and mode in _VALID_MODES
and isinstance(src, str) and isinstance(dst, str)):
out["pending_move"] = {"from": src, "to": dst, "mode": mode}
return out
def save(config: DbConfig, path: Path | None = None) -> None:
"""Atomically write the sidecar (write-temp + rename)."""
path = _resolve_sidecar(path)
path.parent.mkdir(parents=True, exist_ok=True)
# Strip any unknown keys; only persist the validated shape.
out: dict[str, Any] = {}
if "preference" in config:
out["preference"] = config["preference"]
pm = config.get("pending_move")
if isinstance(pm, dict) and {"from", "to", "mode"} <= pm.keys():
out["pending_move"] = {"from": pm["from"], "to": pm["to"], "mode": pm["mode"]}
fd, tmp_str = tempfile.mkstemp(prefix=".db-config-", dir=str(path.parent))
tmp_path = Path(tmp_str)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2)
os.replace(tmp_path, path)
except OSError:
tmp_path.unlink(missing_ok=True)
raise
def clear_pending_move(path: Path | None = None) -> None:
"""Remove just the pending_move field; preserve preference."""
path = _resolve_sidecar(path)
cfg = load(path)
if "pending_move" in cfg:
cfg.pop("pending_move", None)
save(cfg, path)
def plugin_root() -> Path:
"""Expose the plugin root so the resolver can compute the `root` location."""
return _PLUGIN_ROOT
+11 -162
View File
@@ -1,29 +1,9 @@
"""SQLite connection factory.
DB path resolution order (highest priority first):
1. ``WP_DB_PATH`` env var — explicit override, used as-is. Source ``"WP_DB_PATH"``.
2. ``COMFYUI_USER_DIR`` env var — places the DB under
``$COMFYUI_USER_DIR/wildcard-pipeline.db``. Source ``"COMFYUI_USER_DIR"``.
3. Sidecar ``preference`` field at ``<plugin>/db-config.json`` (loaded
indirectly through ``_load_sidecar`` so tests can stub it):
- ``"user"`` → ComfyUI user dir + ``wildcard-pipeline.db``,
source ``"user"``. Falls through if the user dir can't be detected.
- ``"global"`` → ``~/.comfyui/wildcard-pipeline.db``, source ``"global"``.
- ``"root"`` → ``<plugin>/db/wildcard-pipeline.db``, source ``"root"``.
4. Default — ComfyUI user dir + ``wildcard-pipeline.db``, source ``"user"``.
Falls through if detection fails.
5. Last-ditch fallback — ``~/.comfyui/wildcard-pipeline.db``, source
``"global"`` (the design folds the old ``"legacy"`` label into
``"global"`` since the path is identical and the UI exposes a Global
radio for it).
The pre-feature back-compat clause that preferred legacy when the new
path had no DB has been removed — the design intentionally surprises
users with an empty new DB so they migrate explicitly via Settings.
Tests can short-circuit the entire chain by setting ``WP_DB_PATH`` or by
stubbing ``_load_sidecar`` to return a chosen preference.
DB path resolution order:
1. ``WP_DB_PATH`` env var (explicit override)
2. ``$COMFYUI_USER_DIR/wildcard-pipeline.db``
3. Fallback: ``~/.comfyui/wildcard-pipeline.db``
"""
from __future__ import annotations
@@ -31,146 +11,15 @@ import os
import sqlite3
from pathlib import Path
DB_FILENAME = "wildcard-pipeline.db"
def _comfyui_user_dir_from_api() -> Path | None:
"""Use ComfyUI's `folder_paths.get_user_directory()` when the host
process exposes it. Import is guarded because pytest runs outside
the ComfyUI sys.path and the module won't be importable there."""
try:
import folder_paths # type: ignore[import-not-found]
except Exception:
return None
try:
return Path(folder_paths.get_user_directory())
except Exception:
return None
def _comfyui_user_dir_from_path() -> Path | None:
"""Walk up from this file to locate the ComfyUI root.
Custom nodes live at ``<ComfyUI>/custom_nodes/<plugin>/`` by
convention, so this file at
``<ComfyUI>/custom_nodes/ComfyUI-Wildcard-Pipeline/engine/db/connection.py``
is exactly 4 parents away from the ComfyUI root.
A presence check on ``<root>/main.py`` (ComfyUI's entrypoint) +
the `custom_nodes/` directory guards against returning a wrong
root for unusual install layouts (e.g. when the plugin is
symlinked from somewhere else).
"""
here = Path(__file__).resolve()
parents = here.parents
if len(parents) < 5:
return None
candidate = parents[4]
if not (candidate / "custom_nodes").is_dir():
return None
if not (candidate / "main.py").is_file():
return None
return candidate / "user"
def _legacy_home_path() -> Path:
"""Return ``~/.comfyui/wildcard-pipeline.db``.
Still named ``_legacy_*`` for historical reasons but now serves as
both the explicit ``global`` preference target and the last-ditch
fallback when no ComfyUI user dir can be detected. Both report
source ``"global"``."""
return Path.home() / ".comfyui" / DB_FILENAME
def _plugin_root_db_path() -> Path:
"""Return ``<plugin>/db/wildcard-pipeline.db`` — the ``root``
preference target. Lives inside the plugin install dir so it
survives ComfyUI reinstalls; vulnerable to plugin git pulls that
wipe untracked files (called out in the UI as "not recommended")."""
from engine.db.config import plugin_root
return plugin_root() / "db" / DB_FILENAME
def _load_sidecar() -> dict:
"""Indirection over ``engine.db.config.load`` so tests can stub the
sidecar read without touching the real ``<plugin>/db-config.json``
file."""
from engine.db.config import load
return dict(load())
def user_location_path() -> Path | None:
"""Return the ``user`` preference target path, or ``None`` if the
ComfyUI user dir can't be detected.
Same detector chain that ``resolve_db_path_with_source`` uses for the
``user`` branch. Exposed for the SPA Settings API so the frontend can
list all three potential locations regardless of which one is
currently active (or whether one is detectable at all)."""
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
return (comfy_user / DB_FILENAME) if comfy_user else None
def global_location_path() -> Path:
"""Return the ``global`` preference target — ``~/.comfyui/wildcard-pipeline.db``.
Always available regardless of host detection (just ``Path.home()``)."""
return _legacy_home_path()
def root_location_path() -> Path:
"""Return the ``root`` preference target — ``<plugin>/db/wildcard-pipeline.db``."""
return _plugin_root_db_path()
def resolve_db_path_with_source() -> tuple[Path, str]:
"""Like ``resolve_db_path`` but also reports which resolver rule won.
Source values:
- ``"WP_DB_PATH"`` — explicit env override
- ``"COMFYUI_USER_DIR"`` — env user-dir
- ``"user"`` — ComfyUI user dir (sidecar preference or default)
- ``"global"`` — ~/.comfyui/wildcard-pipeline.db
- ``"root"`` — <plugin>/db/wildcard-pipeline.db
"""
override = os.environ.get("WP_DB_PATH")
if override:
return Path(override), "WP_DB_PATH"
user_dir_env = os.environ.get("COMFYUI_USER_DIR")
if user_dir_env:
return Path(user_dir_env) / DB_FILENAME, "COMFYUI_USER_DIR"
sidecar = _load_sidecar()
preference = sidecar.get("preference")
if preference == "global":
return _legacy_home_path(), "global"
if preference == "root":
return _plugin_root_db_path(), "root"
if preference == "user":
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
if comfy_user is not None:
return comfy_user / DB_FILENAME, "user"
# Detection failed — fall through to the default chain rather
# than crash. Last-ditch fallback below will catch it.
# Default — same as explicit "user" preference.
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
if comfy_user is not None:
return comfy_user / DB_FILENAME, "user"
# Last-ditch fallback. The design folds the old "legacy" label into
# "global" because the path is identical and the UI has no separate
# "legacy" radio.
return _legacy_home_path(), "global"
def resolve_db_path() -> Path:
"""Path-only convenience wrapper — preserved for all existing callers."""
return resolve_db_path_with_source()[0]
override = os.environ.get("WP_DB_PATH")
if override:
return Path(override)
user_dir = os.environ.get("COMFYUI_USER_DIR")
if user_dir:
return Path(user_dir) / "wildcard-pipeline.db"
return Path.home() / ".comfyui" / "wildcard-pipeline.db"
def get_connection(path: Path | None = None) -> sqlite3.Connection:
-286
View File
@@ -1,286 +0,0 @@
"""Read-only DB introspection + safe maintenance operations.
Pure Python — no ComfyUI imports, no aiohttp. Each public function takes
a ``sqlite3.Connection`` and returns a dict. Expected-failure modes
(locked DB, integrity errors) are reported via ``ok: False`` payloads,
not exceptions, so the API layer can pass results through verbatim.
"""
from __future__ import annotations
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from engine.db.migrations import _discover, current_version
# Map module-row "type" column values to the human/UI-facing names used
# in the counts response. Kept in one place so future kinds get a single
# insertion point.
_KIND_TYPES: dict[str, str] = {
"wildcards": "wildcard",
"fixed_values": "fixed_values",
"combines": "combine",
"derivations": "derivation",
"constraints": "constraint",
}
# Maps the UI-facing count key to the real SQLite table name. Some
# tables have a `module_` prefix in storage but show up unprefixed in
# the response (e.g. `module_categories` -> "categories") to keep the
# settings card readable.
_TABLE_COUNTS: dict[str, str] = {
"bundles": "bundles",
"templates": "templates",
"categories": "module_categories",
}
def _iso(ts: float) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
def _count_modules_by_type(conn: sqlite3.Connection, type_value: str) -> int:
row = conn.execute(
"SELECT COUNT(*) FROM modules WHERE type = ?", (type_value,)
).fetchone()
return int(row[0]) if row else 0
def _count_table(conn: sqlite3.Connection, table: str) -> int:
try:
row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
except sqlite3.OperationalError:
# Table might not exist on very old DBs that haven't migrated yet.
return 0
return int(row[0]) if row else 0
def _pragma_int(conn: sqlite3.Connection, name: str) -> int:
row = conn.execute(f"PRAGMA {name}").fetchone()
return int(row[0]) if row else 0
def _pragma_str(conn: sqlite3.Connection, name: str) -> str:
row = conn.execute(f"PRAGMA {name}").fetchone()
return str(row[0]) if row else ""
def _migration_names() -> dict[int, str]:
"""Discover known migration files and map version -> stem name.
The migrations table only stores ``version`` and ``applied_at``;
the human-readable name lives in the filename
(``012_templates.sql`` -> ``012_templates``). Walking the same
discovery routine the migrator uses keeps this in sync.
"""
return {version: path.stem for version, path in _discover()}
def _applied_migrations(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Return all rows from the migrations table joined with the
on-disk migration name. Empty list if the table doesn't exist
(pre-migration DB)."""
table = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';"
).fetchone()
if not table:
return []
rows = conn.execute(
"SELECT version, applied_at FROM migrations ORDER BY version ASC"
).fetchall()
names = _migration_names()
return [
{
"version": int(r[0]),
"name": names.get(int(r[0]), f"migration_{int(r[0]):03d}"),
"applied_at": str(r[1]),
}
for r in rows
]
def gather_info(
conn: sqlite3.Connection, db_path: Path, *, source: str = "unknown"
) -> dict[str, Any]:
"""Return a read-only snapshot of the DB suitable for the SPA card.
``source`` is passed through verbatim. The resolver in
``engine.db.connection`` produces one of:
``"WP_DB_PATH" | "COMFYUI_USER_DIR" | "user" | "global" | "root"``.
``"unknown"`` is reserved for callers that don't have a real source
(e.g. engine unit tests that build a connection directly)."""
counts: dict[str, int] = {}
for ui_name, type_value in _KIND_TYPES.items():
counts[ui_name] = _count_modules_by_type(conn, type_value)
for ui_name, table_name in _TABLE_COUNTS.items():
counts[ui_name] = _count_table(conn, table_name)
try:
stat = db_path.stat()
size_bytes = stat.st_size
mtime_iso = _iso(stat.st_mtime)
except OSError:
size_bytes = 0
mtime_iso = ""
return {
"path": str(db_path),
"source": source,
"size_bytes": size_bytes,
"mtime_iso": mtime_iso,
"counts": counts,
"migration": {
"current_version": current_version(conn),
"applied": _applied_migrations(conn),
},
"pragma": {
"journal_mode": _pragma_str(conn, "journal_mode"),
"foreign_keys": _pragma_int(conn, "foreign_keys"),
"page_size": _pragma_int(conn, "page_size"),
"page_count": _pragma_int(conn, "page_count"),
"freelist_count": _pragma_int(conn, "freelist_count"),
},
}
def _db_filename(conn: sqlite3.Connection) -> str | None:
"""Return the on-disk filename for the main DB attached to this
connection, or None if it's an in-memory / un-attached connection.
Uses ``PRAGMA database_list`` which returns one row per attached
DB; the ``main`` one is what we care about. The third column is the
absolute file path (empty string for ``:memory:``)."""
try:
for row in conn.execute("PRAGMA database_list"):
# row order: seq, name, file
if row[1] == "main":
fname = row[2]
return fname if fname else None
except sqlite3.OperationalError:
return None
return None
def _file_size(conn: sqlite3.Connection) -> int:
"""Return current DB file size from the filesystem. Falls back to
page_size * page_count (logical size) when the connection is
in-memory or the file can't be stat'd."""
fname = _db_filename(conn)
if fname:
try:
return Path(fname).stat().st_size
except OSError:
pass
page_size = _pragma_int(conn, "page_size")
page_count = _pragma_int(conn, "page_count")
return page_size * page_count
def vacuum(conn: sqlite3.Connection) -> dict[str, Any]:
"""Run VACUUM. Returns {ok, op, duration_ms, bytes_reclaimed}.
bytes_reclaimed is the on-disk size delta of the main DB file. In
WAL mode pending writes live in the ``-wal`` sidecar until a
checkpoint flushes them into the main DB, so we issue a TRUNCATE
checkpoint BOTH before VACUUM (so ``size_before`` reflects the real
occupied size including the freelist we're about to reclaim) and
after VACUUM (so ``size_after`` reflects the post-vacuum shrinkage
immediately rather than waiting for the next automatic checkpoint).
"""
t0 = time.monotonic()
try:
# Pre-checkpoint: flush any pending WAL writes into the main DB
# file so size_before captures the true on-disk footprint.
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
size_before = _file_size(conn)
conn.execute("VACUUM")
conn.commit()
# Post-checkpoint: VACUUM itself writes via the WAL; flush again
# so the shrinkage is reflected on the main DB file right now.
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except sqlite3.OperationalError as e:
return {
"ok": False,
"op": "vacuum",
"duration_ms": int((time.monotonic() - t0) * 1000),
"error": str(e),
}
size_after = _file_size(conn)
return {
"ok": True,
"op": "vacuum",
"duration_ms": int((time.monotonic() - t0) * 1000),
"bytes_reclaimed": max(0, size_before - size_after),
}
def integrity_check(conn: sqlite3.Connection) -> dict[str, Any]:
"""Run PRAGMA integrity_check. ``ok=True`` iff output is exactly ['ok']."""
t0 = time.monotonic()
try:
rows = conn.execute("PRAGMA integrity_check").fetchall()
except sqlite3.OperationalError as e:
return {
"ok": False,
"op": "integrity",
"duration_ms": int((time.monotonic() - t0) * 1000),
"output": [],
"error": str(e),
}
output = [str(r[0]) for r in rows]
is_ok = output == ["ok"]
return {
"ok": is_ok,
"op": "integrity",
"duration_ms": int((time.monotonic() - t0) * 1000),
"output": output,
}
def analyze(conn: sqlite3.Connection) -> dict[str, Any]:
"""Run ANALYZE to refresh the query planner's statistics tables."""
t0 = time.monotonic()
try:
conn.execute("ANALYZE")
conn.commit()
except sqlite3.OperationalError as e:
return {
"ok": False,
"op": "analyze",
"duration_ms": int((time.monotonic() - t0) * 1000),
"error": str(e),
}
return {
"ok": True,
"op": "analyze",
"duration_ms": int((time.monotonic() - t0) * 1000),
}
def run_migrations(conn: sqlite3.Connection) -> dict[str, Any]:
"""Force-run pending migrations. Returns the list of versions applied
(empty when the DB was already up to date)."""
from engine.db.migrations import migrate # local import to keep top-level clean
before = current_version(conn)
t0 = time.monotonic()
try:
migrate(conn)
except Exception as e: # noqa: BLE001 — surface to UI as a structured failure
return {
"ok": False,
"op": "migrate",
"duration_ms": int((time.monotonic() - t0) * 1000),
"error": str(e),
"applied": [],
}
after = current_version(conn)
applied = list(range(before + 1, after + 1)) if after > before else []
return {
"ok": True,
"op": "migrate",
"duration_ms": int((time.monotonic() - t0) * 1000),
"applied": applied,
}
-165
View File
@@ -1,165 +0,0 @@
"""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()
+15 -55
View File
@@ -1,29 +1,19 @@
"""Versioned, forward-only migration runner.
Migration files live in ``engine/db/migrations_sql/<NNN>_<name>.<ext>``
where ``<ext>`` is ``.sql`` (executed verbatim) or ``.py`` (imported and
its top-level ``up(conn)`` callable invoked). Filenames must start with
a 3-digit zero-padded version number; the runner applies them in
numeric order.
Python migrations are useful when a transformation needs to read row
payloads, mutate JSON, or otherwise do work SQL alone can't express
cleanly. The ``.py`` file is imported via ``importlib.util`` so it
can sit next to the ``.sql`` files without polluting the package's
import graph.
SQL files live in ``engine/db/migrations_sql/<NNN>_<name>.sql``. Filenames
must start with a 3-digit zero-padded version number; the runner applies
them in numeric order.
"""
from __future__ import annotations
import importlib.util
import re
import sqlite3
from collections.abc import Callable
from pathlib import Path
from engine._utils import now_iso
_SQL_DIR = Path(__file__).parent / "migrations_sql"
_VERSION_RE = re.compile(r"^(\d{3})_.+\.(sql|py)$")
_VERSION_RE = re.compile(r"^(\d{3})_.+\.sql$")
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
@@ -54,49 +44,19 @@ def _discover() -> list[tuple[int, Path]]:
return pairs
def _load_py_migration(path: Path) -> Callable[[sqlite3.Connection], object]:
"""Import a `.py` migration file and return its `up` callable.
The module is loaded via ``importlib.util.spec_from_file_location``
so it doesn't have to be importable through a normal package path —
callers can drop a script into ``migrations_sql/`` without adding
it to ``__init__.py``. Return value of `up` is ignored.
"""
spec = importlib.util.spec_from_file_location(f"_mig_{path.stem}", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load migration module at {path}")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
up = getattr(mod, "up", None)
if not callable(up):
raise RuntimeError(
f"Migration {path.name} must export a top-level `up(conn)` function."
)
return up # type: ignore[no-any-return]
def migrate(conn: sqlite3.Connection) -> None:
"""Apply all pending migrations. Safe to call repeatedly."""
applied = current_version(conn)
for version, mig_file in _discover():
for version, sql_file in _discover():
if version <= applied:
continue
if mig_file.suffix == ".py":
up = _load_py_migration(mig_file)
with conn:
up(conn)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
else:
sql = mig_file.read_text(encoding="utf-8")
with conn:
for stmt in sql.split(";"):
stmt = stmt.strip()
if stmt:
conn.execute(stmt)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
sql = sql_file.read_text(encoding="utf-8")
with conn:
for stmt in sql.split(";"):
stmt = stmt.strip()
if stmt:
conn.execute(stmt)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
@@ -1,28 +0,0 @@
-- 003_add_uuid_column.sql
--
-- Adds an explicit, indexed `uuid` column to the `modules` table.
-- Backfills existing rows by extracting the trailing 8 hex chars of the
-- generated id (`<prefix>_<slug>_<token_hex(4)>` per ModuleRepository._gen_id).
--
-- After this migration, server code that needs canonical UUID lookup
-- (engine catalog, embed-bundle, drift hashes) does indexed equality
-- queries against `uuid` instead of `LIKE '%_<8hex>'` string scans.
--
-- Partial-apply note: SQLite's Python binding implicitly commits DDL
-- before the surrounding `with conn:` block can roll back. If the final
-- CREATE UNIQUE INDEX fails because two pre-existing rows share the same
-- trailing 8-hex chars (collision probability ~10^-6 for libraries with
-- <1000 modules), the column is added but the migrations table is not
-- bumped, and a retry would fail with `duplicate column name: uuid`.
-- Recovery is manual: rename one of the colliding ids in the modules
-- table, then re-run migrate(). This is acceptable for a forward-only
-- one-shot migration on a per-user library DB. Collisions in practice
-- are vanishingly rare and recoverable.
ALTER TABLE modules ADD COLUMN uuid TEXT;
UPDATE modules
SET uuid = substr(id, -8)
WHERE uuid IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_modules_uuid ON modules(uuid);
@@ -1,152 +0,0 @@
"""Migration 004 — unify `modules.id` with the 8-hex `uuid`.
Pre-state: ``modules.id`` is shaped ``<prefix>_<slug>_<8hex>``
(e.g. ``wc_outfit_a1b2c3d4``). Migration 003 added a separate
indexed ``uuid`` column carrying just the trailing 8 hex chars.
Two coexisting identifiers turned into a leak-prone abstraction —
the SPA picker emitted the slug form, the embed-bundle endpoint
looked rows up by uuid, and they never matched, surfacing as
silent ``missing_target`` walker reports for every pick.
Post-state: ``modules.id`` IS the 8-hex uuid. There is no second
identifier. The ``uuid`` column is dropped (SQLite ≥3.35 supports
``ALTER TABLE … DROP COLUMN``; if the runtime sqlite is older we
leave the column orphaned — code paths only read ``id``).
Cross-refs inside payload JSON also need rewriting so they point at
the new short ids:
- ``pipeline.steps[].module_id``
- ``constraint.source_wildcard_id`` / ``target_wildcard_id``
- ``derivation`` rule fields if any reference modules by id
(current schema doesn't, but we scan defensively)
Wildcard option values (``@{8hex}`` ref tokens) are already short by
construction — the tokenizer regex never matched the slug form.
Same for combine templates. No rewrite needed there.
Idempotent: rerunning is a no-op once ``id == uuid`` for every row
(mapping table is empty → every UPDATE sets a column to its current
value).
"""
from __future__ import annotations
import json
import sqlite3
from typing import Any
def _rewrite_payload(
payload: dict[str, Any],
*,
mod_type: str,
id_to_uuid: dict[str, str],
) -> dict[str, Any]:
"""Return a new payload with cross-refs rewritten to short uuids.
The mapping is keyed by the OLD slugged id; values not present in
the mapping (already-short refs, dangling refs to deleted modules)
pass through unchanged.
"""
if mod_type == "pipeline":
steps = payload.get("steps")
if isinstance(steps, list):
new_steps: list[Any] = []
for step in steps:
if isinstance(step, dict) and isinstance(step.get("module_id"), str):
old = step["module_id"]
new_steps.append({**step, "module_id": id_to_uuid.get(old, old)})
else:
new_steps.append(step)
payload = {**payload, "steps": new_steps}
elif mod_type == "constraint":
out = dict(payload)
for key in ("source_wildcard_id", "target_wildcard_id"):
v = out.get(key)
if isinstance(v, str):
out[key] = id_to_uuid.get(v, v)
payload = out
return payload
def _has_uuid_column(conn: sqlite3.Connection) -> bool:
cols = {r["name"] for r in conn.execute("PRAGMA table_info(modules);")}
return "uuid" in cols
def _has_uuid_index(conn: sqlite3.Connection) -> bool:
row = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type='index' AND name='idx_modules_uuid';"
).fetchone()
return row is not None
def up(conn: sqlite3.Connection) -> None:
# Need row-as-dict access for column-name lookups even if the
# caller's connection wasn't configured with `Row` factory.
prev_factory = conn.row_factory
conn.row_factory = sqlite3.Row
try:
# Build the (old_id → uuid) mapping. After 003 every row has
# a uuid column populated. Skip rows where id already equals
# uuid so reruns are no-ops.
if not _has_uuid_column(conn):
# 003 not yet applied (shouldn't happen — runner enforces
# ordering — but be defensive).
return
rows = conn.execute(
"SELECT id, uuid, type, payload FROM modules;"
).fetchall()
id_to_uuid: dict[str, str] = {}
for row in rows:
old_id = row["id"]
uuid = row["uuid"]
if old_id != uuid and isinstance(uuid, str) and uuid:
id_to_uuid[old_id] = uuid
# Rewrite payload cross-refs first — references must point at
# the new short ids by the time we update the id column itself.
for row in rows:
mod_type = row["type"]
try:
payload = json.loads(row["payload"])
except (ValueError, TypeError):
continue
if not isinstance(payload, dict):
continue
new_payload = _rewrite_payload(
payload, mod_type=mod_type, id_to_uuid=id_to_uuid,
)
if new_payload is not payload:
conn.execute(
"UPDATE modules SET payload = ? WHERE id = ?;",
(json.dumps(new_payload), row["id"]),
)
# Now rewrite the id column itself. Only touches rows where
# id != uuid; rerunning is a no-op.
if id_to_uuid:
conn.executemany(
"UPDATE modules SET id = ? WHERE id = ?;",
[(uuid, old) for old, uuid in id_to_uuid.items()],
)
# Tear down the now-redundant uuid index + column. SQLite has
# supported ALTER TABLE … DROP COLUMN since 3.35 (2021-03).
# If the running sqlite is older the DROP raises — catch and
# leave the column orphaned. Code paths only read id from
# this point forward.
if _has_uuid_index(conn):
conn.execute("DROP INDEX idx_modules_uuid;")
if _has_uuid_column(conn):
try:
conn.execute("ALTER TABLE modules DROP COLUMN uuid;")
except sqlite3.OperationalError:
# Older sqlite — leave column. Fine: nothing reads it.
pass
finally:
conn.row_factory = prev_factory
-43
View File
@@ -1,43 +0,0 @@
-- Bundles — library-tracked groupings of modules.
--
-- A bundle is a deep-cloned snapshot package: when the user "Save as
-- bundle", the full payloads + instance overrides of every contained
-- module are serialized into this table's `children` column as a JSON
-- array. On insert into a Context, the frontend deserializes that
-- JSON, regenerates uuids, and splices into modules[].
--
-- Bundles are intentionally frozen — library updates to the original
-- wildcards / combines / etc do NOT propagate into existing bundles.
-- Drift detection happens at the child instance level via existing
-- per-kind logic (each spliced child carries its own library_id +
-- payload_hash).
--
-- `color` is the user-picked hex for the frame around the bundle's
-- range in ContextWidget. When NULL/empty, the UI falls back to the
-- default `#46566B` token.
--
-- `payload_hash` is the content hash of `children` (and any other
-- fields that affect runtime behavior). Each inserted instance
-- captures this at insert time as `inserted_at_hash` so the UI can
-- show an informational "library updated" hint when the live entry's
-- hash diverges. This is NOT a conflict — bundles are frozen by
-- design.
CREATE TABLE IF NOT EXISTS bundles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color TEXT,
category_id TEXT REFERENCES module_categories(id) ON DELETE SET NULL,
tags TEXT NOT NULL DEFAULT '[]',
is_favorite INTEGER NOT NULL DEFAULT 0,
children TEXT NOT NULL DEFAULT '[]',
payload_hash TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_bundles_name ON bundles(name COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_bundles_category ON bundles(category_id);
CREATE INDEX IF NOT EXISTS idx_bundles_updated_at ON bundles(updated_at DESC);
@@ -1,7 +0,0 @@
-- Add snapshot_fingerprint to the modules table.
-- Computed on next write via ModuleRepository.create() / update().
-- Existing rows from before this migration keep NULL until next write —
-- Task 8 (collision detection) treats NULL as "no collision" so legacy
-- rows never block imports.
ALTER TABLE modules ADD COLUMN snapshot_fingerprint TEXT;
@@ -1,23 +0,0 @@
-- Import undo metadata. Stores the snapshot bundle needed to reverse a
-- single `commit_import` call: which ids the commit inserted (so undo
-- deletes them) and the full pre-replace row for every id the commit
-- overwrote (so undo restores them).
--
-- One row per import. The row id is the `undo_id` returned by
-- `commit_import` and consumed by `undo_import`. There is no GC policy
-- in this migration — the wp_api layer (Task 14) decides when to evict
-- old undo entries (e.g. "keep last N" or "expire after T").
--
-- All three JSON columns are opaque to SQLite — the importer module
-- defines + reads the shapes. See `engine/importer.py` module docstring
-- for the schema.
CREATE TABLE IF NOT EXISTS import_undo (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
imported_records TEXT NOT NULL, -- JSON list[{kind, id}]
replaced_snapshots TEXT NOT NULL, -- JSON dict[id, {kind, row}]
rename_map TEXT NOT NULL -- JSON dict[old_id, new_id]
);
CREATE INDEX IF NOT EXISTS idx_import_undo_created_at ON import_undo(created_at DESC);
@@ -1,44 +0,0 @@
"""Backfill ``modules.snapshot_fingerprint`` for any row left NULL by
migration 006.
Migration 006 added the column but deferred populating it until the next
write (``ModuleRepository.update``/``create``). Rows that haven't been
touched since the upgrade therefore carry NULL — and the import-export
collision detector flags those as ``exists-unknown`` (amber "EXISTING"
badge), which is correct but misleading: a freshly-exported-and-imported
NULL row looks "drifted" when the content is actually byte-identical.
Running the backfill eagerly here turns every NULL into the same djb2
hash ``create`` would produce, so identical content reads as
``silent-skip`` instead of ``exists-unknown``. Pure read+UPDATE; no
schema change, no version bump on the row itself (``version`` stays
where the user left it).
"""
from __future__ import annotations
import json
import sqlite3
from engine._fingerprint import module_fingerprint
from engine.modules.snapshot import payload_hash
def up(conn: sqlite3.Connection) -> None:
rows = conn.execute(
"SELECT id, type, name, description, tags, payload "
"FROM modules WHERE snapshot_fingerprint IS NULL;"
).fetchall()
for row in rows:
payload = json.loads(row["payload"])
tags = json.loads(row["tags"])
fp = module_fingerprint({
"type": row["type"],
"name": row["name"],
"description": row["description"],
"tags": tags,
"payload_hash": payload_hash(payload),
})
conn.execute(
"UPDATE modules SET snapshot_fingerprint = ? WHERE id = ?;",
(fp, row["id"]),
)
@@ -1,15 +0,0 @@
-- Cascade-edit undo. One row per cascade-apply transaction.
-- snapshot_before / snapshot_after are JSON arrays of entity rows
-- captured pre- and post-mutation, used to atomically restore on undo.
CREATE TABLE IF NOT EXISTS cascade_undo (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
target_kind TEXT NOT NULL,
target_id TEXT NOT NULL,
action TEXT NOT NULL,
snapshot_before TEXT NOT NULL,
snapshot_after TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cascade_undo_created_at ON cascade_undo(created_at DESC);
-161
View File
@@ -1,161 +0,0 @@
"""Backfill stable per-option ids and migrate constraint exception strings.
After this migration:
* Every wildcard's payload.options[i] has a unique 8-hex `id` field.
* Every constraint's payload.exceptions[i] references options by
`source_id` / `target_id` instead of value strings.
* Exceptions whose source or target string can no longer be resolved
against the current option list are moved to `broken_exceptions[]`
with a `reason` field describing the failure mode (source_value not
found, target_value not found, source_wildcard missing,
target_wildcard missing, ambiguous_match).
The migration is forward-only. The runner ensures it only applies
once, but the implementation is defensive: existing option ids are
preserved, and exceptions already in the new shape are left untouched.
"""
from __future__ import annotations
import json
import secrets
import sqlite3
from typing import Any
def _load_modules(conn: sqlite3.Connection) -> list[dict[str, Any]]:
cur = conn.cursor()
cur.execute(
"SELECT id, type, payload FROM modules WHERE type IN ('wildcard', 'constraint');"
)
rows = cur.fetchall()
return [
{"id": r["id"], "type": r["type"], "payload": json.loads(r["payload"])}
for r in rows
]
def _backfill_option_ids(payload: dict[str, Any]) -> tuple[bool, dict[str, Any]]:
changed = False
new_opts = []
for opt in payload.get("options") or []:
new_opt = dict(opt)
if not isinstance(new_opt.get("id"), str) or not new_opt["id"]:
new_opt["id"] = secrets.token_hex(4)
changed = True
new_opts.append(new_opt)
if changed:
payload = dict(payload)
payload["options"] = new_opts
return changed, payload
def _option_value_index(wc: dict[str, Any]) -> tuple[dict[str, str], set[str]]:
"""Return (value_to_id_map, ambiguous_values_set) for one wildcard."""
value_to_id: dict[str, str] = {}
counts: dict[str, int] = {}
for opt in wc["payload"].get("options") or []:
v = opt.get("value")
oid = opt.get("id")
if not isinstance(v, str) or not isinstance(oid, str):
continue
counts[v] = counts.get(v, 0) + 1
value_to_id[v] = oid
ambiguous = {v for v, count in counts.items() if count > 1}
return value_to_id, ambiguous
def _resolve_axis(
value: Any,
wildcard_id: str | None,
wildcards_by_id: dict[str, dict[str, Any]],
axis_label: str,
) -> tuple[str | None, str | None]:
"""Return (option_id, reason) for a single exception axis.
On success: (id, None). On failure: (None, reason_string).
"""
if wildcard_id is None:
return None, f"{axis_label}_wildcard_id missing"
if wildcard_id not in wildcards_by_id:
return None, f"{axis_label}_wildcard missing"
if not isinstance(value, str):
return None, f"{axis_label}_value not a string"
lookup, ambiguous = _option_value_index(wildcards_by_id[wildcard_id])
if value in ambiguous:
return None, f"ambiguous_match: {axis_label}"
if value not in lookup:
return None, f"{axis_label}_value not found: {value!r}"
return lookup[value], None
def _migrate_constraint_exceptions(
payload: dict[str, Any],
wildcards_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
src_id = payload.get("source_wildcard_id")
tgt_id = payload.get("target_wildcard_id")
new_exceptions: list[dict[str, Any]] = []
broken: list[dict[str, Any]] = list(payload.get("broken_exceptions") or [])
for ex in payload.get("exceptions") or []:
if "source_id" in ex and "target_id" in ex:
new_exceptions.append(ex)
continue
sid, sreason = _resolve_axis(ex.get("source"), src_id, wildcards_by_id, "source")
if sreason:
broken.append({**ex, "reason": sreason})
continue
tid, treason = _resolve_axis(ex.get("target"), tgt_id, wildcards_by_id, "target")
if treason:
broken.append({**ex, "reason": treason})
continue
# Keep `source` / `target` value strings alongside new ids.
# The runtime constraint resolver still keys instance-override
# lookups by encoded (source_value, target_value) pairs (see
# `engine/modules/constraint_handler.py`'s exception loop), so
# dropping the legacy fields would break Tier-2 overrides for
# any constraint touched by this migration. Cascade indexing
# uses `source_id`/`target_id`; runtime stays on value strings.
migrated = dict(ex)
migrated["source_id"] = sid
migrated["target_id"] = tid
new_exceptions.append(migrated)
payload = dict(payload)
payload["exceptions"] = new_exceptions
if broken:
payload["broken_exceptions"] = broken
return payload
def up(conn: sqlite3.Connection) -> None:
mods = _load_modules(conn)
wildcards_by_id: dict[str, dict[str, Any]] = {}
cur = conn.cursor()
# Pass 1: backfill option ids on every wildcard.
for m in mods:
if m["type"] != "wildcard":
continue
changed, new_payload = _backfill_option_ids(m["payload"])
m["payload"] = new_payload
if changed:
cur.execute(
"UPDATE modules SET payload = ? WHERE id = ?;",
(json.dumps(new_payload), m["id"]),
)
wildcards_by_id[m["id"]] = m
# Pass 2: migrate constraint exceptions.
for m in mods:
if m["type"] != "constraint":
continue
migrated = _migrate_constraint_exceptions(m["payload"], wildcards_by_id)
if migrated != m["payload"]:
cur.execute(
"UPDATE modules SET payload = ? WHERE id = ?;",
(json.dumps(migrated), m["id"]),
)
@@ -1,160 +0,0 @@
"""Backfill the `#name` segment into every `@{uuid}` / `@{uuid:subcat}` ref.
The ref grammar gained an optional `#name` cache so a broken ref can
still tell the user which wildcard it pointed at. Existing rows store
bare-uuid refs; this migration walks every surface that holds ref
strings and rewrites them with the current library name.
Surfaces walked:
* `wildcard.payload.options[].value` — other wildcards' option text.
* `derivation.payload.rules[].branches[].actions[].*` — derivation
rules' string action fields (the only non-wildcard surface where
`@{uuid}` refs resolve at runtime).
* `bundle.children[].payload.options[].value` — frozen module
snapshots inside bundle library entries. Same shape as live
wildcards, walked separately so the snapshot survives in lockstep.
Refs whose target uuid doesn't resolve to a live wildcard are left
unchanged (bare-uuid form). On the next save the cascade-rename fixer
will either fill them in or the user re-attaches the missing entry.
The migration is forward-only and idempotent: refs that already
carry a `#name` segment (e.g. authored after this code lands) pass
through unchanged. payload_hash + fingerprint columns are recomputed
in-place by the repository layer the next time each row is updated;
this script only touches the JSON blobs so the workflow JSON the
engine reads is consistent with the new tokenizer.
"""
from __future__ import annotations
import json
import re
import sqlite3
from typing import Any
# Same shape as `engine/syntax/tokenize.py:_REF_RE` minus the anchors.
# Groups: 1=uuid, 2=optional existing name, 3=optional subcat list.
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}")
def _build_wildcard_name_map(conn: sqlite3.Connection) -> dict[str, str]:
"""{uuid → current display name} for every wildcard in the library."""
cur = conn.cursor()
cur.execute("SELECT id, name FROM modules WHERE type = 'wildcard';")
return {row["id"]: row["name"] for row in cur.fetchall()}
def _rewrite_ref_string(s: str, name_map: dict[str, str]) -> str:
"""Apply the name cache to every ref in *s*. Returns the input
unchanged when no rewrite was needed (preserves identity so the
caller can cheaply detect changes)."""
def repl(m: re.Match[str]) -> str:
uuid = m.group(1)
old_name = m.group(2)
subcat = m.group(3)
live_name = name_map.get(uuid)
# Skip refs whose target wildcard is gone — bare-uuid form is
# honest signal that the ref is broken.
if live_name is None:
return m.group(0)
# Idempotency: if the existing name already matches live, no-op.
if old_name == live_name:
return m.group(0)
sub_seg = f":{subcat}" if subcat is not None else ""
return f"@{{{uuid}#{live_name}{sub_seg}}}"
return _REF_RE.sub(repl, s)
def _walk_wildcard_payload(payload: dict[str, Any], name_map: dict[str, str]) -> bool:
"""In-place mutate option values. Returns True when anything changed."""
changed = False
for opt in payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_ref_string(v, name_map)
if new_v != v:
opt["value"] = new_v
changed = True
return changed
def _walk_derivation_payload(payload: dict[str, Any], name_map: dict[str, str]) -> bool:
"""In-place mutate string fields on rule actions."""
changed = False
for rule in payload.get("rules") or []:
for branch in rule.get("branches") or []:
for action in branch.get("actions") or []:
if not isinstance(action, dict):
continue
for k, v in list(action.items()):
if isinstance(v, str):
new_v = _rewrite_ref_string(v, name_map)
if new_v != v:
action[k] = new_v
changed = True
return changed
def _walk_bundle_children(
children: list[Any], name_map: dict[str, str],
) -> bool:
"""In-place mutate children list. Mirrors the engine fixer's bundle
walk — module snapshots get their option values rewritten."""
changed = False
for ch in children:
if not isinstance(ch, dict):
continue
if ch.get("type") != "wildcard":
continue
payload = ch.get("payload")
if isinstance(payload, dict):
if _walk_wildcard_payload(payload, name_map):
changed = True
return changed
def up(conn: sqlite3.Connection) -> None:
name_map = _build_wildcard_name_map(conn)
if not name_map:
return # empty library — nothing to rewrite
# Modules — wildcards + derivations carry ref strings.
cur = conn.cursor()
cur.execute(
"SELECT id, type, payload FROM modules "
"WHERE type IN ('wildcard', 'derivation');"
)
rows = cur.fetchall()
for row in rows:
try:
payload = json.loads(row["payload"])
except (TypeError, json.JSONDecodeError):
continue
if row["type"] == "wildcard":
changed = _walk_wildcard_payload(payload, name_map)
else:
changed = _walk_derivation_payload(payload, name_map)
if changed:
conn.execute(
"UPDATE modules SET payload = ? WHERE id = ?;",
(json.dumps(payload), row["id"]),
)
# Bundles — children snapshots may carry ref strings inside frozen
# module copies.
cur.execute("SELECT id, children FROM bundles;")
bundle_rows = cur.fetchall()
for row in bundle_rows:
try:
children = json.loads(row["children"]) if row["children"] else []
except (TypeError, json.JSONDecodeError):
continue
if not isinstance(children, list):
continue
if _walk_bundle_children(children, name_map):
conn.execute(
"UPDATE bundles SET children = ? WHERE id = ?;",
(json.dumps(children), row["id"]),
)
conn.commit()
@@ -1,27 +0,0 @@
-- Prompt templates — reusable PromptAssembler template strings.
--
-- A template is just the assembler's `template` STRING ($var tokens).
-- Loading copies the string into a node's template widget with NO stored
-- back-reference, so (unlike bundles) templates can't drift — hence no
-- payload_hash / version columns. Referenced var names are derived from
-- the string on demand, never stored.
--
-- `category_id` FK mirrors bundles (ON DELETE SET NULL) — the API catches
-- sqlite3.IntegrityError on a bad category_id and returns 400. NOTE: the
-- migration runner splits on the semicolon char, so comments must avoid it.
CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category_id TEXT REFERENCES module_categories(id) ON DELETE SET NULL,
tags TEXT NOT NULL DEFAULT '[]',
is_favorite INTEGER NOT NULL DEFAULT 0,
template_string TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_templates_name ON templates(name COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_templates_category ON templates(category_id);
CREATE INDEX IF NOT EXISTS idx_templates_updated_at ON templates(updated_at DESC);
@@ -1,49 +0,0 @@
"""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);"
)
@@ -1,86 +0,0 @@
"""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={})
@@ -1,44 +0,0 @@
"""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';"
)
@@ -1,41 +0,0 @@
"""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
@@ -1,73 +0,0 @@
"""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
-55
View File
@@ -1,55 +0,0 @@
"""Execute deferred DB file moves at plugin load.
The SPA Settings UI lets users change the DB location. The actual file
operation runs here, on next ComfyUI start, BEFORE any DB connection
opens. Errors are logged and never crash the host.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from engine.db.config import clear_pending_move, load
logger = logging.getLogger(__name__)
def execute_pending_move() -> None:
"""If the sidecar has a pending_move, run it and clear the entry.
Idempotent if the move already happened (source missing, dest present).
Failures (source missing without dest, permission errors) are logged;
sidecar is left intact so the UI can show the pending state on next
page load.
"""
cfg = load()
pm = cfg.get("pending_move")
if not pm:
return
src = Path(pm["from"])
dst = Path(pm["to"])
mode = pm["mode"]
# Resume: previous run completed the move but failed to clear sidecar.
if not src.exists() and dst.exists():
logger.info("wp_db_pending_move: already-done (src missing, dst present), clearing")
clear_pending_move()
return
if not src.exists():
logger.error("wp_db_pending_move: source %s missing, skipping", src)
return
try:
dst.parent.mkdir(parents=True, exist_ok=True)
if mode == "copy":
shutil.copy2(src, dst)
else: # move
shutil.move(str(src), str(dst))
except OSError as e:
logger.exception("wp_db_pending_move: %s failed: %s", mode, e)
return
clear_pending_move()
logger.info("wp_db_pending_move: %s %s -> %s OK", mode, src, dst)
+39 -726
View File
@@ -12,43 +12,18 @@ import secrets
import sqlite3
from typing import Any
from engine._fingerprint import module_fingerprint
from engine._utils import now_iso as _now
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
# to collapse the two-identifier model down to one canonical form.
_ID_HEX_LEN = 8
# Categories keep slug-based ids (`subjects`, `style`, …) — human
# readable and never need to be embedded in a tokenizer regex, so
# the unification doesn't apply to them.
_CATEGORY_SLUG_RE = re.compile(r"[^a-z0-9]+")
def _category_slug(name: str) -> str:
s = _CATEGORY_SLUG_RE.sub("_", name.lower()).strip("_")
return s[:24] or "module"
_TYPE_PREFIX = {
"wildcard": "wc",
"fixed_values": "fv",
"combine": "cb",
"derivation": "dr",
"constraint": "ct",
"pipeline": "pl",
}
_VALID_TYPES: frozenset[str] = frozenset(_TYPE_PREFIX.keys())
_SLUG_RE = re.compile(r"[^a-z0-9]+")
class _Unset:
@@ -68,26 +43,12 @@ class ModuleNotFound(LookupError):
"""Raised when a requested module id does not exist."""
def _slug(name: str) -> str:
s = _SLUG_RE.sub("_", name.lower()).strip("_")
return s[:24] or "module"
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"],
@@ -96,15 +57,10 @@ def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
"category_id": row["category_id"],
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"payload": payload,
"payload_hash": payload_hash(payload),
"snapshot_fingerprint": row["snapshot_fingerprint"], # NULL for pre-006 rows
"payload": json.loads(row["payload"]),
"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,
}
@@ -112,43 +68,9 @@ class ModuleRepository:
def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
def _gen_id(self) -> str:
"""Generate a fresh 8-hex module id.
Post migration 004 the id IS the only identifier — we no
longer carry a separate slugged form. Collisions are rejected
by the PRIMARY KEY constraint on `modules.id`; `secrets.token_hex(4)`
gives 4 bytes (~4.3B values), so the birthday-paradox 50% mark
sits around 65k modules — well past the expected single-user
library size.
"""
return secrets.token_hex(_ID_HEX_LEN // 2)
@staticmethod
def _backfill_option_ids(payload: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of *payload* with each option assigned a stable
8-hex `id` if missing. Wildcard payload only — caller checks type.
Option ids must be unique per-wildcard (validated in
WildcardHandler.validate_payload). Existing ids are preserved so
constraint exception refs stay intact across edits.
Payloads without an ``options`` key are returned unchanged. Adding
an empty ``options: []`` would shift the payload hash for legacy
rows that never declared an options field — see migration 008
fingerprint parity test.
"""
if "options" not in payload:
return payload
new_payload = dict(payload)
new_opts: list[dict[str, Any]] = []
for opt in new_payload.get("options") or []:
new_opt = dict(opt)
if not isinstance(new_opt.get("id"), str) or not new_opt["id"]:
new_opt["id"] = secrets.token_hex(_ID_HEX_LEN // 2)
new_opts.append(new_opt)
new_payload["options"] = new_opts
return new_payload
def _gen_id(self, type: str, name: str) -> str:
prefix = _TYPE_PREFIX.get(type, "mod")
return f"{prefix}_{_slug(name)}_{secrets.token_hex(4)}"
def create(
self,
@@ -160,54 +82,23 @@ class ModuleRepository:
tags: list[str],
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).
Validates the supplied id is 8-hex to prevent the workflow
from injecting arbitrary primary-key shapes.
"""
if type not in _VALID_TYPES:
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:
mid = self._gen_id()
else:
if not isinstance(id, str) or len(id) != _ID_HEX_LEN or not all(
c in "0123456789abcdef" for c in id
):
raise ValueError(
f"id must be a {_ID_HEX_LEN}-char lowercase-hex string"
)
mid = id
fp = module_fingerprint({
"type": type,
"name": name,
"description": description,
"tags": tags,
"payload_hash": payload_hash(payload),
})
mid = self._gen_id(type, name)
now = _now()
with self._conn:
self._conn.execute(
"INSERT INTO modules("
"id, type, name, description, category_id, tags, "
"is_favorite, payload, snapshot_fingerprint, version, "
"created_at, updated_at, content_rating"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?);",
"is_favorite, payload, version, created_at, updated_at"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
(
mid, type, name, description, category_id,
json.dumps(tags), int(is_favorite),
json.dumps(payload), fp, now, now, content_rating,
json.dumps(payload), now, now,
),
)
return self.get(mid)
@@ -220,41 +111,6 @@ class ModuleRepository:
raise ModuleNotFound(module_id)
return _row_to_module(row)
# Back-compat aliases — `id == uuid` after migration 004, so the
# uuid-named lookups are now thin wrappers around `get` / a bulk
# `get` variant. Kept so external callers (test runner, embed-
# bundle endpoint, future drift-hash endpoint) don't need code
# changes; new code should prefer `get` / the new `get_many`.
def get_by_uuid(self, uuid: str) -> dict[str, Any]:
return self.get(uuid)
def get_many(self, ids: list[str]) -> list[dict[str, Any]]:
"""Bulk lookup. Dedups input and returns rows in the same order
as the (deduped) input. Silently skips ids that aren't present —
callers needing explicit miss-detection should compare returned
ids against the input set.
Note on input size: callers (test-runner walker, embed-bundle)
pass at most a few dozen ids in practice, so we don't chunk.
SQLite's `SQLITE_LIMIT_VARIABLE_NUMBER` defaults to 32766 on
modern builds (3.32+) and 999 on older ones — well above any
expected upper bound."""
if not ids:
return []
unique = list(dict.fromkeys(ids)) # dedup, preserve input order
placeholders = ",".join("?" for _ in unique)
cur = self._conn.execute(
f"SELECT * FROM modules WHERE id IN ({placeholders});",
unique,
)
# SQLite makes no order guarantee for IN-predicate results, so
# re-order against the input list to honor the docstring.
by_id = {r["id"]: r for r in cur.fetchall()}
return [_row_to_module(by_id[u]) for u in unique if u in by_id]
def get_by_uuids(self, uuids: list[str]) -> list[dict[str, Any]]:
return self.get_many(uuids)
def update(
self,
module_id: str,
@@ -265,18 +121,8 @@ 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.
if (
existing["type"] == "wildcard"
and not isinstance(payload, _Unset)
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": (
@@ -290,84 +136,39 @@ 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({
"type": existing["type"],
"name": new["name"],
"description": new["description"],
"tags": new["tags"],
"payload_hash": new_payload_hash,
})
now = _now()
with self._conn:
self._conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
"version = version + 1, updated_at = ?, content_rating = ? "
"is_favorite = ?, payload = ?, version = version + 1, "
"updated_at = ? "
"WHERE id = ?;",
(
new["name"], new["description"], new["category_id"],
json.dumps(new["tags"]), int(new["is_favorite"]),
json.dumps(new["payload"]), new_fp, now,
new["content_rating"], module_id,
json.dumps(new["payload"]), now, 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,))
if cur.rowcount == 0:
raise ModuleNotFound(module_id)
def _build_filter_clause(
def list(
self,
*,
type: str | None,
category_id: str | None,
query: str | None,
favorites_only: bool,
) -> tuple[str, list[Any]]:
"""Build the WHERE-clause fragment + bound params shared by list/count.
Extracted so list() (paginated rows) and count() (total ignoring
pagination) apply the same filters bit-for-bit. Mismatched filters
between the two paths is the kind of bug that silently makes
Dashboard counts wrong — the helper closes that loop."""
type: str | None = None,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
limit: int | None = None,
offset: int = 0,
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if type is not None:
@@ -386,24 +187,10 @@ class ModuleRepository:
params.append(f"%{escaped}%")
if favorites_only:
clauses.append("is_favorite = 1")
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
return where, params
def list(
self,
*,
type: str | None = None,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
limit: int | None = None,
offset: int = 0,
) -> list[dict[str, Any]]:
where, params = self._build_filter_clause(
type=type, category_id=category_id, query=query,
favorites_only=favorites_only,
)
sql = "SELECT * FROM modules" + where + " ORDER BY updated_at DESC, id DESC"
sql = "SELECT * FROM modules"
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " ORDER BY updated_at DESC, id DESC"
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
@@ -414,26 +201,6 @@ class ModuleRepository:
rows = self._conn.execute(sql, params).fetchall()
return [_row_to_module(r) for r in rows]
def count(
self,
*,
type: str | None = None,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
) -> int:
"""Total rows matching the same filters as list(), ignoring limit
and offset. Used by the API to populate `ModuleListResponse.total`
accurately even when the client passed `limit=1` for cheap polling
(Dashboard count cards do exactly this)."""
where, params = self._build_filter_clause(
type=type, category_id=category_id, query=query,
favorites_only=favorites_only,
)
sql = "SELECT COUNT(*) AS n FROM modules" + where
row = self._conn.execute(sql, params).fetchone()
return int(row["n"]) if row is not None else 0
class CategoryNotFound(LookupError):
"""Raised when a requested category id does not exist."""
@@ -461,7 +228,7 @@ class CategoryRepository:
icon: str | None,
sort_order: int = 0,
) -> dict[str, Any]:
cid = _category_slug(name)
cid = _slug(name)
if cid == "module":
# _slug returned its fallback because `name` had no alphanumeric chars.
raise ValueError(
@@ -540,457 +307,3 @@ class CategoryRepository:
"ORDER BY sort_order ASC, name COLLATE NOCASE ASC;"
).fetchall()
return [_row_to_category(r) for r in rows]
class BundleNotFound(LookupError):
"""Raised when a requested bundle id does not exist."""
def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
"""Materialize a bundles row into the SPA-facing dict shape.
`children` is stored as a JSON array of full module snapshots
(deep-cloned at save time). The repository deserializes it on
read so callers don't have to. `payload_hash` lives in the row
rather than being recomputed on demand because bundles are
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"],
"description": row["description"],
"color": row["color"],
"category_id": row["category_id"],
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"children": json.loads(row["children"]),
"payload_hash": row["payload_hash"],
"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,
}
class BundleRepository:
"""Library-side store for bundle entries. Mirrors `ModuleRepository`
shape (list / get / create / update / delete / count + favorite
toggle) so the SPA Library page can re-use its existing fetch
patterns.
Bundles intentionally do NOT support the embed-bundle / snapshot
endpoints modules expose — bundles are themselves the snapshot
package, so re-snapshotting is a no-op."""
def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
def _gen_id(self) -> str:
"""Generate a fresh 8-hex bundle id. Same scheme as modules so
`@{<id>}` ref syntax could theoretically reference a bundle in
the future (not currently used — bundles aren't `$variable`
producers themselves; their children are). Birthday-paradox
50% mark at ~65k bundles, well past expected library size."""
return secrets.token_hex(_ID_HEX_LEN // 2)
def create(
self,
*,
name: str,
description: str = "",
color: str | None = None,
category_id: str | None = None,
tags: list[str] | None = None,
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:
if not isinstance(id, str) or len(id) != _ID_HEX_LEN or not all(
c in "0123456789abcdef" for c in id
):
raise ValueError(
f"id must be a {_ID_HEX_LEN}-char lowercase-hex string"
)
bid = id
children_blob = list(children) if children else []
# `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})
now = _now()
with self._conn:
self._conn.execute(
"INSERT INTO bundles("
"id, name, description, color, category_id, tags, "
"is_favorite, children, payload_hash, version, "
"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, content_rating,
),
)
return self.get(bid)
def get(self, bundle_id: str) -> dict[str, Any]:
row = self._conn.execute(
"SELECT * FROM bundles WHERE id = ?;", (bundle_id,),
).fetchone()
if row is None:
raise BundleNotFound(bundle_id)
return _row_to_bundle(row)
def update(
self,
bundle_id: str,
*,
name: str | _Unset = _UNSET,
description: str | _Unset = _UNSET,
color: str | None | _Unset = _UNSET,
category_id: str | None | _Unset = _UNSET,
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": (
existing["description"] if isinstance(description, _Unset) else description
),
"color": existing["color"] if isinstance(color, _Unset) else color,
"category_id": (
existing["category_id"] if isinstance(category_id, _Unset) else category_id
),
"tags": existing["tags"] if isinstance(tags, _Unset) else tags,
"children": (
existing["children"] if isinstance(children, _Unset) else children
),
"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, 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)
else payload_hash({"children": new["children"]})
)
now = _now()
with self._conn:
self._conn.execute(
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, category_id = ?, "
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
"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,
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(
"DELETE FROM bundles WHERE id = ?;", (bundle_id,),
)
if cur.rowcount == 0:
raise BundleNotFound(bundle_id)
def _build_filter_clause(
self,
*,
category_id: str | None,
query: str | None,
favorites_only: bool,
) -> tuple[str, list[Any]]:
clauses: list[str] = []
params: list[Any] = []
if category_id is not None:
clauses.append("category_id = ?")
params.append(category_id)
if query:
escaped = (
query.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
params.append(f"%{escaped}%")
if favorites_only:
clauses.append("is_favorite = 1")
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
return where, params
def list(
self,
*,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
limit: int | None = None,
offset: int = 0,
) -> list[dict[str, Any]]:
where, params = self._build_filter_clause(
category_id=category_id, query=query,
favorites_only=favorites_only,
)
sql = "SELECT * FROM bundles" + where + " ORDER BY updated_at DESC, id DESC"
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
elif offset:
sql += " LIMIT -1 OFFSET ?"
params.append(offset)
rows = self._conn.execute(sql, params).fetchall()
return [_row_to_bundle(r) for r in rows]
def count(
self,
*,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
) -> int:
where, params = self._build_filter_clause(
category_id=category_id, query=query,
favorites_only=favorites_only,
)
sql = "SELECT COUNT(*) AS n FROM bundles" + where
row = self._conn.execute(sql, params).fetchone()
return int(row["n"]) if row is not None else 0
class TemplateNotFound(LookupError):
"""Raised when a requested template id does not exist."""
def _row_to_template(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"category_id": row["category_id"],
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"template_string": row["template_string"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
class TemplateRepository:
"""CRUD for reusable PromptAssembler template strings.
Mirrors BundleRepository but simpler: a template is a single
`template_string` plus library metadata. No children / color /
payload_hash / version (templates can't drift — loading copies the
string with no stored back-reference). Pure DB — engine-isolated.
"""
def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
def _gen_id(self) -> str:
return secrets.token_hex(_ID_HEX_LEN // 2)
def create(
self,
*,
name: str,
template_string: str = "",
description: str = "",
category_id: str | None = None,
tags: list[str] | None = None,
is_favorite: bool = False,
) -> dict[str, Any]:
tid = self._gen_id()
now = _now()
with self._conn:
self._conn.execute(
"INSERT INTO templates("
"id, name, description, category_id, tags, "
"is_favorite, template_string, created_at, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
tid, name, description, category_id,
json.dumps(tags or []), int(is_favorite),
template_string, now, now,
),
)
return self.get(tid)
def get(self, template_id: str) -> dict[str, Any]:
row = self._conn.execute(
"SELECT * FROM templates WHERE id = ?;", (template_id,),
).fetchone()
if row is None:
raise TemplateNotFound(template_id)
return _row_to_template(row)
def update(
self,
template_id: str,
*,
name: str | _Unset = _UNSET,
description: str | _Unset = _UNSET,
category_id: str | None | _Unset = _UNSET,
tags: list[str] | _Unset = _UNSET,
template_string: str | _Unset = _UNSET,
is_favorite: bool | _Unset = _UNSET,
) -> dict[str, Any]:
existing = self.get(template_id)
new = {
"name": existing["name"] if isinstance(name, _Unset) else name,
"description": (
existing["description"] if isinstance(description, _Unset) else description
),
"category_id": (
existing["category_id"] if isinstance(category_id, _Unset) else category_id
),
"tags": existing["tags"] if isinstance(tags, _Unset) else tags,
"template_string": (
existing["template_string"]
if isinstance(template_string, _Unset)
else template_string
),
"is_favorite": (
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
),
}
now = _now()
with self._conn:
self._conn.execute(
"UPDATE templates SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, template_string = ?, updated_at = ? "
"WHERE id = ?;",
(
new["name"], new["description"], new["category_id"],
json.dumps(new["tags"]), int(new["is_favorite"]),
new["template_string"], now, template_id,
),
)
return self.get(template_id)
def delete(self, template_id: str) -> None:
with self._conn:
cur = self._conn.execute(
"DELETE FROM templates WHERE id = ?;", (template_id,),
)
if cur.rowcount == 0:
raise TemplateNotFound(template_id)
def _build_filter_clause(
self,
*,
category_id: str | None,
query: str | None,
favorites_only: bool,
) -> tuple[str, list[Any]]:
clauses: list[str] = []
params: list[Any] = []
if category_id is not None:
clauses.append("category_id = ?")
params.append(category_id)
if query:
escaped = (
query.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
params.append(f"%{escaped}%")
if favorites_only:
clauses.append("is_favorite = 1")
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
return where, params
def list(
self,
*,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
limit: int | None = None,
offset: int = 0,
) -> list[dict[str, Any]]:
where, params = self._build_filter_clause(
category_id=category_id, query=query, favorites_only=favorites_only,
)
sql = "SELECT * FROM templates" + where + " ORDER BY updated_at DESC, id DESC"
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
elif offset:
sql += " LIMIT -1 OFFSET ?"
params.append(offset)
rows = self._conn.execute(sql, params).fetchall()
return [_row_to_template(r) for r in rows]
def count(
self,
*,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
) -> int:
where, params = self._build_filter_clause(
category_id=category_id, query=query, favorites_only=favorites_only,
)
row = self._conn.execute(
"SELECT COUNT(*) AS n FROM templates" + where, params,
).fetchone()
return int(row["n"]) if row is not None else 0
-166
View File
@@ -1,166 +0,0 @@
"""Export payload builder. Transitive ref walk + inner-bundle
auto-include + 8-bucket grouping.
Pure relative to DB — reads via repositories only, no writes.
Stateless: caller passes the requested UUID sets, exporter returns
the full payload including auto-resolved bundle dependencies.
Module rows are routed by type: a UUID passed in `wildcard_uuids` is
looked up and emitted into `wildcards[]` ONLY if the module's type is
'wildcard'. Mis-typed UUIDs are silently skipped (filter, not error)
so the picker UI's loose type tracking can't accidentally cross-pollinate
buckets.
Inner-bundle resolution: bundle ``children[]`` entries with
``type == "bundle"`` are stored as reference objects with ``id``,
``type``, ``name``, ``color`` fields (not full snapshots). The transitive
walk follows these references until a fixed point, then includes every
reachable bundle in the export.
"""
from __future__ import annotations
import sqlite3
from typing import Any
from engine._utils import now_iso
from engine.db.repositories import (
BundleNotFound,
BundleRepository,
CategoryNotFound,
CategoryRepository,
ModuleNotFound,
ModuleRepository,
TemplateNotFound,
TemplateRepository,
)
from engine.migrations import CURRENT_SCHEMA_VERSION
def _walk_inner_bundles(repo: BundleRepository, seed: list[str]) -> list[str]:
"""Transitive walk of inner-bundle refs starting from ``seed``.
For each bundle, its ``children[]`` entries with ``type=='bundle'``
carry an ``id`` field pointing to the referenced bundle. Walk until
a fixed point (visited set prevents cycles). Returns sorted list of
all reachable bundle UUIDs (including seed members that exist).
Missing UUIDs in seed are silently dropped — the caller may pass
stale ids; we log no error and return only what exists.
"""
visited: set[str] = set()
queue = list(seed)
while queue:
uuid = queue.pop()
if uuid in visited:
continue
try:
b = repo.get(uuid)
except BundleNotFound:
continue
visited.add(uuid)
children = b.get("children") or []
for child in children:
if not isinstance(child, dict):
continue
if child.get("type") == "bundle":
child_id = child.get("id")
if isinstance(child_id, str) and child_id not in visited:
queue.append(child_id)
return sorted(visited)
def _modules_by_type(
repo: ModuleRepository,
uuids: list[str],
expected_type: str,
) -> list[dict[str, Any]]:
"""Look up modules by UUID, filter to expected type, drop misses.
Returns rows in input order (mod the type filter), each carrying
``snapshot_fingerprint`` already stamped by repository writes.
A UUID that doesn't exist in the DB or belongs to a different type
is silently skipped — the picker UI's UUID sets may be imprecise and
the exporter must never raise on a bad cross-bucket reference.
"""
out: list[dict[str, Any]] = []
for uuid in uuids:
try:
row = repo.get(uuid)
except ModuleNotFound:
continue
if row.get("type") != expected_type:
continue
out.append(row)
return out
def build_export_payload(
conn: sqlite3.Connection,
*,
bundle_uuids: list[str],
wildcard_uuids: list[str],
fixed_values_uuids: list[str],
combine_uuids: list[str],
derivation_uuids: list[str],
constraint_uuids: list[str],
category_uuids: list[str],
template_uuids: list[str],
) -> dict[str, Any]:
"""Build the 8-bucket export payload.
Performs:
- Transitive inner-bundle walk from ``bundle_uuids`` seed
- Type-gated module resolution (UUID in wrong bucket → silently skipped)
- Category lookup by id
- Template lookup by id (templates have their own repo/table; a
missing id is silently dropped)
Returns a dict ready for JSON serialisation.
"""
bundle_repo = BundleRepository(conn)
module_repo = ModuleRepository(conn)
category_repo = CategoryRepository(conn)
# Bundles: transitive walk, then bulk fetch in walk order (sorted).
resolved_bundle_uuids = _walk_inner_bundles(bundle_repo, bundle_uuids)
bundles: list[dict[str, Any]] = []
for uuid in resolved_bundle_uuids:
try:
bundles.append(bundle_repo.get(uuid))
except BundleNotFound:
pass # was reachable during walk but deleted in a race — skip
wildcards = _modules_by_type(module_repo, wildcard_uuids, "wildcard")
fixed_values = _modules_by_type(module_repo, fixed_values_uuids, "fixed_values")
combines = _modules_by_type(module_repo, combine_uuids, "combine")
derivations = _modules_by_type(module_repo, derivation_uuids, "derivation")
constraints = _modules_by_type(module_repo, constraint_uuids, "constraint")
categories: list[dict[str, Any]] = []
for uuid in category_uuids:
try:
categories.append(category_repo.get(uuid))
except CategoryNotFound:
pass
template_repo = TemplateRepository(conn)
templates: list[dict[str, Any]] = []
for uuid in template_uuids:
try:
templates.append(template_repo.get(uuid))
except TemplateNotFound:
pass
return {
"schema_version": CURRENT_SCHEMA_VERSION,
"exported_at": now_iso(),
"bundles": bundles,
"wildcards": wildcards,
"fixed_values": fixed_values,
"combines": combines,
"derivations": derivations,
"constraints": constraints,
"categories": categories,
"templates": templates,
}
-898
View File
@@ -1,898 +0,0 @@
"""Atomic import commit + snapshot-based undo.
Receives a classified commit payload from the SPA (Task 15 client-side
classifier produces it from a diff vs. live DB), and applies the three
operation buckets — adds, replaces, renames — inside a single SQLite
transaction. If any row write raises (e.g. PK collision), the entire
commit rolls back and no undo entry is created.
For every successful commit we persist a row in ``import_undo`` that
carries enough information to reverse the commit: the kind+id of every
inserted record, and the pre-replace row contents (with kind) of every
replaced record. ``undo_import`` deletes the inserts and restores the
snapshots, then evicts the undo row.
Commit payload shape (received)::
{
"adds": [{"kind": ..., "entity": {...full row...}}, ...],
"replaces": [{"kind": ..., "id": ..., "new_content": {...}}, ...],
"renames": [{"kind": ..., "old_id": ..., "new_id": ..., "content": {...}}, ...]
}
``kind`` is one of: ``wildcard``, ``fixed_values``, ``combine``,
``derivation``, ``constraint``, ``bundle``, ``category``, ``template``.
The first five are dispatched to the ``modules`` table (discriminated by
the ``type`` column). Templates have their own ``templates`` table and
support add/replace/rename like bundles. Categories appear only in
``adds`` (name-based merge, no replace/rename concept).
Undo metadata shape (persisted in ``import_undo`` row)::
imported_records: list[{"kind": str, "id": str}]
— records added or renamed; undo deletes each.
replaced_snapshots: dict[id, {"kind": str, "row": {... full pre-replace ...}}]
— rows to restore on undo.
rename_map: dict[old_id, new_id]
— informational mapping; the new_id also appears
in imported_records for the actual delete.
Atomicity note: each repository method opens its own ``with self._conn:``
block, which auto-commits per call. To get a single atomic commit across
multi-row inserts we bypass the repositories and use raw SQL inside one
outer transaction — mirroring the legacy ``wp_api/import_export.py``
``import_bundle`` pattern.
"""
from __future__ import annotations
import json
import logging
import secrets
import sqlite3
from typing import Any
from engine._fingerprint import module_fingerprint
from engine._utils import now_iso
from engine.modules.snapshot import payload_hash
logger = logging.getLogger(__name__)
class _ImporterContractError(ValueError):
"""Raised for importer-defined contract violations.
Carries a user-safe message. Caught separately from raw sqlite
errors so the envelope can preserve our wording but generalize
sqlite's (which can leak column/constraint internals).
"""
# Module kinds dispatched to the `modules` table. Mirrors the 5 entries
# of `engine.db.repositories._VALID_TYPES` — kept duplicated here so a
# typo in the discriminator field is caught locally, not silently
# inserted as an unknown type.
_MODULE_KINDS: frozenset[str] = frozenset({
"wildcard", "fixed_values", "combine", "derivation", "constraint",
})
def _is_module_kind(kind: str) -> bool:
return kind in _MODULE_KINDS
def _require_entity_fields(
kind: str, op: str, entity: dict[str, Any], required: tuple[str, ...],
) -> None:
"""Raise ``_ImporterContractError`` listing every missing field.
Treats ``None``, missing key, and falsy non-empty-string values as
missing. Empty strings ``""`` are allowed — callers explicitly opt
into "must be non-empty" by passing the field name; empty string
fields like ``description`` are NOT included in ``required``.
"""
missing = [
k for k in required
if entity.get(k) is None
or (not entity.get(k) and entity.get(k) != "")
]
if missing:
raise _ImporterContractError(
f"{op} for {kind} missing required field(s): {sorted(missing)}",
)
def _gen_undo_id() -> str:
"""Generate a fresh 16-hex undo id.
Wider than the 8-hex module/bundle ids — undo entries can pile up
over a session, and a wider id keeps collision probability
negligible without a uniqueness check round-trip.
"""
return secrets.token_hex(8)
def _module_fp_for_entity(entity: dict[str, Any]) -> str:
"""Compute snapshot_fingerprint for a module row dict.
Mirrors the formula `ModuleRepository.create` uses at line 126:
derive payload_hash on-the-fly from payload, then build the
fingerprint. The exporter (Task 12) stamps fingerprints on rows
before they leave the source DB, but we must re-compute on the
destination because (a) the source-side hash may differ if the
payload was migrated en route, and (b) the destination repository
contract is that any write recomputes the fingerprint.
"""
return module_fingerprint({
"type": entity.get("type", ""),
"name": entity.get("name", ""),
"description": entity.get("description", ""),
"tags": entity.get("tags") or [],
"payload_hash": payload_hash(entity.get("payload") or {}),
})
def _insert_module(
conn: sqlite3.Connection, kind: str, entity: dict[str, Any], *,
target_id: str | None = None,
) -> str:
"""INSERT a module row using raw SQL. Returns the inserted id.
``target_id`` overrides ``entity["id"]`` — used by the rename path
where the inbound entity carries its original id but we want to
insert under a freshly-allocated id. When None, ``entity["id"]``
is used verbatim (the add path).
"""
# When target_id is supplied (rename path), the caller has already
# validated new_id; only `name` is required from the entity.
required = ("name",) if target_id else ("name", "id")
op_label = "rename" if target_id else "add"
_require_entity_fields(kind, op_label, entity, required)
mid = target_id or entity["id"]
now = now_iso()
fp = _module_fp_for_entity({**entity, "type": kind})
conn.execute(
"INSERT INTO modules("
"id, type, name, description, category_id, tags, "
"is_favorite, payload, snapshot_fingerprint, version, "
"created_at, updated_at, "
"community_post_slug, community_version_number, "
"content_rating"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
mid, kind, entity["name"], entity.get("description", ""),
entity.get("category_id"),
json.dumps(entity.get("tags") or []),
int(entity.get("is_favorite", False)),
json.dumps(entity.get("payload") or {}),
fp,
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
def _insert_bundle(
conn: sqlite3.Connection, entity: dict[str, Any], *,
target_id: str | None = None,
) -> str:
"""INSERT a bundle row using raw SQL. Returns the inserted id.
Mirrors `BundleRepository.create` for the field set, including
`payload_hash` recomputation off `children` (the source-side hash
may not match this DB's hashing rules — we always recompute on
insert).
"""
required = ("name",) if target_id else ("name", "id")
op_label = "rename" if target_id else "add"
_require_entity_fields("bundle", op_label, entity, required)
bid = target_id or entity["id"]
children_blob = list(entity.get("children") or [])
ph = payload_hash({"children": children_blob})
now = now_iso()
conn.execute(
"INSERT INTO bundles("
"id, name, description, color, category_id, tags, "
"is_favorite, children, payload_hash, version, "
"created_at, updated_at, "
"community_post_slug, community_version_number, "
"content_rating"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
bid, entity["name"], entity.get("description", ""),
entity.get("color"),
entity.get("category_id"),
json.dumps(entity.get("tags") or []),
int(entity.get("is_favorite", False)),
json.dumps(children_blob),
ph,
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
def _update_module(
conn: sqlite3.Connection, mid: str, kind: str, content: dict[str, Any],
) -> None:
"""UPDATE the modules row identified by `mid` to mirror `content`.
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})
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`.
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()
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:
row = conn.execute("SELECT 1 FROM modules WHERE id = ?;", (mid,)).fetchone()
return row is not None
def _bundle_exists(conn: sqlite3.Connection, bid: str) -> bool:
row = conn.execute("SELECT 1 FROM bundles WHERE id = ?;", (bid,)).fetchone()
return row is not None
def _category_id_exists(conn: sqlite3.Connection, cid: str) -> bool:
row = conn.execute(
"SELECT 1 FROM module_categories WHERE id = ?;", (cid,),
).fetchone()
return row is not None
def _fetch_module_row(
conn: sqlite3.Connection, mid: str,
) -> dict[str, Any] | None:
"""Read a modules row as a plain dict (JSON columns deserialised)."""
row = conn.execute("SELECT * FROM modules WHERE id = ?;", (mid,)).fetchone()
if row is None:
return None
return {
"id": row["id"],
"type": row["type"],
"name": row["name"],
"description": row["description"],
"category_id": row["category_id"],
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"payload": json.loads(row["payload"]),
"snapshot_fingerprint": row["snapshot_fingerprint"],
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _fetch_bundle_row(
conn: sqlite3.Connection, bid: str,
) -> dict[str, Any] | None:
"""Read a bundles row as a plain dict (JSON columns deserialised)."""
row = conn.execute("SELECT * FROM bundles WHERE id = ?;", (bid,)).fetchone()
if row is None:
return None
return {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"color": row["color"],
"category_id": row["category_id"],
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"children": json.loads(row["children"]),
"payload_hash": row["payload_hash"],
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _insert_template(
conn: sqlite3.Connection, entity: dict[str, Any], *,
target_id: str | None = None,
) -> str:
"""INSERT a template row using raw SQL. Returns the inserted id.
Mirrors `_insert_bundle` but for the templates table — no
`payload_hash` / `version` columns (templates can't drift). The
rename path passes ``target_id`` (only `name` required); the add
path uses ``entity["id"]`` verbatim (`name` + `id` required).
"""
required = ("name",) if target_id else ("name", "id")
op_label = "rename" if target_id else "add"
_require_entity_fields("template", op_label, entity, required)
tid = target_id or entity["id"]
now = now_iso()
conn.execute(
"INSERT INTO templates("
"id, name, description, category_id, tags, "
"is_favorite, template_string, created_at, updated_at"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
tid, entity["name"], entity.get("description", ""),
entity.get("category_id"),
json.dumps(entity.get("tags") or []),
int(entity.get("is_favorite", False)),
entity.get("template_string", ""),
entity.get("created_at", now),
entity.get("updated_at", now),
),
)
return tid
def _update_template(
conn: sqlite3.Connection, tid: str, content: dict[str, Any],
) -> None:
"""UPDATE the templates row identified by `tid` to mirror `content`.
Stamps `updated_at`; no `version` bump (templates have no version
column).
"""
_require_entity_fields("template", "replace", content, ("name",))
now = now_iso()
conn.execute(
"UPDATE templates SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, template_string = ?, 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)),
content.get("template_string", ""),
now, tid,
),
)
def _template_exists(conn: sqlite3.Connection, tid: str) -> bool:
row = conn.execute("SELECT 1 FROM templates WHERE id = ?;", (tid,)).fetchone()
return row is not None
def _fetch_template_row(
conn: sqlite3.Connection, tid: str,
) -> dict[str, Any] | None:
"""Read a templates row as a plain dict (JSON columns deserialised)."""
row = conn.execute("SELECT * FROM templates WHERE id = ?;", (tid,)).fetchone()
if row is None:
return None
return {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"category_id": row["category_id"],
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"template_string": row["template_string"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def commit_import(
conn: sqlite3.Connection, payload: dict[str, Any],
) -> dict[str, Any]:
"""Apply a classified commit payload atomically.
Returns ``{"ok": True, "undo_id": str, "summary": {...counts...}}``
on success, or ``{"ok": False, "error": str}`` on contract
violation (collision on add, missing-id on replace, missing-id on
rename target table, etc.).
On any error all in-progress writes are rolled back via the outer
``with conn:`` block, and the import_undo row is NOT inserted.
"""
adds = list(payload.get("adds") or [])
replaces = list(payload.get("replaces") or [])
renames = list(payload.get("renames") or [])
imported_records: list[dict[str, str]] = []
replaced_snapshots: dict[str, dict[str, Any]] = {}
rename_map: dict[str, str] = {}
try:
with conn:
# ---- ADDS ---------------------------------------------------
for op in adds:
kind = op.get("kind")
entity = op.get("entity") or {}
if kind == "category":
# Mirror legacy wp_api/import_export.py:76 verbatim
# (no .strip()) so behaviour stays bit-for-bit
# compatible. Tightening here would silently change
# name-merge semantics for whitespace-padded names.
name = entity.get("name") or ""
if not name:
# Skip nameless categories the same way the
# legacy import_bundle does — but loudly, since
# the new pipeline pre-validates.
raise _ImporterContractError(
"category add missing 'name' field",
)
# Name-based merge (case-insensitive). Matches the
# legacy `import_bundle` behaviour at lines 71-82
# of wp_api/import_export.py.
existing = conn.execute(
"SELECT id FROM module_categories "
"WHERE name = ? COLLATE NOCASE;",
(name,),
).fetchone()
if existing is not None:
# Skip — nothing to undo.
continue
cid = entity.get("id") or name.lower()
if _category_id_exists(conn, cid):
# Same id, different name — treat as collision.
raise _ImporterContractError(
f"category id collision: {cid!r}",
)
conn.execute(
"INSERT INTO module_categories("
"id, name, color, icon, sort_order"
") VALUES(?, ?, ?, ?, ?);",
(
cid, name,
entity.get("color"), entity.get("icon"),
entity.get("sort_order", 0),
),
)
imported_records.append({"kind": "category", "id": cid})
elif kind == "bundle":
_require_entity_fields(
"bundle", "add", entity, ("id", "name"),
)
bid = entity["id"]
if _bundle_exists(conn, bid):
raise _ImporterContractError(
f"bundle id collision on add: {bid!r}",
)
_insert_bundle(conn, entity)
imported_records.append({"kind": "bundle", "id": bid})
elif kind == "template":
_require_entity_fields(
"template", "add", entity, ("id", "name"),
)
tid = entity["id"]
if _template_exists(conn, tid):
raise _ImporterContractError(
f"template id collision on add: {tid!r}",
)
_insert_template(conn, entity)
imported_records.append({"kind": "template", "id": tid})
elif _is_module_kind(kind or ""):
_require_entity_fields(
kind, "add", entity, ("id", "name"), # type: ignore[arg-type]
)
mid = entity["id"]
if _module_exists(conn, mid):
raise _ImporterContractError(
f"module id collision on add: {mid!r}",
)
_insert_module(conn, kind, entity) # type: ignore[arg-type]
imported_records.append({"kind": kind, "id": mid}) # type: ignore[dict-item]
else:
raise _ImporterContractError(
f"unknown kind in add: {kind!r}",
)
# ---- REPLACES ----------------------------------------------
# Categories never appear here (spec lock #2).
for op in replaces:
kind = op.get("kind")
rid = op.get("id")
new_content = op.get("new_content") or {}
if not rid:
raise _ImporterContractError(
f"{kind} replace missing 'id' field",
)
if kind == "bundle":
existing = _fetch_bundle_row(conn, rid)
if existing is None:
raise _ImporterContractError(
f"bundle replace target {rid!r} not found",
)
replaced_snapshots[rid] = {"kind": "bundle", "row": existing}
_update_bundle(conn, rid, new_content)
elif kind == "template":
existing = _fetch_template_row(conn, rid)
if existing is None:
raise _ImporterContractError(
f"template replace target {rid!r} not found",
)
replaced_snapshots[rid] = {"kind": "template", "row": existing}
_update_template(conn, rid, new_content)
elif _is_module_kind(kind or ""):
existing = _fetch_module_row(conn, rid)
if existing is None:
raise _ImporterContractError(
f"{kind} replace target {rid!r} not found",
)
replaced_snapshots[rid] = {"kind": kind, "row": existing} # type: ignore[dict-item]
_update_module(conn, rid, kind, new_content) # type: ignore[arg-type]
else:
raise _ImporterContractError(
f"unknown kind in replace: {kind!r}",
)
# ---- RENAMES -----------------------------------------------
# Insert at new_id, treat as a fresh add for undo purposes.
# Categories never appear here (spec lock #2).
for op in renames:
kind = op.get("kind")
old_id = op.get("old_id")
new_id = op.get("new_id")
content = op.get("content") or {}
if not new_id:
raise _ImporterContractError(
f"{kind} rename missing 'new_id' field",
)
if not old_id:
raise _ImporterContractError(
f"{kind} rename missing 'old_id' field",
)
# Content carries the renamed entity; `name` must be
# present (id field is irrelevant — we use new_id).
if not content.get("name"):
raise _ImporterContractError(
f"{kind} rename missing 'name' field in content",
)
if kind == "bundle":
if _bundle_exists(conn, new_id):
raise _ImporterContractError(
f"bundle new_id collision on rename: {new_id!r}",
)
_insert_bundle(conn, content, target_id=new_id)
imported_records.append({"kind": "bundle", "id": new_id})
rename_map[old_id] = new_id
elif kind == "template":
if _template_exists(conn, new_id):
raise _ImporterContractError(
f"template new_id collision on rename: {new_id!r}",
)
_insert_template(conn, content, target_id=new_id)
imported_records.append({"kind": "template", "id": new_id})
rename_map[old_id] = new_id
elif _is_module_kind(kind or ""):
if _module_exists(conn, new_id):
raise _ImporterContractError(
f"module new_id collision on rename: {new_id!r}",
)
_insert_module(conn, kind, content, target_id=new_id) # type: ignore[arg-type]
imported_records.append({"kind": kind, "id": new_id}) # type: ignore[dict-item]
rename_map[old_id] = new_id
else:
raise _ImporterContractError(
f"unknown kind in rename: {kind!r}",
)
# ---- UNDO ROW ----------------------------------------------
undo_id = _gen_undo_id()
conn.execute(
"INSERT INTO import_undo("
"id, created_at, imported_records, "
"replaced_snapshots, rename_map"
") VALUES(?, ?, ?, ?, ?);",
(
undo_id, now_iso(),
json.dumps(imported_records),
json.dumps(replaced_snapshots),
json.dumps(rename_map),
),
)
except _ImporterContractError as exc:
# `with conn:` already rolled back on exception. Return the
# importer-side contract message verbatim — we wrote it, it's
# safe.
return {"ok": False, "error": str(exc)}
except (sqlite3.IntegrityError, sqlite3.DatabaseError) as exc:
# Raw sqlite error — don't leak column/constraint internals.
# The pre-checks above (`_module_exists`, `_bundle_exists`,
# `_require_entity_fields`) cover every contract-level
# violation we expect; reaching here means the DB rejected
# the write for a lower-level reason (e.g. FK, NOT NULL,
# corruption). Log the real cause for ops; return a generic
# envelope to the caller.
logger.warning("import commit failed at DB layer: %s", exc)
return {"ok": False, "error": "database integrity violation"}
return {
"ok": True,
"undo_id": undo_id,
"summary": {
"added": len(imported_records) - len(rename_map),
"replaced": len(replaced_snapshots),
"renamed": len(rename_map),
},
}
def get_undo_entry(
conn: sqlite3.Connection, undo_id: str,
) -> dict[str, Any] | None:
"""Read the persisted undo metadata. Returns ``None`` if missing.
The dict shape mirrors what `commit_import` wrote:
``{id, created_at, imported_records, replaced_snapshots, rename_map}``.
"""
row = conn.execute(
"SELECT * FROM import_undo WHERE id = ?;", (undo_id,),
).fetchone()
if row is None:
return None
return {
"id": row["id"],
"created_at": row["created_at"],
"imported_records": json.loads(row["imported_records"]),
"replaced_snapshots": json.loads(row["replaced_snapshots"]),
"rename_map": json.loads(row["rename_map"]),
}
def undo_import(
conn: sqlite3.Connection, undo_id: str,
) -> dict[str, Any]:
"""Reverse a previous `commit_import` call.
Steps inside a single ``with conn:`` transaction:
1. Restore each replaced row to its pre-replace state.
2. Delete each inserted record (adds + renames' new_ids).
3. Delete the import_undo row.
Returns ``{"ok": True}`` on success, ``{"ok": False, "error": str}``
on missing entry or any restore/delete error (rolls back).
"""
entry = get_undo_entry(conn, undo_id)
if entry is None:
return {"ok": False, "error": f"undo entry {undo_id!r} not found"}
try:
with conn:
# ---- RESTORE REPLACES --------------------------------------
for rid, snap in entry["replaced_snapshots"].items():
kind = snap["kind"]
row = snap["row"]
if kind == "bundle":
# Restore literal columns including the original
# payload_hash, version, timestamps. We bypass
# `_update_bundle` because that helper bumps
# `version` and recomputes `payload_hash` — neither
# is correct for an undo (we want the row to look
# bit-identical to its pre-replace state).
conn.execute(
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, "
"category_id = ?, tags = ?, is_favorite = ?, "
"children = ?, payload_hash = ?, version = ?, "
"created_at = ?, updated_at = ? "
"WHERE id = ?;",
(
row["name"], row["description"], row["color"],
row["category_id"],
json.dumps(row["tags"]),
int(row["is_favorite"]),
json.dumps(row["children"]),
row["payload_hash"],
row["version"],
row["created_at"], row["updated_at"], rid,
),
)
elif kind == "template":
# Restore literal columns to the pre-replace state.
# Templates have no version / payload_hash columns,
# so the snapshot is just the metadata + string.
conn.execute(
"UPDATE templates SET "
"name = ?, description = ?, category_id = ?, "
"tags = ?, is_favorite = ?, template_string = ?, "
"created_at = ?, updated_at = ? "
"WHERE id = ?;",
(
row["name"], row["description"],
row["category_id"],
json.dumps(row["tags"]),
int(row["is_favorite"]),
row["template_string"],
row["created_at"], row["updated_at"], rid,
),
)
elif _is_module_kind(kind):
conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, "
"tags = ?, is_favorite = ?, payload = ?, "
"snapshot_fingerprint = ?, version = ?, "
"created_at = ?, updated_at = ? "
"WHERE id = ?;",
(
row["name"], row["description"],
row["category_id"],
json.dumps(row["tags"]),
int(row["is_favorite"]),
json.dumps(row["payload"]),
row["snapshot_fingerprint"],
row["version"],
row["created_at"], row["updated_at"], rid,
),
)
else:
raise _ImporterContractError(
f"unknown kind {kind!r} in undo snapshot",
)
# ---- DELETE INSERTS ----------------------------------------
for rec in entry["imported_records"]:
kind = rec["kind"]
rec_id = rec["id"]
if kind == "bundle":
conn.execute(
"DELETE FROM bundles WHERE id = ?;", (rec_id,),
)
elif kind == "template":
conn.execute(
"DELETE FROM templates WHERE id = ?;", (rec_id,),
)
elif kind == "category":
conn.execute(
"DELETE FROM module_categories WHERE id = ?;",
(rec_id,),
)
elif _is_module_kind(kind):
conn.execute(
"DELETE FROM modules WHERE id = ?;", (rec_id,),
)
else:
raise _ImporterContractError(
f"unknown kind {kind!r} in imported_records",
)
# ---- EVICT UNDO ROW ----------------------------------------
conn.execute(
"DELETE FROM import_undo WHERE id = ?;", (undo_id,),
)
except _ImporterContractError as exc:
return {"ok": False, "error": str(exc)}
except KeyError as exc:
# Corrupt undo metadata (missing 'kind'/'id'/'row' keys). Treat
# as our contract violation — the undo blob is internal so the
# safe message names the missing field without leaking SQL.
return {
"ok": False,
"error": f"corrupt undo metadata: missing key {exc!s}",
}
except sqlite3.DatabaseError as exc:
logger.warning("undo failed at DB layer: %s", exc)
return {"ok": False, "error": "database integrity violation"}
return {"ok": True}
-84
View File
@@ -1,84 +0,0 @@
"""Versioned payload migration chain. Mirror of TypeScript
`src/manager/import-export/migrations.ts`.
Used by `engine/importer.py` on server-side parse paths (re-validate
on commit). Client-side migration is the source of truth for what
the user saw in the picker; server re-runs as defense in depth.
"""
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 = 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,
}
def migrate_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Walk the migration chain from `payload.schema_version` up to current.
Returns one of:
- {"ok": True, "migrated": <dict>, "migrated_entity_count": int}
where migrated_entity_count is the cumulative entity count across
all migration steps (total bundles+wildcards+fixed_values+combines+
derivations+constraints+categories that passed through any step).
Matches TS `migratedEntityCount`.
- {"ok": False, "reason": str}
"""
version = payload.get("schema_version")
if not isinstance(version, int):
return {"ok": False, "reason": "payload missing schema_version field"}
# 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} (max known: {MAX_KNOWN_SCHEMA_VERSION})",
}
current = {
"schema_version": version,
"bundles": payload.get("bundles", []),
"wildcards": payload.get("wildcards", []),
"fixed_values": payload.get("fixed_values", []),
"combines": payload.get("combines", []),
"derivations": payload.get("derivations", []),
"constraints": payload.get("constraints", []),
"categories": payload.get("categories", []),
}
migrated_entity_count = 0
while current["schema_version"] < CURRENT_SCHEMA_VERSION:
fn = _CHAIN.get(current["schema_version"])
if fn is None:
ver = current["schema_version"]
return {"ok": False, "reason": f"no migration registered for v{ver}"}
before = sum(
len(current[k])
for k in ("bundles", "wildcards", "fixed_values", "combines",
"derivations", "constraints", "categories")
)
current = fn(current)
migrated_entity_count += before
return {"ok": True, "migrated": current, "migrated_entity_count": migrated_entity_count}
-27
View File
@@ -1,27 +0,0 @@
"""v0 -> v1: no-op stub. Tags every entity with migrated_from=0.
NOTE: explicitly enumerates all seven known entity arrays (bundles,
wildcards, fixed_values, combines, derivations, constraints, categories).
If a future schema version adds an eighth entity array to the payload
shape, update BOTH this migration AND every subsequent migration in the
chain to tag the new array. Otherwise migrations will silently pass the
new array through untagged.
"""
from __future__ import annotations
from typing import Any
def migrate_v0_to_v1(payload: dict[str, Any]) -> dict[str, Any]:
def tag(e: dict[str, Any]) -> dict[str, Any]:
return {**e, "migrated_from": 0}
return {
"schema_version": 1,
"bundles": [tag(b) for b in payload["bundles"]],
"wildcards": [tag(w) for w in payload["wildcards"]],
"fixed_values": [tag(v) for v in payload["fixed_values"]],
"combines": [tag(v) for v in payload["combines"]],
"derivations": [tag(v) for v in payload["derivations"]],
"constraints": [tag(c) for c in payload["constraints"]],
"categories": [tag(v) for v in payload["categories"]],
}
-199
View File
@@ -1,199 +0,0 @@
"""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
+5 -102
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import random
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Any
from engine.syntax.types import ResolveContext, SurfaceKind
@@ -23,101 +23,15 @@ class _RuntimeResolveContext:
warnings: list[dict[str, Any]]
_vars: dict[str, Any]
_catalog: dict[str, dict[str, Any]]
# Back-channels for the syntax resolver's nested-`@{}` path so it
# can apply constraints to wildcards reached transitively (same
# rule book as the chain-level wildcard handler). Both default to
# empty so legacy callers that build a ResolveContext by hand keep
# working — the resolver falls back to "no constraints" when the
# bucket is empty/None.
_constraints: list[dict[str, Any]] = field(default_factory=list)
_picks: dict[str, dict[str, Any]] = field(default_factory=dict)
# 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 self._vars[name]
return str(self._vars[name])
return None
def get_module(self, uuid: str) -> dict[str, Any] | None:
"""Catalog lookup. Pure O(1) dict get. Spec §2.5.
No DB fallback. No network. No side effects. If the catalog does
not contain `uuid`, returns None and lets the resolver decide
(lenient → warning, strict → UnknownRefError).
Engine isolation invariant — `engine/modules/__init__.py` MUST NOT
import from `engine.db`, `wp_api`, or `wp_nodes`. Pinned by
`tests/engine/modules/test_resolve_context_isolation.py`.
"""
return self._catalog.get(uuid)
def get_constraints(self) -> list[dict[str, Any]]:
"""Registered constraints from the current pipeline run.
Each entry is the meta dict that ``constraint_handler`` writes
into ``ctx['__wp_constraints__']``. Used by ``_resolve_ref`` to
apply constraints against the nested-target wildcard before
rolling its options.
"""
return self._constraints
def get_picks(self) -> dict[str, dict[str, Any]]:
"""``ctx['__wp_picks__']`` — every wildcard's last pick keyed
by module id. Constraint application needs this to look up the
source wildcard's pick when reweighting the target."""
return self._picks
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(
ctx: dict[str, Any],
@@ -130,9 +44,6 @@ def build_resolve_ctx(
Handlers call this with their type-appropriate surface ("wildcard" /
"combine" / "derivation" / "assembler") then pass the result to resolve_text.
"""
constraints = ctx.get("__wp_constraints__")
picks = ctx.get("__wp_picks__")
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)),
@@ -142,17 +53,6 @@ def build_resolve_ctx(
warnings=ctx["__wp_warnings__"],
_vars={k: v for k, v in ctx.items() if not k.startswith("__")},
_catalog=ctx.get("__wp_catalog__", {}),
# Constraint bucket + picks table threaded through so the
# syntax resolver's `_resolve_ref` can apply chain-level
# constraints against nested-via-`@{}` target wildcards. Both
# may be missing (legacy ctx, tests that pre-date constraint
# support) — defaults handle that path.
_constraints=constraints if isinstance(constraints, list) else [],
_picks=picks if isinstance(picks, dict) else {},
# 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 {},
)
@@ -219,15 +119,18 @@ __all__ += ["FixedValuesHandler"]
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.pipeline_handler import PipelineHandler # noqa: E402
register_handler(CombineHandler)
register_handler(DerivationHandler)
register_handler(ConstraintHandler)
register_handler(PipelineHandler)
__all__ += [
"CombineHandler",
"ConstraintHandler",
"DerivationHandler",
"PipelineHandler",
]
__all__ += ["build_resolve_ctx"]
-62
View File
@@ -1,62 +0,0 @@
"""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
-264
View File
@@ -1,264 +0,0 @@
"""Shared constraint application — used by wildcard_handler (chain-level
target wildcards) and engine.syntax.resolve._resolve_ref (nested-via-@{}
target wildcards).
Pre-2026-05 the constraint matrix only applied when the target wildcard
ran as a top-level module in the chain. Wildcards reached through
``@{uuid}`` refs inside another wildcard's option value silently
bypassed the matrix, defeating the whole point of constraints in any
composed pipeline (outfit-set wildcards, palette reuse, etc.). This
module pulls the apply logic out of wildcard_handler so both surfaces
share one implementation.
The helper takes raw primitives (options list, target uuid, constraints
bucket, picks bucket, warnings list) so it can be called from any
context that has access to those — full pipeline ctx dict (wildcard
handler) or narrow ResolveContext-style accessors (ref resolver).
"""
from __future__ import annotations
from typing import Any
from engine.modules.wildcard_handler import _apply_constraint_to_options
def _push_constraint_warning(
warnings: list[dict[str, Any]],
*,
type_: str,
target_uuid: str,
detail: dict[str, Any],
) -> None:
"""Mirror the warning shape used elsewhere in the engine so the
debug viewer can render it uniformly."""
warnings.append({
"type": type_,
"severity": "warn",
"module_id": target_uuid,
"source_field": "",
"position": 0,
"token_index": None,
"detail": detail,
"message": (
f"constraint source not yet picked: {detail.get('source_wildcard_id')!r}"
if type_ == "unknown_constraint_source"
else f"constraint warning: {detail!r}"
),
})
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,
constraints: list[dict[str, Any]] | None,
picks: dict[str, dict[str, Any]] | None,
warnings: list[dict[str, Any]],
*,
consumed=None,
hits=None,
firing_uid=None,
carrier_ctx=None,
) -> tuple[list[dict[str, Any]], bool]:
"""Apply EVERY constraint whose reach selector covers this firing
target instance, combined sequentially.
Returns ``(adjusted_options, any_applied)``. ``any_applied`` is True
iff at least one constraint actually fired — callers use it to
decide whether to run the excludes-all check.
**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.
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.
``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.
"""
if not constraints or not isinstance(constraints, list):
return options, False
if not isinstance(picks, dict):
picks = {}
if hits is None:
hits = {}
any_applied = False
for c in constraints:
if not isinstance(c, dict) or c.get("target_wildcard_id") != target_uuid:
continue
cid = c.get("__constraint_module_id__")
src_id = c.get("source_wildcard_id")
src_pick = _select_source_pick(picks, src_id, c)
if not isinstance(src_pick, dict):
_push_constraint_warning(
warnings,
type_="unknown_constraint_source",
target_uuid=target_uuid,
detail={
"source_wildcard_id": src_id,
"target_wildcard_id": target_uuid,
"hint": (
"source must be picked before target — check chain order"
),
},
)
continue
if cid is not None:
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 adjust_warnings:
_push_constraint_warning(
warnings,
type_=w.get("type", "unknown_constraint_mode"),
target_uuid=target_uuid,
detail={
**{k: v for k, v in w.items() if k != "type"},
"source_wildcard_id": src_id,
"target_wildcard_id": target_uuid,
},
)
return options, any_applied
def warn_excludes_all(
options: list[dict[str, Any]],
target_uuid: str,
warnings: list[dict[str, Any]],
) -> None:
"""Emit the ``constraint_excludes_all_options`` warning when post-
application weights sum to zero. ``_pick_weighted`` falls back to
``options[0]`` silently — without this warning the user sees the
same option forever with no signal that constraints over-narrowed
the pool.
Call AFTER ``apply_constraints_for_target`` returned ``any_applied``.
"""
if not options:
return
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
if total <= 0:
_push_constraint_warning(
warnings,
type_="constraint_excludes_all_options",
target_uuid=target_uuid,
detail={
"target_wildcard_id": target_uuid,
"hint": "every option was excluded; falling back to options[0]",
},
)
-26
View File
@@ -1,26 +0,0 @@
"""Composite-key encoder. Mirrors JS `encodeKey` byte-for-byte.
Spec invariant: composite keys are JSON-encoded string arrays, no
whitespace, unicode literal (not escaped), array order preserved.
Cross-language fitness test in `tests/test_engine_keys.py` enforces
byte-exact match.
See: docs/superpowers/specs/2026-05-07-instance-overrides-modal-design.md §4.5
"""
import json
def encode_key(parts: list[str]) -> str:
return json.dumps(parts, separators=(",", ":"), ensure_ascii=False)
def decode_key(key: str) -> list[str]:
"""Inverse of encode_key. Round-trip safe by spec invariant.
Raises ValueError on malformed input — caller should treat unknown
keys as "skip silently" rather than crash the resolve.
"""
parts = json.loads(key)
if not isinstance(parts, list) or not all(isinstance(p, str) for p in parts):
raise ValueError(f"composite key must decode to list[str], got {parts!r}")
return parts
-30
View File
@@ -1,30 +0,0 @@
"""Shared seed-derivation helper for module handlers.
Every kind that needs reproducible randomness (wildcard pick, combine
{a|b|c} resolution, fixed_values value alternations) routes through
this same function so the same `(seed, key)` pair always yields the
same RNG state. Lifting it out of wildcard_handler.py prevents drift
when more kinds gain seed-lock support.
"""
from __future__ import annotations
import hashlib
import random
def derive_module_rng(seed: int, key: str) -> random.Random:
"""Per-module RNG derived from (seed, key).
`key` is a stable per-module string — wildcard uses `binding`,
combine uses `output_var`, fixed_values uses module id. Different
keys under the same seed yield independent streams so two
co-resident modules binding different vars roll independently.
Used for BOTH locked and unlocked picks — applying the same
derivation either way is what lets lock capture the visible
roll: locking with `locked_seed = chain_seed` reproduces the
unlocked pick exactly because both paths derive from
`sha256(chain_seed:key)`.
"""
digest = hashlib.sha256(f"{int(seed)}:{key}".encode()).hexdigest()
return random.Random(int(digest[:16], 16))
+5 -65
View File
@@ -1,15 +1,7 @@
"""Combine module resolver — syntax-aware template fill.
Reads payload.template (or instance.template_override when set), resolves
all $var / {a|b|c} / {N$$sep$$...} constructs against the runtime ctx,
binds the result to payload.output_var.
Surface-gated: combine resolves $var (it's the consumer) but not @{uuid}
(refs only resolve from wildcard option values — RefOutOfSurfaceError).
Seed lock: instance.locked_seed pins {a|b|c} resolution per instance;
without it, derives from chain seed via the shared
engine.modules._seed.derive_module_rng helper.
Reads payload.template, resolves all $var / @{uuid} / {a|b|c} / {N$$sep$$...}
constructs against the runtime ctx, binds the result to payload.output_var.
"""
from __future__ import annotations
@@ -17,13 +9,10 @@ import re
from typing import Any
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
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
_MAX_IDENT_LEN = 64
_MAX_TEMPLATE_LEN = 8000
class CombineHandler(ModuleHandler):
@@ -38,30 +27,9 @@ class CombineHandler(ModuleHandler):
template = payload.get("template")
if not isinstance(template, str):
raise ValueError("combine payload.template must be a string")
# Empty template means the module emits an empty string for its
# output_var — almost always a config mistake (the user removed
# the template content but forgot the module is still in the
# chain). Reject so the broken state surfaces at save time.
if not template:
raise ValueError("combine payload.template must not be empty")
if len(template) > _MAX_TEMPLATE_LEN:
raise ValueError(
f"combine payload.template must be at most {_MAX_TEMPLATE_LEN} "
f"chars (got {len(template)})"
)
output_var = payload.get("output_var")
if not isinstance(output_var, str) or not output_var:
raise ValueError("combine payload.output_var must be a non-empty string")
if len(output_var) > _MAX_IDENT_LEN:
raise ValueError(
f"combine payload.output_var must be at most {_MAX_IDENT_LEN} "
f"chars (got {len(output_var)})"
)
if output_var.startswith("__"):
raise ValueError(
"combine payload.output_var must not start with '__' "
"(reserved for engine-internal keys)"
)
if not _IDENT_RE.match(output_var):
raise ValueError(
f"combine payload.output_var {output_var!r} is not a valid identifier"
@@ -81,37 +49,9 @@ class CombineHandler(ModuleHandler):
ctx: Any,
) -> dict[str, str]:
cls.validate_payload(payload)
# instance.template_override wins over payload.template — same
# precedence pattern wildcard's option_weights / fixed_values'
# values_overrides use.
override = instance.get("template_override")
template: str = override if isinstance(override, str) else payload["template"]
# instance.variable_binding rebinds the produced var (mirrors
# wildcard precedence). Empty string / non-string falls back to
# payload.output_var. Strip leading `$` so callers can write
# either `$foo` or `foo` in the modal.
binding_override = instance.get("variable_binding")
if isinstance(binding_override, str) and binding_override.strip():
output_var: str = binding_override.lstrip("$").strip()
else:
output_var = payload["output_var"]
template: str = payload["template"]
output_var: str = payload["output_var"]
# Effective seed selection (mirrors WildcardHandler):
# - locked_seed when present → reproducible per-instance
# - chain seed otherwise → re-rolls each queue
# Both feed derive_module_rng(seed, output_var) so locking with
# locked_seed = chain_seed reproduces the unlocked roll exactly.
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
if isinstance(locked_seed, (int, float)):
effective_seed = int(locked_seed)
else:
effective_seed = chain_seed
rng = derive_module_rng(effective_seed, output_var)
# Patch ctx for the scope of this resolve so build_resolve_ctx
# picks up our derived RNG instead of the chain RNG.
ctx_local = {**ctx, "__wp_rng__": rng}
resolve_ctx = build_resolve_ctx(ctx_local, surface="combine")
resolve_ctx = build_resolve_ctx(ctx, surface="combine")
result = resolve_text(template, resolve_ctx)
return {output_var: result}
+20 -363
View File
@@ -2,15 +2,10 @@
A constraint module declares a re-weighting matrix from one wildcard module's
options to another's. The actual mutation is intentionally **not** done here:
WildcardHandler will eventually inspect ``ctx["__wp_constraints__"]`` and apply the
WildcardHandler will eventually inspect ``ctx["_constraints"]`` and apply the
matrix when picking options. This handler only validates the payload and
records metadata in the context for the wildcard handler to consume.
The bucket key uses the canonical ``__wp_*__`` engine-internal prefix so
``strip_internals`` drops it from the public socket payload — without the
``__`` prefix the constraint metadata leaked to the assembler as a
user-facing ``_constraints`` variable, polluting autocomplete + templates.
TODO: When WildcardHandler grows constraint awareness, switch from recording
metadata to applying it here, or keep it as a pure metadata channel and let
the consumer drive resolution. The current decision is metadata-only.
@@ -19,70 +14,13 @@ from __future__ import annotations
from typing import Any
from engine.modules._keys import decode_key, encode_key
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).
On total failure (both setter API and dict-like access raise), emit a
`constraint_register_failed` warning so the user gets a debug-viewer
signal — pre-fix the constraint silently never registered and the
wildcard handler ran unconstrained with zero indication that the
user's constraint was even loaded.
"""
"""Append ``meta`` to ``ctx['_constraints']`` (best-effort)."""
if ctx is None:
return
# Try setter-based access first (engine Context API).
@@ -90,56 +28,27 @@ def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
getter = getattr(ctx, "get", None)
if callable(setter) and callable(getter):
try:
existing = getter("__wp_constraints__", None)
existing = getter("_constraints", None)
except TypeError:
existing = None
bucket = list(existing) if isinstance(existing, list) else []
bucket.append(meta)
try:
setter("__wp_constraints__", bucket)
setter("_constraints", bucket)
return
except Exception:
pass
# Fall back to dict-like __getitem__/__setitem__.
try:
existing = ctx["__wp_constraints__"] if "__wp_constraints__" in ctx else [] # type: ignore[operator,index]
existing = ctx["_constraints"] if "_constraints" in ctx else [] # type: ignore[operator,index]
except Exception:
existing = []
bucket = list(existing) if isinstance(existing, list) else []
bucket.append(meta)
try:
ctx["__wp_constraints__"] = bucket # type: ignore[index]
ctx["_constraints"] = bucket # type: ignore[index]
except Exception:
return
except Exception as e:
# Both setter + dict paths failed. Emit a warning so the
# constraint failure surfaces in WP_Debug instead of being a
# silent no-op (pre-fix users had no signal that their
# constraint never made it into ctx — the wildcard handler
# then ran unconstrained with zero indication).
warnings = None
try:
warnings = ctx.get("__wp_warnings__") if hasattr(ctx, "get") else None
except Exception:
warnings = None
if isinstance(warnings, list):
warnings.append({
"type": "constraint_register_failed",
"severity": "error",
"module_id": "",
"source_field": "",
"position": 0,
"token_index": None,
"detail": {
"exception_type": type(e).__name__,
"exception_message": str(e),
"source_wildcard_id": meta.get("source_wildcard_id"),
"target_wildcard_id": meta.get("target_wildcard_id"),
},
"message": (
"constraint failed to register in ctx — "
f"{type(e).__name__}: {e}"
),
})
def _validate_cell(cell: Any, where: str) -> None:
@@ -153,14 +62,8 @@ def _validate_cell(cell: Any, where: str) -> None:
factor = cell.get("factor")
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
raise ValueError(f"constraint {where}.factor must be a number")
# factor >= 0 — factor=0 is meaningful (effectively excludes the
# option, same as `mode: exclude`). Strict `> 0` rejected legitimate
# SPA-saved payloads where the editor surfaced an explicit zero
# weight, breaking the entire constraint at runtime. Negative
# factors stay rejected since they have no defined semantics in the
# weighted-pick model.
if float(factor) < 0:
raise ValueError(f"constraint {where}.factor must not be negative")
if float(factor) <= 0:
raise ValueError(f"constraint {where}.factor must be a positive number")
class ConstraintHandler(ModuleHandler):
@@ -200,20 +103,12 @@ class ConstraintHandler(ModuleHandler):
raise ValueError(
f"constraint payload.exceptions[{i}] must be an object"
)
# Support both legacy (source/target) and tier 2 (source_value/target_value) naming.
# Empty STRING is allowed — it's the null-option marker (a
# wildcard option with `is_null: True` has `value: ""`, and
# exceptions targeting that option store `source: ""` /
# `target: ""`). Reject only when neither key is present as
# a string (genuine missing-data) — that means we don't know
# WHICH option the exception was authored against.
for key_legacy, key_tier2 in (("source", "source_value"), ("target", "target_value")):
v_legacy = exc.get(key_legacy)
v_tier2 = exc.get(key_tier2)
if not isinstance(v_legacy, str) and not isinstance(v_tier2, str):
for key in ("source", "target"):
v = exc.get(key)
if not isinstance(v, str) or not v:
raise ValueError(
f"constraint payload.exceptions[{i}].{key_legacy} must be a "
f"string (empty allowed for null-option matches)"
f"constraint payload.exceptions[{i}].{key} must be a "
f"non-empty string"
)
mode = exc.get("mode")
if mode not in _VALID_MODES:
@@ -226,15 +121,10 @@ class ConstraintHandler(ModuleHandler):
raise ValueError(
f"constraint payload.exceptions[{i}].factor must be a number"
)
# factor >= 0 — factor=0 is the canonical exception form for
# "always exclude this pair". Same logic as the matrix-cell
# check above; SPA payloads surface zero factors and the
# strict > 0 rejected them, killing the whole constraint.
if float(factor) < 0:
if float(factor) <= 0:
raise ValueError(
f"constraint payload.exceptions[{i}].factor must not be negative"
f"constraint payload.exceptions[{i}].factor must be positive"
)
_validate_target_select(payload.get("target_select"))
@classmethod
def resolve(
@@ -244,246 +134,13 @@ class ConstraintHandler(ModuleHandler):
ctx: Any,
) -> dict[str, str]:
cls.validate_payload(payload)
# Tier 2 instance filters — runtime projection layer, applied
# before recording into ctx so downstream consumers see only the
# active subset.
disabled_cells = set(instance.get("disabled_matrix_cells") or [])
disabled_excs = set(instance.get("disabled_exception_keys") or [])
# Tier-D override maps (sparse — only set keys override library).
cell_mode_overrides = instance.get("cell_mode_overrides") or {}
cell_factor_overrides = instance.get("cell_factor_overrides") or {}
exception_mode_overrides = instance.get("exception_mode_overrides") or {}
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] = {}
# Track which (src, tgt) pairs the library iteration covered so
# the second pass below only touches keys that have NO library
# rule. Without this guard, instance-only overrides would
# double-apply on top of the library merge already done here.
covered_keys: set[str] = set()
for src, row in raw_matrix.items():
kept: dict[str, Any] = {}
for tgt, cell in row.items():
key = encode_key([src, tgt])
covered_keys.add(key)
if key in disabled_cells:
continue
# Apply override on a copy — payload cells are shared
# references; mutating them would leak into the library
# value across resolves.
merged = dict(cell)
if key in cell_mode_overrides:
mode = cell_mode_overrides[key]
if mode not in _VALID_MODES:
raise ValueError(
f"constraint cell_mode_overrides[{key!r}] must be one of "
f"{sorted(_VALID_MODES)}"
)
merged["mode"] = mode
if key in cell_factor_overrides:
factor = cell_factor_overrides[key]
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
raise ValueError(
f"constraint cell_factor_overrides[{key!r}] must be a number"
)
if float(factor) < 0:
raise ValueError(
f"constraint cell_factor_overrides[{key!r}] must not be negative"
)
merged["factor"] = float(factor)
kept[tgt] = merged
if kept:
matrix[src] = kept
# ── Empty-cell instance overrides — keys present in
# cell_mode_overrides / cell_factor_overrides but absent from
# the library matrix. Implicit baseline is mode "allow" + factor
# 1.0; whichever side(s) the user set populate the rest.
# Keys in disabled_cells skip — equivalent to "no rule" runtime.
instance_only_keys = (
set(cell_mode_overrides) | set(cell_factor_overrides)
) - covered_keys
for key in instance_only_keys:
if key in disabled_cells:
continue
try:
parts = decode_key(key)
except ValueError:
continue
if len(parts) != 2:
continue
src, tgt = parts[0], parts[1]
mode = cell_mode_overrides.get(key, "allow")
if mode not in _VALID_MODES:
raise ValueError(
f"constraint cell_mode_overrides[{key!r}] must be one of "
f"{sorted(_VALID_MODES)}"
)
factor_raw = cell_factor_overrides.get(key, 1.0)
if not isinstance(factor_raw, (int, float)) or isinstance(factor_raw, bool):
raise ValueError(
f"constraint cell_factor_overrides[{key!r}] must be a number"
)
if float(factor_raw) < 0:
raise ValueError(
f"constraint cell_factor_overrides[{key!r}] must not be negative"
)
matrix.setdefault(src, {})[tgt] = {
"mode": mode,
"factor": float(factor_raw),
}
# ── Exceptions: filter, override, then append extras ─────────
raw_excs = payload.get("exceptions", [])
exceptions: list[dict[str, Any]] = []
for exc in raw_excs:
key = encode_key([
exc.get("source_value") or exc.get("source", ""),
exc.get("target_value") or exc.get("target", ""),
])
if key in disabled_excs:
continue
merged = dict(exc)
if key in exception_mode_overrides:
mode = exception_mode_overrides[key]
if mode not in _VALID_MODES:
raise ValueError(
f"constraint exception_mode_overrides[{key!r}] must be one of "
f"{sorted(_VALID_MODES)}"
)
merged["mode"] = mode
if key in exception_factor_overrides:
factor = exception_factor_overrides[key]
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
raise ValueError(
f"constraint exception_factor_overrides[{key!r}] must be a number"
)
if float(factor) < 0:
raise ValueError(
f"constraint exception_factor_overrides[{key!r}] must not be negative"
)
merged["factor"] = float(factor)
exceptions.append(merged)
# Validate + append instance-only extras. Same shape as library
# exceptions; rejected with same error message style. NOT
# written back to library — lives entirely in the instance.
for i, exc in enumerate(extra_exceptions):
if not isinstance(exc, dict):
raise ValueError(
f"constraint extra_exceptions[{i}] must be an object"
)
for key_legacy, _key_tier2 in (("source", "source_value"), ("target", "target_value")):
v = exc.get(key_legacy) or exc.get(_key_tier2)
if not isinstance(v, str) or not v:
raise ValueError(
f"constraint extra_exceptions[{i}].{key_legacy} must be a "
f"non-empty string"
)
mode = exc.get("mode")
if mode not in _VALID_MODES:
raise ValueError(
f"constraint extra_exceptions[{i}].mode must be one of "
f"{sorted(_VALID_MODES)}"
)
factor = exc.get("factor")
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
raise ValueError(
f"constraint extra_exceptions[{i}].factor must be a number"
)
if float(factor) < 0:
raise ValueError(
f"constraint extra_exceptions[{i}].factor must not be negative"
)
exceptions.append(dict(exc))
# Carry the owning constraint module's id into the registered
# 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 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 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"):
module_id = (
ctx.get("__wp_current_module_uid__")
or ctx.get("__wp_current_module_id__")
)
except Exception:
module_id = None
# 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
# warning text embeds this as the `#name` suffix on the constraint
# ref so the chip renders a readable label even when the workflow
# was imported on a machine whose library doesn't have this
# constraint — both the SPA library catalog AND the embed-bundle
# 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,
"matrix": payload.get("matrix", {}),
"exceptions": payload.get("exceptions", []),
}
_ctx_set_constraint(ctx, meta)
# Pass-through stub: return no bindings — WildcardHandler is expected
# to consume ctx["_constraints"] when it grows constraint awareness.
return {}
+18 -209
View File
@@ -16,93 +16,37 @@ import re
from typing import Any
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",
# Presence-check ops added in 2026-05-09 cycle. `exists`/`not_exists`
# check key presence in ctx regardless of value (empty string still
# counts as present); `is_set`/`is_unset` additionally require the
# value to be non-empty. Both pairs ship so users can pick the
# semantics they need — wildcards can pick options with empty
# values, leaving keys present-but-empty.
"exists", "not_exists", "is_set", "is_unset",
}
_VALID_OPS = {"equals", "not_equals", "contains", "matches"}
_VALID_MODES = {"replace", "append", "prepend"}
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 None
getter = getattr(ctx, "get", None)
if callable(getter):
try:
return getter(name, None)
except TypeError:
try:
return getter(name)
except Exception:
return None
try:
if name in ctx: # type: ignore[operator]
return ctx[name] # type: ignore[index]
except Exception:
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:
"""Whether `name` is a known key in `ctx`, regardless of value.
Mirrors `_ctx_get`'s indirection chain (callable getter →
`__contains__` / dict lookup) but returns bool. Required so the
`exists` / `not_exists` ops can distinguish "key absent" from
"key present but value is empty string" — `_ctx_get` collapses
both into `""`. Used only by presence-check ops; non-presence
ops continue to read through `_ctx_get`.
"""
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:
return name in ctx # type: ignore[operator]
except Exception:
pass
return ""
getter = getattr(ctx, "get", None)
if callable(getter):
# Use a sentinel default so a "missing" key can be told apart
# from a key whose value is `None` or `""`.
sentinel = object()
try:
value = getter(name, sentinel)
value = getter(name, "")
except TypeError:
try:
value = getter(name)
except Exception:
return False
return value is not sentinel
return False
return ""
return "" if value is None else str(value)
try:
if name in ctx: # type: ignore[operator]
return str(ctx[name]) # type: ignore[index]
except Exception:
return ""
return ""
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)
@@ -117,18 +61,6 @@ def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
var = condition.get("var", "")
op = condition.get("op", "")
value = condition.get("value", "")
# Presence-check ops short-circuit before reading the value — they
# only care about key presence (and, for is_set/is_unset, whether
# the stored value is non-empty). The `value` field is ignored by
# these ops; the SPA disables the value input when one is selected.
if op == "exists":
return _ctx_has(ctx, var)
if op == "not_exists":
return not _ctx_has(ctx, var)
if op == "is_set":
return _ctx_has(ctx, var) and _ctx_get(ctx, var) != ""
if op == "is_unset":
return not _ctx_has(ctx, var) or _ctx_get(ctx, var) == ""
actual = _ctx_get(ctx, var)
if op == "equals":
return actual == value
@@ -144,48 +76,17 @@ 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", ""))
# 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)
new_value = resolve_text(raw_value, resolve_ctx)
if mode == "replace":
result = new_value
elif mode == "append":
@@ -242,7 +143,6 @@ class DerivationHandler(ModuleHandler):
rules = payload.get("rules")
if not isinstance(rules, list):
raise ValueError("derivation payload.rules must be a list")
seen_rule_ids: set[str] = set()
for ri, rule in enumerate(rules):
if not isinstance(rule, dict):
raise ValueError(f"derivation payload.rules[{ri}] must be an object")
@@ -251,14 +151,6 @@ class DerivationHandler(ModuleHandler):
raise ValueError(
f"derivation payload.rules[{ri}].id must be a non-empty string"
)
# Rule ids index `instance.disabled_rule_ids` and
# `rule_order_override` — dupes make both ambiguous.
if rule_id in seen_rule_ids:
raise ValueError(
f"derivation payload.rules[{ri}].id {rule_id!r} duplicates "
f"an earlier rule id"
)
seen_rule_ids.add(rule_id)
branches = rule.get("branches")
if not isinstance(branches, list) or not branches:
raise ValueError(
@@ -289,105 +181,22 @@ class DerivationHandler(ModuleHandler):
ctx: Any,
) -> dict[str, str]:
cls.validate_payload(payload)
# Effective seed selection — same pattern wildcard / combine /
# fixed_values use. Locked seed pins {a|b|c} resolution in
# action.value across runs; without lock the chain seed
# propagates per-queue. Falls back to the host ctx's RNG when
# the ctx wasn't built with `__wp_rng__` (legacy callers).
if isinstance(ctx, dict) and "__wp_rng__" in ctx:
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
effective_seed = (
int(locked_seed) if isinstance(locked_seed, (int, float))
else chain_seed
)
module_id = str(ctx.get("__wp_module_id__") or "derivation")
rng = derive_module_rng(effective_seed, module_id)
ctx_local = {**ctx, "__wp_rng__": rng}
resolve_ctx = build_resolve_ctx(ctx_local, surface="derivation")
else:
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
# Tier-D instance overrides (2026-05-10 cycle).
disabled_rule_ids = set(instance.get("disabled_rule_ids") or [])
disabled_branch_keys = set(instance.get("disabled_branch_keys") or [])
action_overrides = instance.get("action_value_overrides") or {}
cond_overrides = instance.get("condition_value_overrides") or {}
# Per-instance rule reorder — sort by rule_order_override list.
# IDs not in the override fall through in their original library
# order at the end (defensive against partial reorders).
rules = list(payload.get("rules", []))
order = instance.get("rule_order_override")
if isinstance(order, list) and order:
ordering = {rid: i for i, rid in enumerate(order)}
tail = len(order)
rules.sort(
key=lambda r: ordering.get(r.get("id"), tail + 1),
)
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
out: dict[str, str] = {}
for rule in rules:
rule_id = rule.get("id", "")
if rule_id in disabled_rule_ids:
continue
for rule in payload.get("rules", []):
applied = False
for bi, branch in enumerate(rule.get("branches", [])):
# 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 branch_carrier_key(rule_id, bi) in disabled_branch_keys:
continue
# Condition-value override per branch index.
cond = branch.get("condition", {})
cond_override = (
cond_overrides.get(rule_id, {}).get(str(bi))
if isinstance(cond_overrides.get(rule_id), dict)
else None
)
if isinstance(cond_override, str):
cond = {**cond, "value": cond_override}
if _match_condition(cond, ctx):
# Action-value override per branch index.
action = branch.get("action", {})
action_override = (
action_overrides.get(rule_id, {}).get(str(bi))
if isinstance(action_overrides.get(rule_id), dict)
else None
)
if isinstance(action_override, str):
action = {**action, "value": action_override}
pair = _apply_action(
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, bi)
)
for branch in rule.get("branches", []):
if _match_condition(branch.get("condition", {}), ctx):
pair = _apply_action(branch.get("action", {}), ctx, resolve_ctx)
if pair is not None:
out[pair[0]] = pair[1]
applied = True
break
if not applied:
# ELSE skip when listed 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):
action = else_clause.get("action", {})
# ELSE action-value override under `else` key.
action_override = (
action_overrides.get(rule_id, {}).get("else")
if isinstance(action_overrides.get(rule_id), dict)
else None
)
if isinstance(action_override, str):
action = {**action, "value": action_override}
pair = _apply_action(
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, "else")
else_clause.get("action", {}), ctx, resolve_ctx
)
if pair is not None:
out[pair[0]] = pair[1]
+5 -166
View File
@@ -1,90 +1,14 @@
"""Fixed-values module resolver.
Two-tier model mirroring wildcard:
- `payload.values` = library-truth (immutable while picked).
- `instance.values_overrides` = optional full-replacement list of
`{id, name, value}` written by the modal when the user edits a
library-tracked fixed_values.
Per-value syntax resolution (Phase: combine v2 + syntax parity cycle):
each emitted value passes through resolve_text with surface="fixed_values"
so {a|b|c} alternations + {N$$sep$$...} repeats + escapes resolve.
$var reads + @{uuid} refs are gated off — the surface is a binding
PRODUCER, not a consumer. Lenient mode renders unsupported tokens as
literal text + emits warnings (see engine/syntax/resolve.py).
Seed lock: instance.locked_seed pins {a|b|c} resolution per instance
via the shared engine.modules._seed.derive_module_rng helper, keyed by
the module id when present (ctx["__wp_module_id__"]).
"""
"""Fixed-values module resolver. Emits one binding per named value."""
from __future__ import annotations
import re
from typing import Any
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
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
_MAX_IDENT_LEN = 64
class FixedValuesHandler(ModuleHandler):
type_id = "fixed_values"
@classmethod
def validate_payload(cls, payload: dict[str, Any]) -> None:
if not isinstance(payload, dict):
raise ValueError("fixed_values payload must be an object")
values = payload.get("values")
if not isinstance(values, list):
raise ValueError("fixed_values payload.values must be a list")
seen_names: set[str] = set()
for i, v in enumerate(values):
if not isinstance(v, dict):
raise ValueError(
f"fixed_values payload.values[{i}] must be an object"
)
name = v.get("name")
if name is not None and not isinstance(name, str):
raise ValueError(
f"fixed_values payload.values[{i}].name must be a string"
)
# Empty name is allowed (resolver skips those entries) but a
# non-empty name must be a valid identifier — it gets emitted
# as a $var into the runtime ctx.
if isinstance(name, str) and name:
if len(name) > _MAX_IDENT_LEN:
raise ValueError(
f"fixed_values payload.values[{i}].name must be at most "
f"{_MAX_IDENT_LEN} chars (got {len(name)})"
)
if name.startswith("__"):
raise ValueError(
f"fixed_values payload.values[{i}].name must not start "
f"with '__' (reserved for engine-internal keys)"
)
if not _IDENT_RE.match(name):
raise ValueError(
f"fixed_values payload.values[{i}].name {name!r} is not a valid identifier"
)
# Names must be unique across rows — two rows with the
# same name silently last-write-wins at runtime, hiding
# the earlier row's value.
if name in seen_names:
raise ValueError(
f"fixed_values payload.values[{i}].name {name!r} "
f"duplicates an earlier row"
)
seen_names.add(name)
value = v.get("value", "")
if not isinstance(value, str):
raise ValueError(
f"fixed_values payload.values[{i}].value must be a string"
)
@classmethod
def resolve(
cls,
@@ -92,100 +16,15 @@ class FixedValuesHandler(ModuleHandler):
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
# Values source-of-truth: instance.values_overrides wins when
# set + non-empty, else payload.values. Existing two-tier model
# preserved.
overrides = instance.get("values_overrides")
if isinstance(overrides, list) and overrides:
values: list[dict[str, Any]] = [v for v in overrides if isinstance(v, dict)]
else:
# Non-None but non-list (string/dict/number) is malformed
# state — almost certainly a workflow saved by an old SPA
# or hand-edited JSON. Surface a warning so the user sees
# their edits were dropped, instead of silently reverting
# to library values without explanation.
if overrides is not None and not isinstance(overrides, list):
warnings = None
if ctx is not None:
try:
warnings = ctx.get("__wp_warnings__") if hasattr(ctx, "get") else None
except Exception:
warnings = None
if isinstance(warnings, list):
warnings.append({
"type": "fixed_values_overrides_malformed",
"severity": "warn",
"module_id": "",
"source_field": "instance.values_overrides",
"position": 0,
"token_index": None,
"detail": {
"got_type": type(overrides).__name__,
"fell_back_to": "payload.values",
},
"message": (
"fixed_values.instance.values_overrides was "
f"{type(overrides).__name__}, expected list — "
"fell back to library payload.values"
),
})
values = list(payload.get("values", []))
values: list[dict[str, Any]] = list(payload.get("values", []))
enabled = instance.get("enabled_options")
if enabled is not None:
allowed = set(enabled)
values = [v for v in values if v.get("id") in allowed]
# Syntax-aware emission path. When ctx is missing required keys
# for build_resolve_ctx (legacy callers passing None / minimal
# dicts), fall back to raw emission so existing tests + workflows
# keep working.
if not _ctx_supports_resolve(ctx):
out: dict[str, str] = {}
for v in values:
name = (v.get("name") or "").strip()
if not name:
continue
out[name] = str(v.get("value", ""))
return out
# Effective seed selection (mirrors WildcardHandler + CombineHandler):
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
if isinstance(locked_seed, (int, float)):
effective_seed = int(locked_seed)
else:
effective_seed = chain_seed
# Use module id as the per-module RNG key when ctx carries one;
# fall back to "fixed_values" literal so two co-resident
# fixed_values modules in the same chain still derive distinct
# RNG streams from the same seed (independence property).
module_key = str(ctx.get("__wp_module_id__") or "fixed_values")
rng = derive_module_rng(effective_seed, module_key)
ctx_local = {**ctx, "__wp_rng__": rng}
resolve_ctx = build_resolve_ctx(ctx_local, surface="fixed_values")
out_resolved: dict[str, str] = {}
out: dict[str, str] = {}
for v in values:
name = (v.get("name") or "").strip()
if not name:
continue
raw = str(v.get("value", ""))
out_resolved[name] = resolve_text(raw, resolve_ctx)
return out_resolved
def _ctx_supports_resolve(ctx: Any) -> bool:
"""build_resolve_ctx requires __wp_rng__ + __wp_warnings__ in ctx.
Legacy test/usage passes None or minimal dicts — detect those and
skip the syntax-resolution path so behavior matches pre-rewrite.
"""
if ctx is None:
return False
if not hasattr(ctx, "__getitem__"):
return False
try:
return "__wp_rng__" in ctx and "__wp_warnings__" in ctx
except (TypeError, KeyError):
return False
out[name] = str(v.get("value", ""))
return out
+99
View File
@@ -0,0 +1,99 @@
"""Pipeline module resolver — runs an ordered list of sub-modules.
Each step references a module in the library by id. The repository is
threaded through ``ctx["_repo"]``; without it the handler can still validate
its payload but ``resolve`` will raise ``RuntimeError`` because it cannot load
sub-modules. Steps marked ``enabled=False`` are skipped.
"""
from __future__ import annotations
from typing import Any
from engine.modules.dispatcher import ModuleHandler, resolve_module
def _ctx_get(ctx: Any, name: str, default: Any = None) -> Any:
if ctx is None:
return default
getter = getattr(ctx, "get", None)
if callable(getter):
try:
return getter(name, default)
except TypeError:
try:
return getter(name)
except Exception:
return default
try:
if name in ctx: # type: ignore[operator]
return ctx[name] # type: ignore[index]
except Exception:
return default
return default
class PipelineHandler(ModuleHandler):
"""Composes other modules into an ordered execution list."""
type_id = "pipeline"
@classmethod
def validate_payload(cls, payload: dict[str, Any]) -> None:
if not isinstance(payload, dict):
raise ValueError("pipeline payload must be an object")
steps = payload.get("steps")
if not isinstance(steps, list):
raise ValueError("pipeline payload.steps must be a list")
for i, step in enumerate(steps):
if not isinstance(step, dict):
raise ValueError(f"pipeline payload.steps[{i}] must be an object")
module_id = step.get("module_id")
if not isinstance(module_id, str) or not module_id:
raise ValueError(
f"pipeline payload.steps[{i}].module_id must be a non-empty string"
)
step_id = step.get("id")
if not isinstance(step_id, str) or not step_id:
raise ValueError(
f"pipeline payload.steps[{i}].id must be a non-empty string"
)
enabled = step.get("enabled", True)
if not isinstance(enabled, bool):
raise ValueError(
f"pipeline payload.steps[{i}].enabled must be a boolean"
)
instance = step.get("instance", {})
if not isinstance(instance, dict):
raise ValueError(
f"pipeline payload.steps[{i}].instance must be an object"
)
@classmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
cls.validate_payload(payload)
repo = _ctx_get(ctx, "_repo", None)
if repo is None:
raise RuntimeError(
"pipeline handler requires ctx['_repo'] to load sub-modules"
)
out: dict[str, str] = {}
for step in payload.get("steps", []):
if not step.get("enabled", True):
continue
module_id = step["module_id"]
row = repo.get(module_id)
sub_snapshot = {
"type": row["type"],
"payload": row["payload"],
"instance": step.get("instance", {}),
}
bindings = resolve_module(sub_snapshot, ctx)
if bindings:
out.update(bindings)
return out
+18 -294
View File
@@ -1,5 +1,4 @@
"""Snapshot freezing, content hashing, legacy-shape coercion, and transitive
ref walking.
"""Snapshot freezing, content hashing, and legacy-shape coercion.
A *snapshot* is the JSON shape embedded in a ComfyUI workflow's WP_Context
node when a user picks a library row. The snapshot is decoupled from the
@@ -13,94 +12,17 @@ from __future__ import annotations
import hashlib
import json
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from typing import Any, Literal, TypedDict
from typing import Any
from engine._utils import now_iso as _now_iso
from engine.syntax.tokenize import tokenize_text
from engine.syntax.types import TokenKind
class _SourceUser(TypedDict):
kind: Literal["user"]
class _SourceDep(TypedDict):
kind: Literal["dep"]
parent_uuids: list[str]
SnapshotSource = _SourceUser | _SourceDep
class SnapshotEntry(TypedDict):
"""Canonical shape stored in `__wp_catalog__` and embedded in
WP_Context workflow JSON. Spec §2.4 — pinned, do not rename fields.
`payload_hash` covers `payload` only (not name/description/tags) so
a rename does not flip drift state. `source` records why the entry
is in the embed: explicit user pick vs. transitive dep of one or
more picks (multi-parent supported)."""
snapshot_version: Literal[1]
uuid: str # 8 hex chars
type: str # ModuleType — wildcards-only in catalog (spec §2.7)
name: str # display label
payload: dict[str, Any]
payload_hash: str # SHA-256 of payload
source: SnapshotSource
def _fresh_instance() -> dict[str, Any]:
"""Default per-snapshot instance overrides. Fresh dict per call.
Invariant: every field defaults to ``None``. The frontend
"modified-state" predicate is ``value != null``, so any non-None
sentinel (e.g. ``""`` for a string field, ``False`` for a bool)
would falsely light up the orange dot, force the modal to default
to the Instance tab, and tag the section as modified — even on a
pristine snapshot. Engine readers already tolerate ``None`` (see
``wildcard_handler.py:216`` for the binding fallback chain and
``pipeline.py:160`` for the internal-flag coercion), so we keep a
single contract: ``None`` everywhere = "no override, use library
default".
"""
"""Default per-snapshot instance overrides. Fresh dict per call."""
return {
"variable_binding": None,
"variable_binding": "",
"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`
# is currently identical to `random` engine-side; the modal
# uses it as a UX framing for "manual subset via enabled_options".
"mode": None,
"pinned_option_id": None,
# Lock — when set, derives a stable per-instance RNG seed
# independent of the chain's `__wp_node_seed__`. Means the
# wildcard's pick stays the same value across runs even when
# the user cycles the Context node's seed. `None` = roll with
# the chain seed (default).
"locked_seed": None,
# Internal — when True, every binding this module produces is
# marked engine-only by `PipelineEngine.run` (via
# `__wp_internal_flags__`). Downstream modules still see the
# value; the public socket payload doesn't. ``None`` is coerced
# to ``False`` at the read site, so the default stays "off".
"internal": None,
"disabled_rule_ids": None,
"disabled_exception_keys": None,
"disabled_matrix_cells": None,
}
@@ -130,54 +52,28 @@ def coerce_legacy_module(raw: dict[str, Any]) -> dict[str, Any]:
"""Upgrade a legacy module dict to canonical snapshot shape.
Idempotent: returns ``raw`` unchanged if it already has both
``library_id`` and ``instance`` keys (the legacy "frozen" shape).
SPA-picked modules don't carry ``library_id`` (the picked uuid IS
the library id, stored as ``id``), but they may carry an
``instance`` dict with user overrides (option enable/disable,
weight overrides). Merge those onto the fresh-instance defaults
instead of stomping them with ``_fresh_instance()`` below.
``library_id`` and ``instance`` keys.
"""
if "instance" in raw and "library_id" in raw:
return raw
# Capture caller-supplied instance early so we can merge it with
# defaults once the rest of the snapshot is built. Missing → empty.
raw_instance = raw.get("instance")
user_instance = raw_instance if isinstance(raw_instance, dict) else {}
payload: dict[str, Any]
if raw.get("type") == "fixed_values":
# Two shapes can land here:
#
# 1. Inline-created (legacy): `entries: [{variable_name, value}]`
# with no `payload.values`. Translate entries → values.
# 2. Library-picked (post-5.5.4): `payload: {values: [...]}` with
# an empty `entries: []` (the field is always present so the
# ContextWidget conflict-scanner doesn't have to null-check).
# Use `payload` directly — translating its empty entries
# would zero out a perfectly good values list.
raw_payload = raw.get("payload") or {}
legacy_entries = raw.get("entries") or []
if legacy_entries and not raw_payload.get("values"):
payload = {
"values": [
{
"id": f"val_{i:04x}",
"name": e.get("variable_name", ""),
"value": e.get("value", ""),
}
for i, e in enumerate(legacy_entries)
],
}
else:
payload = raw_payload
if raw.get("type") == "fixed_values" and "entries" in raw:
legacy_entries = raw.get("entries", [])
payload = {
"values": [
{
"id": f"val_{i:04x}",
"name": e.get("variable_name", ""),
"value": e.get("value", ""),
}
for i, e in enumerate(legacy_entries)
],
}
else:
payload = raw.get("payload", {})
meta = raw.get("meta") or {}
instance = _fresh_instance()
instance.update(user_instance)
return {
"library_id": raw.get("library_id"),
"library_snapshot_at": raw.get("library_snapshot_at"),
@@ -186,177 +82,5 @@ def coerce_legacy_module(raw: dict[str, Any]) -> dict[str, Any]:
"name": raw.get("name") or meta.get("name", ""),
"category_id": raw.get("category_id"),
"payload": payload,
"instance": instance,
"instance": _fresh_instance(),
}
# ---------------------------------------------------------------------------
# Transitive-ref walker (spec §2.7, §2.8, §2.10, §6)
# ---------------------------------------------------------------------------
class WalkOverflow(TypedDict):
"""Walker-recorded anomaly. Returned alongside the partial snapshot
bundle; never aborts the walk. Spec §2.8."""
uuid: str
reason: Literal["max_depth", "cycle_detected", "missing_target"]
@dataclass
class WalkResult:
"""Return value of `walk_transitive_refs`. Two fields, two concerns:
`snapshots` is the materialised catalog dict; `walk_overflow` lists
every uuid the walker could not include (cycle, depth, miss)."""
snapshots: dict[str, SnapshotEntry] = field(default_factory=dict)
walk_overflow: list[WalkOverflow] = field(default_factory=list)
def _extract_at_uuid_refs(payload: dict[str, Any]) -> set[str]:
"""Collect all ``@{8hex}`` refs referenced inside a payload's
string-typed leaves. Uses ``tokenize_text`` exclusively — only
canonical 8-hex-char refs (spec §2.4) are followed.
Surface-agnostic: collects from any string in the dict/list tree;
the caller decides which module types have their payloads walked
(spec §2.7)."""
refs: set[str] = set()
def _scan(value: Any) -> None:
if isinstance(value, str):
for tok in tokenize_text(value):
if tok.kind == TokenKind.REF:
uid = (tok.meta or {}).get("uuid")
if isinstance(uid, str):
refs.add(uid)
elif isinstance(value, dict):
for v in value.values():
_scan(v)
elif isinstance(value, list):
for item in value:
_scan(item)
_scan(payload)
return refs
def _row_to_snapshot_entry(
row: dict[str, Any], source: SnapshotSource,
) -> SnapshotEntry:
"""Convert a repository row dict to a canonical SnapshotEntry.
Post migration 004 the row's `id` IS the canonical 8-hex uuid;
there is no separate `uuid` field. The snapshot schema still
names the field `uuid` because that's the published wire format
embedded in workflow JSON — we just source it from `row["id"]`.
"""
return {
"snapshot_version": 1,
"uuid": row["id"],
"type": row["type"],
"name": row["name"],
"payload": row["payload"],
"payload_hash": row.get("payload_hash") or payload_hash(row["payload"]),
"source": source,
}
def walk_transitive_refs(
root_uuids: Iterable[str],
*,
fetch_module: Callable[[str], dict[str, Any] | None],
max_depth: int = 8,
) -> WalkResult:
"""Lazy transitive walker. Spec §2.10.
Starts from ``root_uuids``, follows ``@{8hex}`` refs in wildcard payloads
only (spec §2.7), and returns a ``WalkResult`` containing every reachable
SnapshotEntry plus a list of overflow records (cycle, depth, missing).
Cycle detection uses a per-path ancestor frozenset carried with each BFS
queue entry. When we pop ``(uuid, depth, parent, ancestors)`` and find
``uuid`` already in ``ancestors``, we record ``cycle_detected`` and skip
without emitting the node. This means the node that *caused* the cycle
reference is already snapshotted (it was emitted when we first reached it
on a shorter path), so both sides of a two-node cycle appear in snapshots.
Engine isolation invariant: this function does NOT import from ``engine.db``
or ``wp_api``; the caller injects a ``fetch_module(uuid) -> row | None``
callable. WP_Context graph nodes never call this — they receive a
pre-walked dict embedded in workflow JSON.
"""
result = WalkResult()
# parent_map: uuid -> list of direct parent uuids (accumulated across BFS)
parent_map: dict[str, list[str]] = {}
seen_overflow: set[tuple[str, str]] = set()
def _record_overflow(uuid: str, reason: str) -> None:
key = (uuid, reason)
if key in seen_overflow:
return
seen_overflow.add(key)
result.walk_overflow.append(
{"uuid": uuid, "reason": reason}, # type: ignore[arg-type]
)
# Queue items: (uuid, depth, parent_uuid_or_None, ancestor_frozenset)
# ancestor_frozenset contains all uuids on the path from a root to *before*
# this node — used to detect back-edges (cycles).
queue: list[tuple[str, int, str | None, frozenset[str]]] = []
for u in root_uuids:
queue.append((u, 0, None, frozenset()))
while queue:
uuid, depth, parent, ancestors = queue.pop(0)
# Track parentage before any short-circuit so multi-parent deps
# accumulate even if we have already snapshotted the child.
if parent is not None:
parent_map.setdefault(uuid, [])
if parent not in parent_map[uuid]:
parent_map[uuid].append(parent)
# Cycle: this uuid appears on the path from a root to here.
if uuid in ancestors:
_record_overflow(uuid, "cycle_detected")
continue
# Already snapshotted on a shorter BFS path — skip emission but
# parent tracking above already ran, so multi-parent deps accumulate.
if uuid in result.snapshots:
continue
if depth > max_depth:
_record_overflow(uuid, "max_depth")
continue
row = fetch_module(uuid)
if row is None:
_record_overflow(uuid, "missing_target")
continue
# Determine source based on whether this uuid has a parent.
if parent is None:
source: SnapshotSource = {"kind": "user"}
else:
source = {"kind": "dep", "parent_uuids": list(parent_map[uuid])}
result.snapshots[uuid] = _row_to_snapshot_entry(row, source)
# Only walk wildcard payloads — spec §2.7.
if row.get("type") != "wildcard":
continue
new_ancestors = ancestors | {uuid}
for child_uuid in _extract_at_uuid_refs(row["payload"]):
queue.append((child_uuid, depth + 1, uuid, new_ancestors))
# After walk: re-stamp every dep's parent list (BFS may have accumulated
# additional parents after first emission).
for uuid, parents in parent_map.items():
if uuid not in result.snapshots:
continue
existing = result.snapshots[uuid]
if existing["source"]["kind"] == "dep":
existing["source"]["parent_uuids"] = list(parents)
return result
+3 -688
View File
@@ -3,59 +3,14 @@
Each option's `value` may contain $var, @{uuid}, {a|b|c}, {N$$sep$$...}, and
escapes. After picking the option, resolve_text expands the syntax against
the runtime context.
Constraint integration: when the chain has registered constraints in
``ctx["__wp_constraints__"]`` whose ``target_wildcard_id`` matches the
current module's id, this handler applies the constraint matrix +
exception list to the option pool's weights before picking. The source
wildcard MUST have already been resolved earlier in the chain — picks
are recorded in ``ctx["__wp_picks__"][module_id]`` so a downstream
constraint-aware target can look them up. If a constraint fires
against a source that hasn't been picked yet (graph ordering bug), the
constraint is skipped and an ``unknown_constraint_source`` warning is
emitted.
"""
from __future__ import annotations
import re
from typing import Any
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
# `_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:
@@ -74,454 +29,9 @@ def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
return options[-1]
def _apply_constraint_to_options(
options: list[dict[str, Any]],
constraint: dict[str, Any],
source_pick: dict[str, Any],
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(s).
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).
`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`.
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 []
# 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 []),
}]
# 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:
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))
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_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):
entry = {
"value": chosen.get("value"),
"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):
raise ValueError("wildcard payload must be an object")
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):
if not isinstance(opt, dict):
raise ValueError(f"wildcard payload.options[{i}] must be an object")
# Option ids must be unique across the pool — `pinned_option_id`
# lookup, `enabled_options` toggles, and `option_weights`
# overrides all key by id, so dupes make those features
# nondeterministic.
opt_id = opt.get("id")
if not isinstance(opt_id, str) or not opt_id:
raise ValueError(
f"wildcard payload.options[{i}].id must be a string"
)
if opt_id in seen_ids:
raise ValueError(
f"wildcard payload.options[{i}].id {opt_id!r} duplicates "
f"an earlier option id"
)
seen_ids.add(opt_id)
value = opt.get("value", "")
is_null = bool(opt.get("is_null"))
if is_null:
null_count += 1
# Null option contract: value must be empty string, no
# sub_category. The flag is the source of truth — see
# docs/superpowers/specs/2026-05-24-null-wildcard-option-design.md.
if value != "":
raise ValueError(
f"wildcard payload.options[{i}] null option "
f"{opt_id!r} must have empty value (got {value!r})"
)
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_categories (got "
f"{null_subs!r})"
)
else:
if not isinstance(value, str) or value == "":
raise ValueError(
f"wildcard payload.options[{i}].value must be a "
f"non-empty string (use is_null=True for the null option)"
)
weight = opt.get("weight", 1)
if not isinstance(weight, (int, float)) or isinstance(weight, bool):
raise ValueError(
f"wildcard payload.options[{i}].weight must be a number"
)
if float(weight) < 0:
raise ValueError(
f"wildcard payload.options[{i}].weight must not be negative"
)
subs = opt.get("sub_categories", [])
if not isinstance(subs, list):
raise ValueError(
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 "
f"(found {null_count})"
)
binding = payload.get("var_binding")
if binding is not None:
if not isinstance(binding, str):
raise ValueError("wildcard payload.var_binding must be a string")
# Empty string used to slip through (the old guard was
# `if binding and not match`, which short-circuited on
# falsy). Reject explicitly + bound length + reject the
# `__dunder` prefix (collides with engine-internal key
# convention — see engine/modules/types.py:strip_internals).
if not binding:
raise ValueError("wildcard payload.var_binding must not be empty")
if len(binding) > _MAX_IDENT_LEN:
raise ValueError(
f"wildcard payload.var_binding must be at most "
f"{_MAX_IDENT_LEN} chars (got {len(binding)})"
)
if binding.startswith("__"):
raise ValueError(
"wildcard payload.var_binding must not start with '__' "
"(reserved for engine-internal keys)"
)
if not _IDENT_RE.match(binding):
raise ValueError(
f"wildcard payload.var_binding {binding!r} is not a valid identifier"
)
@classmethod
def resolve(
cls,
@@ -534,47 +44,6 @@ class WildcardHandler(ModuleHandler):
return {}
options: list[dict[str, Any]] = list(payload.get("options", []))
# `pinned` mode short-circuits the RNG: always pick the option
# whose id matches `pinned_option_id`. Falls through to random
# (with a warning) if the pinned id can't be found — handles
# the case where the user pinned an option that the library
# has since removed. Honors enabled_options + weights normally
# in every other mode.
mode = instance.get("mode")
if mode == "pinned":
pinned_id = instance.get("pinned_option_id")
pinned = next((o for o in options if o.get("id") == pinned_id), None)
if pinned is not None:
# Track the pinned pick the same way as a random pick —
# downstream constraint-aware wildcards need source
# info regardless of how the source resolved its option.
_record_pick(ctx, pinned)
value = str(pinned.get("value", ""))
if not value:
return {binding: ""}
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
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 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")
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:
allowed = set(enabled)
@@ -583,169 +52,15 @@ class WildcardHandler(ModuleHandler):
if not options:
return {binding: ""}
# Per-instance weight overrides — replaces (not multiplies) the
# library weight when the option's id appears in the override
# map. Missing/non-numeric overrides leave the library weight
# untouched. Shallow-copy each option so we don't mutate the
# underlying snapshot payload (which is shared across runs).
weight_overrides = instance.get("option_weights")
if isinstance(weight_overrides, dict) and weight_overrides:
adjusted: list[dict[str, Any]] = []
for o in options:
oid = o.get("id")
if oid in weight_overrides:
try:
new_weight = float(weight_overrides[oid])
except (TypeError, ValueError):
adjusted.append(o)
continue
o = {**o, "weight": new_weight}
adjusted.append(o)
options = adjusted
# Constraint application — each constraint targeting THIS module
# adjusts the option pool's weights based on what its source
# wildcard already picked. Apply logic extracted to
# `engine.modules._constraints` so the nested-`@{}` path in
# `engine.syntax.resolve` shares the same implementation.
from engine.modules._constraints import (
apply_constraints_for_target,
warn_excludes_all,
)
my_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
any_constraint_applied = False
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
# 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__"],
hits=hits, firing_uid=firing_uid,
)
if any_constraint_applied:
warn_excludes_all(options, my_id or "", ctx["__wp_warnings__"])
# Effective seed selection:
# - locked_seed when present → reproducible per-instance
# - chain seed (`__wp_node_seed__`) otherwise
# Both paths feed the SAME `_derive_module_rng(seed, binding)`,
# so locking with `locked_seed = chain_seed` reproduces the
# unlocked pick exactly. This is the contract the lock UX
# relies on: the user sees a roll, locks it, captures the
# current chain seed, future runs reproduce the same roll.
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
if locked_seed is not None:
try:
effective_seed = int(locked_seed)
except (TypeError, ValueError):
effective_seed = chain_seed
locked_seed = None
else:
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)}
rng = ctx["__wp_rng__"]
chosen = _pick_weighted(options, rng)
if chosen is None:
return {binding: ""}
# Record this wildcard's pick so a downstream constraint-aware
# wildcard can read it. Done BEFORE resolve_text so even an
# empty-string-value pick is registered (matters if a
# constraint exception keys on the literal empty pick value).
_record_pick(ctx, chosen)
value = str(chosen.get("value", ""))
if not value:
# Empty / null pick — no ref to resolve.
return {binding: ""}
# Swap `ctx['__wp_rng__']` to the per-module rng for the
# duration of `resolve_text`. The swap is UNCONDITIONAL —
# both locked and unlocked paths run nested resolution
# against the per-module rng — because:
# - it's the only way locking can reproduce the unlocked
# pick (same effective_seed → same per-module rng →
# same pick AND same nested rolls).
# - it isolates wildcards from each other within one chain
# run: inline `{a|b|c}` picks no longer depend on
# module ordering / sibling picks.
# `build_resolve_ctx` snapshots the rng reference at call
# time, so it must be built INSIDE the swap.
saved_rng = ctx.get("__wp_rng__")
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
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
resolved = resolve_text(value, resolve_ctx)
return {binding: resolved}
+9 -538
View File
@@ -10,8 +10,6 @@ from __future__ import annotations
import dataclasses
import logging
import random
import re
from typing import Any
from engine.context import Context
from engine.modules import Module
@@ -20,221 +18,6 @@ from engine.modules.snapshot import coerce_legacy_module
logger = logging.getLogger(__name__)
# `@{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:
"""Read a field from a module that may be a dict or an object."""
if isinstance(module, dict):
return module.get(key)
return getattr(module, key, None)
def _target_present_in_chain(
target_uuid: str,
modules: list[Any],
catalog: dict[str, Any] | None,
) -> bool:
"""True when `target_uuid` has SOME representation in the chain —
either a direct wildcard instance (a module whose id == target) or a
carrier (any wildcard, in the module list OR the catalog, with an
`@{target}` ref in one of its option values).
Used purely to pick the right never_applied warning wording: if the
target is present-but-unclaimed the cause is ordering / a skipped
roll; if it's absent the cause is a wrong uuid. Best-effort + cheap
(library volumes are dozens of modules, single-digit refs each)."""
def _options_of(payload: Any) -> list:
if isinstance(payload, dict):
opts = payload.get("options")
return opts if isinstance(opts, list) else []
return []
def _carries(payload: Any) -> bool:
for opt in _options_of(payload):
val = opt.get("value") if isinstance(opt, dict) else None
if isinstance(val, str) and any(
m.group(1) == target_uuid for m in _PIPELINE_REF_RE.finditer(val)
):
return True
return False
for m in modules:
if _module_field(m, "type") != "wildcard":
continue
if _module_field(m, "id") == target_uuid:
return True
if _carries(_module_field(m, "payload")):
return True
if isinstance(catalog, dict):
for entry in catalog.values():
payload = entry.get("payload") if isinstance(entry, dict) else None
if _carries(payload):
return True
return False
# Characters that break the engine ref tokenizer's `#name` segment
# (`[^#:}@{]*`). Anything in this set gets replaced with a space when
# embedding a cached name into a `@{uuid#name}` ref so a stray apostrophe
# or curly brace can't shatter the warning's parse on the frontend.
_REF_NAME_FORBIDDEN = re.compile(r"[#:}@{]")
def _build_ref_name_suffix(name: Any) -> str:
"""Return the `#name` suffix for a cached ref token, or `""` when no
usable name was provided. Used by the `constraint_never_applied`
warning emitter so chips render a readable label even when the
workflow is imported on a machine whose library lacks the referenced
modules — the cached `#name` is the only fallback RefChip has in
that scenario."""
if not isinstance(name, str):
return ""
cleaned = _REF_NAME_FORBIDDEN.sub(" ", name).strip()
return f"#{cleaned}" if cleaned else ""
def _extract_static_meta(
module_raw: Any,
) -> dict[str, Any]:
"""Pull metadata fields out of a module dict/dataclass that the
debug viewer surfaces independently of execution success.
Returns a dict that may contain:
- ``binding`` — the variable a single-binding module declares
(``wildcard``/``combine``/``derivation``). Empty for
``constraint`` (no binding) and ``fixed_values`` (multi-binding
— entries listed via ``writes`` after run instead).
- ``bindings`` — list of variable names a multi-binding module
declares (``fixed_values``). Used when the module is disabled
/ errors before writes get populated.
- ``internal`` — bool, true when ``instance.internal`` is set;
every binding the module would produce is engine-only.
- ``seed_locked`` — bool, true when ``instance.locked_seed`` is
a number; the module rolls with that seed not the chain seed.
- ``constraint_source`` / ``constraint_target`` — the source +
target wildcard uuids on a ``constraint`` payload, so the
debug viewer can label the row as `$src → $tgt`.
Robust to malformed shapes — every read is type-checked, every
failure path returns an empty dict so the trace path doesn't
crash on a bad workflow JSON.
"""
if isinstance(module_raw, dict):
m_type = module_raw.get("type", "") or ""
inst = module_raw.get("instance", {})
payload = module_raw.get("payload", {})
entries = module_raw.get("entries", [])
else:
m_type = getattr(module_raw, "type", "") or ""
inst = getattr(module_raw, "instance", {})
payload = getattr(module_raw, "payload", {})
entries = getattr(module_raw, "entries", [])
inst = inst if isinstance(inst, dict) else {}
payload = payload if isinstance(payload, dict) else {}
locked_seed = inst.get("locked_seed")
is_locked = isinstance(locked_seed, (int, float))
meta: dict[str, Any] = {
"internal": bool(inst.get("internal", False)),
"seed_locked": is_locked,
}
# When the module declares a locked_seed, surface the value in the
# trace row regardless of execution status. Pre-fix only ok-status
# rows got `seed: effective_seed` set; disabled / errored rows had
# no seed, so the user couldn't see what seed a locked-but-disabled
# module would have used.
if is_locked:
meta["seed"] = int(locked_seed)
if m_type == "fixed_values":
# fixed_values declares its variables in `entries` (or
# `payload.values` for the unified-list shape used by the SPA).
# Either way, surface every variable name so the disabled
# trace row can show *what* would have been written.
seen: list[str] = []
for source in (entries, payload.get("entries"), payload.get("values")):
if not isinstance(source, list):
continue
for e in source:
if isinstance(e, dict):
name = e.get("variable_name") or e.get("name")
if isinstance(name, str) and name and name not in seen:
seen.append(name)
if seen:
meta["bindings"] = seen
elif m_type == "constraint":
src = payload.get("source_wildcard_id")
tgt = payload.get("target_wildcard_id")
if isinstance(src, str) and src:
meta["constraint_source"] = src
if isinstance(tgt, str) and tgt:
meta["constraint_target"] = tgt
elif m_type == "combine":
# Combine writes to `payload.output_var` (instance may override
# via `variable_binding` for the picker-driven flow). Surface
# the resolved name so disabled-combine rows in the debug
# viewer show `$output_var (disabled)` instead of a uuid.
b = (
inst.get("variable_binding")
or payload.get("output_var")
or payload.get("var_binding")
or ""
)
if isinstance(b, str):
b = b.lstrip("$").strip()
if b:
meta["binding"] = b
elif m_type == "derivation":
# Derivation can write to MULTIPLE target_vars (one per branch
# `action.target_var`, plus the `else.action.target_var`).
# Collect every declared target so the disabled-derivation row
# shows all of them in the debug viewer instead of a uuid.
seen_targets: list[str] = []
rules = payload.get("rules")
if isinstance(rules, list):
for rule in rules:
if not isinstance(rule, dict):
continue
for branch in rule.get("branches", []) or []:
if not isinstance(branch, dict):
continue
action = branch.get("action")
if isinstance(action, dict):
target = action.get("target_var")
if isinstance(target, str):
target = target.lstrip("$").strip()
if target and target not in seen_targets:
seen_targets.append(target)
else_block = rule.get("else")
if isinstance(else_block, dict):
action = else_block.get("action")
if isinstance(action, dict):
target = action.get("target_var")
if isinstance(target, str):
target = target.lstrip("$").strip()
if target and target not in seen_targets:
seen_targets.append(target)
if seen_targets:
meta["bindings"] = seen_targets
else:
# Wildcard / pipeline / other single-binding modules. Picker
# writes `instance.variable_binding`; legacy snapshots may
# carry `payload.var_binding` instead.
b = inst.get("variable_binding") or payload.get("var_binding") or ""
if isinstance(b, str) and b:
meta["binding"] = b.lstrip("$").strip()
return meta
class PipelineEngine:
"""Runs an ordered list of modules against a context dict."""
@@ -252,69 +35,20 @@ class PipelineEngine:
ctx.setdefault("__wp_warnings__", [])
ctx.setdefault("__wp_trace__", [])
ctx.setdefault("__wp_internal_flags__", {})
# 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.
if isinstance(module, dict):
_module_id = module.get("id", "")
# Per-instance uid distinct from the library uuid `id`.
# Siblings (same library entry instantiated twice) share
# `id` but each has its own `_uid`; trace consumers that
# need per-row state (lock-fallback, per-instance seed
# display) key by `_uid`. Defaults to empty string when
# the entry pre-dates the `_uid` migration.
_module_uid = module.get("_uid", "") or ""
_module_type_raw = module.get("type", None) or ""
_module_enabled = module.get("enabled", True)
# Display name. Stashed alongside `id` / `_uid` so handlers
# that emit user-facing warnings (notably the constraint
# handler — see `__constraint_library_name__`) can embed
# the name as a cached `#name` ref suffix. That suffix
# survives library + embed-bundle misses (workflows
# 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
# surfaces what the module would have written, so the
# debug viewer can label it `$varname (disabled)`
# instead of falling back to an opaque short-uuid.
meta = _extract_static_meta(module)
if not getattr(module, "enabled", True):
ctx["__wp_trace__"].append({
"id": _module_id,
"_uid": _module_uid,
"type": _module_type_raw,
"id": getattr(module, "id", ""),
"type": getattr(module, "type", ""),
"enabled": False,
"status": "skipped_disabled",
"writes": [],
"error": None,
**meta,
})
continue
module_type = _module_type_raw
module_type = getattr(module, "type", None) or ""
try:
# coerce_legacy_module expects a dict; convert dataclasses or
# duck-typed objects by reading known attrs via getattr.
@@ -340,50 +74,16 @@ class PipelineEngine:
logger.warning(
"Failed to coerce module %r at index %s: %s", module, index, e
)
meta = _extract_static_meta(module)
ctx["__wp_trace__"].append({
"id": _module_id,
"_uid": _module_uid,
"id": getattr(module, "id", ""),
"type": module_type,
"enabled": True,
"status": "failed",
"writes": [],
"error": {"type": type(e).__name__, "message": str(e)},
**meta,
})
continue
# Stash the active module's id in ctx so handlers that
# need to identify themselves (constraint-aware wildcards
# looking up `__wp_constraints__[*].target_wildcard_id`)
# can read it without reaching into snapshot internals.
# Cleared in the finally below so a stale id doesn't leak
# into the next iteration's resolve call.
#
# Two distinct identifiers:
# - `__wp_current_module_id__` = library uuid (`id`). The
# wildcard handler matches THIS against a constraint's
# `target_wildcard_id` (which is a library uuid).
# - `__wp_current_module_uid__` = per-instance uid (`_uid`).
# 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)
except UnknownModuleType:
@@ -393,14 +93,12 @@ class PipelineEngine:
index,
)
ctx["__wp_trace__"].append({
"id": _module_id,
"_uid": _module_uid,
"id": getattr(module, "id", ""),
"type": module_type,
"enabled": True,
"status": "skipped_unknown_type",
"writes": [],
"error": None,
**meta,
})
continue
except Exception as e:
@@ -410,7 +108,7 @@ class PipelineEngine:
ctx["__wp_warnings__"].append({
"type": "handler_error",
"severity": "error",
"module_id": _module_id,
"module_id": getattr(module, "id", ""),
"source_field": "payload",
"position": 0,
"token_index": None,
@@ -424,34 +122,20 @@ class PipelineEngine:
),
})
ctx["__wp_trace__"].append({
"id": _module_id,
"_uid": _module_uid,
"id": getattr(module, "id", ""),
"type": module_type,
"enabled": True,
"status": "failed",
"writes": [],
"error": {"type": type(e).__name__, "message": str(e)},
**meta,
})
continue
writes = []
# `instance.internal == True` marks every binding the
# module produces as engine-only — they stay in ctx so
# downstream modules can read them, but `strip_internals`
# drops them on the way out so they don't surface on the
# public PIPELINE_CONTEXT socket. Useful for "scratch"
# vars that drive a derivation/combine but shouldn't
# become prompt-text noise.
inst = snapshot.get("instance", {}) if isinstance(snapshot, dict) else {}
mark_internal = bool(inst.get("internal", False)) if isinstance(inst, dict) else False
for var, value in (bindings or {}).items():
key = var.lstrip("$")
before = ctx.get(key)
ctx[key] = value
if mark_internal:
flags = ctx.setdefault("__wp_internal_flags__", {})
flags[key] = True
writes.append({
"variable": key,
"value": value,
@@ -459,226 +143,13 @@ class PipelineEngine:
"overwrite": before is not None and before != value,
})
# `seed` on the trace entry: the effective seed THIS
# module rolled with — `instance.locked_seed` if locked,
# else the chain seed. Surfaces to the frontend via the
# WP_Context node's UI payload so the lock-toggle defaults
# can grab the AUTHORITATIVE value (not the local widget,
# which lies when the seed input is link-driven).
locked = inst.get("locked_seed") if isinstance(inst, dict) else None
if isinstance(locked, (int, float)):
effective_seed = int(locked)
else:
effective_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
ctx["__wp_trace__"].append({
"id": _module_id,
"_uid": _module_uid,
"id": getattr(module, "id", ""),
"type": module_type,
"enabled": True,
"status": "ok",
"writes": writes,
"error": None,
"seed": effective_seed,
**meta,
})
# Drop the active-module markers so they don't leak into the
# public socket payload (both are `__`-prefixed so
# `strip_internals` would filter them, but clearing is cheap and
# keeps post-run ctx introspection tidy).
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)
# 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 []
hits = ctx.get("__wp_constraint_hits__") or {}
catalog = ctx.get("__wp_catalog__")
warned_cids: 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__")
if not cid or hits.get(cid, 0) != 0:
continue
if cid in warned_cids:
continue
warned_cids.add(cid)
# `cid` keys the dedup bookkeeping (per-instance `_uid` so
# two canvas instances of the same library constraint are
# independent). The message text, on the other hand, must
# reference the LIBRARY id — that's the 8-hex uuid the
# SPA/Debug chip resolver looks up in the module catalog
# to render the constraint's name. Falling back to `cid`
# only when the library id is missing (legacy / hand-built
# constraints) keeps the warning informative either way.
library_cid = c.get("__constraint_library_id__") or cid
target_uuid = c.get("target_wildcard_id", "")
# Build `#name` cache suffixes so the chip survives a
# library-missing import. The SPA's chip resolver looks
# the uuid up against the importer's local library — if
# the workflow ships with modules the importer doesn't
# have, the lookup misses and the chip falls back to
# whatever cached name the ref token carries (per
# RefChip.vue's unresolved-with-cached-name path). The
# constraint's name was stashed by its handler; the
# target's name is read from the per-run catalog. Sanitise
# both against the engine's ref tokenizer (`[^#:}@{]*` is
# the allowed name charset; anything else is replaced with
# a space) so a name with stray punctuation can't break
# the warning's parse.
target_name = ""
if isinstance(catalog, dict):
target_row = catalog.get(target_uuid)
if isinstance(target_row, dict):
target_name = target_row.get("name", "") or ""
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.
# 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 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 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
# must stay upstream of the constraint).
message = (
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — its target "
f"@{{{target_uuid}{tgt_suffix}}} is in the chain but every appearance "
f"resolved before this constraint registered, or is "
f"disabled / in a branch this run skipped."
)
else:
message = (
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
f"no @{{{target_uuid}{tgt_suffix}}} wildcard instance or "
f"nested-ref carrier found in this chain "
f"(check the constraint's target uuid)."
)
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
if isinstance(warnings_bucket, list):
warnings_bucket.append({
"type": "constraint_never_applied",
# `warn` (not `info`): an authored constraint that
# silently never fires is almost always either a
# misconfiguration (wrong target uuid, missing
# source/target wildcard in the chain) or a
# disabled / dead branch the user forgot about.
# Either way it deserves the yellow advisory chip,
# not the blue informational one — the constraint
# is doing nothing, and the user should see it.
"severity": "warn",
"module_id": cid,
"source_field": "",
"position": 0,
"token_index": None,
"detail": {
"constraint_id": cid,
"target_wildcard_id": target_uuid,
"source_wildcard_id": c.get("source_wildcard_id", ""),
# `target_present` lets the DebugViewer pick a
# distinct icon/severity later without re-parsing
# the message string.
"target_present": present,
},
# Ids wrapped as `@{uuid}` so the DebugViewer's
# RichTextPreview resolves them to display names
# via the library cache (raw short-uuids would
# otherwise render as plain text).
"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
View File
-183
View File
@@ -1,183 +0,0 @@
"""Type-only validation schema for instance overrides per kind.
Mirrors `src/components/context/editors/_shell.ts:INSTANCE_FIELDS_PER_KIND`
on the engine side. Extending field set: add to BOTH this schema AND the
TS registry. Cross-language parity test (`tests/test_instance_schema_parity.py`)
asserts both sides have identical kind→field mappings.
See: docs/superpowers/specs/2026-05-07-instance-overrides-modal-design.md §7.6
"""
from __future__ import annotations
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]",
]
INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
"wildcard": {
"variable_binding": "string",
"enabled_options": "list[string]",
"option_weights": "dict[string,number]",
# `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
# them as unknown fields with an advisory warning.
},
"fixed_values": {
"values_overrides": "list[dict]",
"enabled_options": "list[string]",
# Added in 2026-05-08 syntax-parity cycle: per-value `{a|b|c}`
# resolution gives fixed_values seed-lock parity with wildcard
# + combine. Locked seed pins the alternation outcome.
"locked_seed": "number",
},
"combine": {
# v2 single-pane modal exposes a richer override surface:
# - template_override: per-instance template string
# - variable_binding: rebind the produced var (mirrors wildcard)
# - locked_seed: pin {a|b|c} resolution
# - internal: hide-from-prompt toggle
"template_override": "string",
"variable_binding": "string",
"locked_seed": "number",
"internal": "boolean",
},
"derivation": {
"disabled_rule_ids": "list[string]",
# Tier-D modal expansion (2026-05-10 cycle):
# - disabled_branch_keys: list of "{rule_id}:{branch_idx}" or
# "{rule_id}:else" entries the engine skips during evaluation
# - action_value_overrides / condition_value_overrides:
# per-rule per-branch value swaps (engine reads override
# before payload value)
# - rule_order_override: list of rule_ids in evaluation order
# - locked_seed + internal: standard runtime fields the
# pipeline already honored on other kinds; surfaced here so
# RESET_FIELDS_PER_KIND + INSTANCE_FIELDS_PER_KIND keep
# parity with what the modal writes.
"disabled_branch_keys": "list[string]",
"action_value_overrides": "dict[string,dict]",
"condition_value_overrides": "dict[string,dict]",
"rule_order_override": "list[string]",
"locked_seed": "number",
"internal": "boolean",
},
"constraint": {
"disabled_exception_keys": "list[string]",
"disabled_matrix_cells": "list[string]",
# Tier-D modal expansion (2026-05-10 cycle):
# - cell_mode_overrides / cell_factor_overrides: per-cell
# value overrides keyed by encode_key([src_subcat, tgt_subcat]).
# Sparse — only override keys present in the dict.
# - exception_mode_overrides / exception_factor_overrides:
# same shape, keyed by encode_key([source_value, target_value]).
# - extra_exceptions: instance-only exception list, appended
# to the library exceptions in resolve. Never written back
# to library.
"cell_mode_overrides": "dict[string,string]",
"cell_factor_overrides": "dict[string,number]",
"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",
},
}
def _matches_type(value: Any, spec: InstanceFieldType) -> bool:
if spec == "string":
return isinstance(value, str)
if spec == "boolean":
return isinstance(value, bool)
if spec == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if spec == "list[string]":
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)
and all(isinstance(k, str) for k in value.keys())
and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value.values())
)
if spec == "dict[string,dict]":
return (
isinstance(value, dict)
and all(isinstance(k, str) for k in value.keys())
and all(isinstance(v, dict) for v in value.values())
)
if spec == "dict[string,string]":
return (
isinstance(value, dict)
and all(isinstance(k, str) for k in value.keys())
and all(isinstance(v, str) for v in value.values())
)
return False
def validate_instance(kind: str, instance: dict[str, Any]) -> list[str]:
"""Returns list of warnings (empty = clean). Does NOT raise — disabled-malformed
items surface as warnings per spec §7.3/§7.4 design. Caller decides escalation."""
schema = INSTANCE_SCHEMAS.get(kind, {})
warnings: list[str] = []
for field, value in instance.items():
if field == "_ui" or field.startswith("__"):
continue # UI scratch / engine-internal namespace ignored
spec = schema.get(field)
if spec is None:
warnings.append(f"unknown instance field: {kind}.{field}")
continue
if value is None:
continue # null = no override, always valid
if not _matches_type(value, spec):
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
-71
View File
@@ -1,71 +0,0 @@
"""Per-iteration seed derivation for WP_ContextLoop.
`derive_loop_seeds` produces the N base seeds emitted by ContextLoop.
`effective_chain_seed` is what the WP_Context wrapper stamps as
`__wp_node_seed__` for each iteration — locked-seed precedence happens
LATER inside per-module handlers, which is why locked modules ignore
the loop automatically (they never read __wp_node_seed__).
Pure Python — zero ComfyUI imports.
"""
import hashlib
MAX_SAFE_SEED = (1 << 50) - 1
"""1_125_899_906_842_623 — matches ComfyUI's frontend randomize cap
(Math.random() * 1125899906842624) so emitted seeds stay copy-pasteable +
visually consistent with stock seed nodes. KSampler accepts up to 64-bit,
but anything past 2^50 falls outside the canvas's typical seed shape and
is unsightly."""
def derive_loop_seeds(base: int, count: int, strategy: str) -> list[int]:
"""Produce `count` base seeds from `base` using the given strategy.
Every emitted seed is masked to ``MAX_SAFE_SEED`` (50 bits) so the
values stay within ComfyUI's frontend randomize range — see the
module docstring for the rationale.
"""
if count <= 1:
return [base & MAX_SAFE_SEED]
if strategy == "sequential":
return [(base + i) & MAX_SAFE_SEED for i in range(count)]
if strategy == "prime_stride":
return [(base + i * 1000003) & MAX_SAFE_SEED for i in range(count)]
if strategy == "hash_index":
out: list[int] = []
for i in range(count):
digest = hashlib.sha256(f"{base}:{i}".encode()).digest()
out.append(int.from_bytes(digest[:8], "big") & MAX_SAFE_SEED)
return out
raise ValueError(f"unknown loop seed strategy: {strategy!r}")
def effective_chain_seed(
*,
widget_seed: int,
seed_override: int | None,
loop_index: int,
) -> int:
"""Compute the chain seed for THIS iteration before module handlers run.
Precedence:
1. ``seed_override`` (from ContextLoop) replaces ``widget_seed`` as
base when present.
2. Otherwise ``widget_seed`` is the base.
3. ``loop_index`` is mixed via XOR with a stable hash. ``loop_index=0``
→ no-op (backwards-compat for chains without ContextLoop).
Locked-module precedence lives in the per-module handlers, NOT here.
Locked modules read ``instance.locked_seed`` directly and ignore the
chain seed — so loop iteration cannot affect them.
Returns a non-negative seed masked to ``MAX_SAFE_SEED`` (50 bits) —
matches the visual shape of ComfyUI's frontend-randomised seeds.
"""
base = seed_override if seed_override is not None else widget_seed
if loop_index == 0:
return base & MAX_SAFE_SEED
digest = hashlib.sha256(f"loop:{loop_index}".encode()).digest()
shift = int.from_bytes(digest[:8], "big")
return (base ^ shift) & MAX_SAFE_SEED
+53 -293
View File
@@ -8,8 +8,6 @@ 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,
@@ -19,7 +17,6 @@ from engine.syntax.types import (
Token,
TokenKind,
UnknownRefError,
deref_var_value,
)
logger = logging.getLogger(__name__)
@@ -65,64 +62,19 @@ def _resolve_tokens(
return "".join(parts)
_VAR_SURFACES_ALLOWED = frozenset(["combine", "derivation", "assembler"])
def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
"""Look up a variable in ctx. Returns empty string for missing or
internal-key vars. Missing vars also emit an `unknown_var` warning so
the user gets a signal when a Combine / Derivation / Constraint
template references a variable not bound by any upstream module.
Internal-`__`-prefixed keys are never substituted and never warn —
they're engine bookkeeping (`__wp_rng__`, `__wp_catalog__`, etc.).
Surface-gated: only combine/derivation/assembler surfaces support
$var reads. Wildcard + fixed_values surfaces are binding PRODUCERS
(not consumers); $var reads there warn + render literal in lenient
mode, raise VarOutOfSurfaceError in strict mode."""
"""Look up a variable in ctx. Returns empty string for missing or internal-key vars."""
name = tok.meta.get("name", "")
if name.startswith("__"):
# Engine-internal keys are never substituted.
return ""
if ctx.surface not in _VAR_SURFACES_ALLOWED:
from engine.syntax.types import VarOutOfSurfaceError
if ctx.strict:
raise VarOutOfSurfaceError(name, ctx.surface)
_push_warning(
ctx,
type="var_out_of_surface",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"name": name, "surface": ctx.surface},
message=(
f"${name} ignored in {ctx.surface!r} surface — only "
f"combine / derivation / assembler surfaces support $var reads"
),
)
return f"${name}"
value = ctx.get_var(name)
if value is None:
_push_warning(
ctx,
type="unknown_var",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"name": name, "surface": ctx.surface},
message=f"Unknown variable ${name}",
)
# TODO(task-9-onward): push warning when unknown var? Spec doesn't
# explicitly call out unknown vars as warnings; they emit "" silently.
# Revisit if user feedback shows confusion.
return ""
# 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"))
return str(value)
def _push_warning(ctx: ResolveContext, **fields) -> None:
@@ -157,15 +109,6 @@ def _resolve_inline_pick(
visited: tuple[str, ...],
) -> str:
"""Pick one branch uniformly at random; recursively resolve its content."""
# Assembler surface is seedless — its RNG always rolls from seed 0, so
# an inline pick there would freeze on the same branch every run. That's
# misleading, so render the source verbatim instead and let the user
# produce the random value in a seeded module (Wildcard / Combine /
# Derivation inside a WP Context) and reference its $var here. Other
# surfaces (wildcard / combine / derivation) run inside a seeded Context
# and keep resolving normally.
if ctx.surface == "assembler":
return tok.raw
branches: list[str] = tok.meta.get("branches", [])
if not branches:
return ""
@@ -217,120 +160,49 @@ 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:
"""Resolve a multi-pick block, joined by `sep`.
"""Pick `count` branches without replacement, weighted, joined by `sep`.
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.
Each chosen branch 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
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))
count: int = tok.meta.get("count", 0)
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 ""
# 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.
# Clamp count to available branches (without-replacement constraint)
n_picks = min(count, len(branches))
if n_picks == 0:
return ""
chosen_values = pick_k_unique(branch_values, weights, n_picks, ctx.rng)
for branch_text in chosen_values:
# 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]
if not branch_text:
resolved.append("")
continue
@@ -340,111 +212,6 @@ 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,
@@ -492,10 +259,8 @@ def _resolve_ref(
)
return ""
# 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"):
# Surface check
if ctx.surface != "wildcard":
if ctx.strict:
raise RefOutOfSurfaceError(uuid, ctx.surface)
# Try to resolve module name for the warning detail (best-effort)
@@ -514,13 +279,26 @@ def _resolve_ref(
)
return ""
# 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:
# Catalog lookup
module = ctx.get_module(uuid)
if module is None:
if ctx.strict:
raise UnknownRefError(uuid)
_push_warning(
ctx,
type="unknown_ref",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"uuid": uuid, "name": None},
message=f"Unknown wildcard ref @{{{uuid}}}",
)
return ""
# Pick weighted option
options = module.get("options", [])
chosen = _pick_weighted(options, ctx.rng)
if chosen is None:
return ""
@@ -529,24 +307,6 @@ def _resolve_ref(
if not chosen_value:
return ""
# 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.
# Recursive resolve with depth+1, visited extended
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
@@ -1,176 +0,0 @@
"""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
+13 -67
View File
@@ -13,34 +13,12 @@ import re
from engine.syntax.types import Token, TokenKind
# `$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/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)
# `$name` — identifier starts with letter or underscore, then word chars
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
# `@{8hex}` — exactly 8 lowercase hex chars in braces
_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)
def _split_top_level_pipes(s: str) -> list[str]:
@@ -62,7 +40,7 @@ def _split_top_level_pipes(s: str) -> list[str]:
def _scan_brace_block(
text: str, start: int
) -> tuple[int, list[str], tuple[int, int, bool] | None, str | None] | None:
) -> tuple[int, list[str], int | None, str | None] | None:
"""Try to scan a `{...}` block starting at `text[start] == '{'`.
Returns:
@@ -105,21 +83,13 @@ 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_raw = multi_match.group(1) # "N" or "N-M"
independent = multi_match.group(2) == "~" # SP2b: repeats allowed
sep = multi_match.group(3)
count = int(multi_match.group(1))
sep = multi_match.group(2)
# 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)
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)
return (end_index, branches, count, sep)
# Single-pick: must contain at least one top-level `|`
branches = _split_top_level_pipes(body)
@@ -185,15 +155,12 @@ 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=var_meta,
meta={"name": m.group(1)},
))
i = m.end()
continue
@@ -204,27 +171,12 @@ def tokenize_text(text: str) -> list[Token]:
m = _REF_RE.match(text, i)
if m:
_flush_text(i)
# 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
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),
start=i,
end=m.end(),
meta=meta,
meta={"uuid": m.group(1)},
))
i = m.end()
continue
@@ -244,18 +196,12 @@ 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,
# `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,
},
meta={"count": count, "sep": sep, "branches": branches},
))
i = end_index
continue
+3 -84
View File
@@ -11,7 +11,6 @@ from __future__ import annotations
import enum
import random
import re
from dataclasses import dataclass, field
from typing import (
Any,
@@ -20,7 +19,7 @@ from typing import (
runtime_checkable,
)
SurfaceKind = Literal["wildcard", "combine", "derivation", "assembler", "fixed_values"]
SurfaceKind = Literal["wildcard", "combine", "derivation", "assembler"]
class TokenKind(enum.Enum):
@@ -54,66 +53,6 @@ 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
@@ -128,10 +67,8 @@ class ResolveContext(Protocol):
developer_mode: bool
warnings: list[dict[str, Any]]
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_var(self, name: str) -> str | None:
"""Return the bound value of `name`, or None if unbound."""
...
def get_module(self, uuid: str) -> dict[str, Any] | None:
@@ -172,24 +109,6 @@ class RefOutOfSurfaceError(SyntaxError):
)
class VarOutOfSurfaceError(SyntaxError):
"""$var read attempted in a surface that doesn't allow it.
Combine, derivation, and assembler surfaces support $var reads.
Wildcard and fixed_values surfaces are binding PRODUCERS, not
consumers — $var reads in those surfaces warn (lenient) or raise
(strict) per ResolveContext.strict.
"""
def __init__(self, name: str, surface: str) -> None:
self.name = name
self.surface = surface
super().__init__(
f"$var ${name} not allowed in {surface!r} surface; "
f"only combine / derivation / assembler surfaces support $var reads"
)
class RecursionLimitExceeded(SyntaxError):
"""@{uuid} chain reached configured max_ref_depth."""
+6 -11
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.2.0",
"version": "1.4.2",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
@@ -16,10 +16,6 @@
"typecheck": "vue-tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,vue}\"",
"size": "node scripts/check-bundle-size.mjs",
"check:css-isolation": "node scripts/check-css-isolation.mjs",
"check:spacing": "node scripts/check-spacing-tokens.mjs",
"check:typography": "node scripts/check-typography-tokens.mjs",
"check:radius": "node scripts/check-radius-tokens.mjs",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
@@ -27,13 +23,15 @@
"prepare": "husky"
},
"dependencies": {
"pinia": "^2.2.6",
"primeicons": "^7.0.0",
"vue": "^3.5.12",
"vue-router": "^4.4.5",
"zod": "^3.25.76"
"pinia": "^2.2.6",
"primeicons": "^7.0.0"
},
"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",
@@ -46,15 +44,12 @@
"@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,9 +20,6 @@ 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
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-140
View File
@@ -1,140 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 600" role="img" aria-label="Wildcard Pipeline — modular procedural prompt generation for ComfyUI">
<style><![CDATA[
.bg { fill: #0e1015; }
.panel { fill: #1a1d2a; }
.border-cyan { stroke: #3a8fb7; }
.border-violet { stroke: #7c6ad6; }
.border-neutral { stroke: #6a6a6a; }
.ico-cyan { fill: #7ec8e3; }
.ico-violet { fill: #c4b5fd; }
.ico-neutral { fill: #aab; }
.name { fill: #d6d8e0; font: 600 18px 'Inter', system-ui, sans-serif; }
.sub { fill: #6f7384; font: 400 12px ui-monospace, monospace; }
.title { fill: #e8eaf2; font: 700 56px 'Inter', system-ui, sans-serif; letter-spacing: -0.5px; }
.tagline { fill: #aab2c0; font: 500 22px 'Inter', system-ui, sans-serif; }
.badge { font: 600 13px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
.badge-wc { fill: #b29cf2; }
.badge-fv { fill: #2dd4bf; }
.badge-cb { fill: #6ad28b; }
.badge-dv { fill: #f4a261; }
.badge-cs { fill: #ec96b8; }
.arrow-lbl { fill: #6f7384; font: 600 12px 'Inter', system-ui, sans-serif; letter-spacing: 1.2px; text-transform: uppercase; }
.arrow-line { stroke: #4a4d55; stroke-width: 2; fill: none; }
.arrow-head { fill: #4a4d55; }
.var-pill { fill: #c4b5fd; fill-opacity: 0.12; stroke: #c4b5fd; stroke-width: 1; }
.var-text { fill: #c4b5fd; font: 500 14px 'Inter', system-ui, sans-serif; }
.pill { fill-opacity: 0.12; stroke-width: 1; }
.pill-stroke-wc { stroke: #b29cf2; }
.pill-stroke-fv { stroke: #2dd4bf; }
.pill-stroke-cb { stroke: #6ad28b; }
.pill-stroke-dv { stroke: #f4a261; }
.pill-stroke-cs { stroke: #ec96b8; }
.glow { filter: drop-shadow(0 0 14px rgba(124, 106, 214, 0.18)); }
]]></style>
<!-- Background -->
<rect class="bg" width="1400" height="600" rx="0" />
<!-- Subtle decorative grid (very dim, evokes ComfyUI canvas) -->
<g opacity="0.06" stroke="#3a8fb7" stroke-width="1">
<line x1="0" y1="100" x2="1400" y2="100" />
<line x1="0" y1="200" x2="1400" y2="200" />
<line x1="0" y1="300" x2="1400" y2="300" />
<line x1="0" y1="400" x2="1400" y2="400" />
<line x1="0" y1="500" x2="1400" y2="500" />
<line x1="200" y1="0" x2="200" y2="600" />
<line x1="400" y1="0" x2="400" y2="600" />
<line x1="600" y1="0" x2="600" y2="600" />
<line x1="800" y1="0" x2="800" y2="600" />
<line x1="1000" y1="0" x2="1000" y2="600" />
<line x1="1200" y1="0" x2="1200" y2="600" />
</g>
<!-- ===== Left half: branding ===== -->
<!-- Title -->
<text class="title" x="70" y="180">Wildcard</text>
<text class="title" x="70" y="248">Pipeline</text>
<!-- Underline accent -->
<rect x="70" y="270" width="120" height="4" rx="2" fill="#7c6ad6" />
<!-- Tagline -->
<text class="tagline" x="70" y="320">Modular procedural prompt</text>
<text class="tagline" x="70" y="350">generation for ComfyUI</text>
<!-- Module-kind badges as a feature row -->
<g transform="translate(70, 410)">
<rect class="pill pill-stroke-wc" x="0" y="0" width="88" height="28" rx="14" fill="#b29cf2" />
<text class="badge badge-wc" x="44" y="19" text-anchor="middle">Wildcard</text>
<rect class="pill pill-stroke-fv" x="98" y="0" width="80" height="28" rx="14" fill="#2dd4bf" />
<text class="badge badge-fv" x="138" y="19" text-anchor="middle">Fixed</text>
<rect class="pill pill-stroke-cb" x="188" y="0" width="98" height="28" rx="14" fill="#6ad28b" />
<text class="badge badge-cb" x="237" y="19" text-anchor="middle">Combine</text>
</g>
<g transform="translate(70, 446)">
<rect class="pill pill-stroke-dv" x="0" y="0" width="108" height="28" rx="14" fill="#f4a261" />
<text class="badge badge-dv" x="54" y="19" text-anchor="middle">Derivation</text>
<rect class="pill pill-stroke-cs" x="118" y="0" width="110" height="28" rx="14" fill="#ec96b8" />
<text class="badge badge-cs" x="173" y="19" text-anchor="middle">Constraint</text>
</g>
<!-- Footnote -->
<text class="sub" x="70" y="525">wildcards · constraints · loops · per-iteration seeds · library</text>
<!-- ===== Right half: flow diagram ===== -->
<g class="glow" transform="translate(620, 0)">
<!-- Layout budget on the right half:
Context (180) + EMITS (90) + Vars (130) + FILLS (90) + Assembler (210)
= 700 px → fits within (1400 - 620) = 780 px viewBox with 80 px margin -->
<!-- WP Context box -->
<g transform="translate(0, 220)">
<rect class="panel border-cyan" x="0" y="0" width="180" height="140" rx="12" stroke-width="2" />
<text class="ico-cyan" x="90" y="42" text-anchor="middle" font-size="28">⌬</text>
<text class="name" x="90" y="80" text-anchor="middle">WP Context</text>
<text class="sub" x="90" y="105" text-anchor="middle">rolls + resolves</text>
</g>
<!-- EMITS arrow -->
<g transform="translate(190, 290)">
<text class="arrow-lbl" x="40" y="-16" text-anchor="middle">Emits</text>
<line class="arrow-line" x1="0" y1="0" x2="76" y2="0" />
<polygon class="arrow-head" points="76,-6 86,0 76,6" />
</g>
<!-- Resolved vars cluster -->
<g transform="translate(290, 260)">
<rect class="var-pill" x="0" y="0" width="84" height="28" rx="14" />
<text class="var-text" x="42" y="19" text-anchor="middle">$subject</text>
<rect class="var-pill" x="92" y="0" width="62" height="28" rx="14" />
<text class="var-text" x="123" y="19" text-anchor="middle">$style</text>
<rect class="var-pill" x="35" y="38" width="86" height="28" rx="14" />
<text class="var-text" x="78" y="57" text-anchor="middle">$lighting</text>
</g>
<!-- FILLS arrow -->
<g transform="translate(460, 290)">
<text class="arrow-lbl" x="35" y="-16" text-anchor="middle">Fills</text>
<line class="arrow-line" x1="0" y1="0" x2="66" y2="0" />
<polygon class="arrow-head" points="66,-6 76,0 66,6" />
</g>
<!-- Prompt Assembler -->
<g transform="translate(550, 220)">
<rect class="panel border-violet" x="0" y="0" width="210" height="140" rx="12" stroke-width="2" />
<text class="ico-violet" x="105" y="42" text-anchor="middle" font-size="28">≡</text>
<text class="name" x="105" y="80" text-anchor="middle">Prompt Assembler</text>
<text class="sub" x="105" y="105" text-anchor="middle">"a $style portrait…"</text>
</g>
</g>
<!-- favicon-ish sparkle marks in the corners (decorative) -->
<g opacity="0.5" fill="#7c6ad6">
<circle cx="40" cy="40" r="2" />
<circle cx="1360" cy="40" r="2" />
<circle cx="40" cy="560" r="2" />
<circle cx="1360" cy="560" r="2" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

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