Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d628a0687d | ||
|
|
02cc9f766f | ||
|
|
a1d051172d | ||
|
|
5b5c635be6 | ||
|
|
ccb8392219 | ||
|
|
d8c20728cb | ||
|
|
0e9e5db486 | ||
|
|
a9516996b4 | ||
|
|
02a046321f | ||
|
|
6a5a8d318e | ||
|
|
a19e5f4500 | ||
|
|
c43bff0488 |
@@ -1,3 +1,45 @@
|
||||
## 🎉 Wildcard Pipeline 2.5.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(manager): one-time library repair for non-hex module/bundle ids ([02cc9f7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02cc9f7))
|
||||
* fix(import): normalize non-hex module/bundle ids on import + download ([a1d0511](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a1d0511))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.4.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 #4 from DumiFlex/feat/inline-weights-pool-ui-debug ([ccb8392](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ccb8392)), closes [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4)
|
||||
* fix(engine): apply N:: branch weights in simple inline {a|b|c} choices ([a951699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a951699))
|
||||
* fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs ([d8c2072](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d8c2072))
|
||||
* feat(engine): emit a constraint source-missing debug warning ([0e9e5db](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9e5db))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.3.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #3 from DumiFlex/fix/instance-modal-scroll ([6a5a8d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5a8d3)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll ([a19e5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a19e5f4))
|
||||
* fix(extension): scroll instance edit modals with pinned header + footer ([c43bff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c43bff0))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.3.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
@@ -613,6 +613,44 @@ class PipelineEngine:
|
||||
# otherwise render as plain text).
|
||||
"message": message,
|
||||
})
|
||||
# Source-side companion warning. The `never_applied` message
|
||||
# above is target-focused; when the constraint's SOURCE
|
||||
# wildcard is ALSO absent from the chain, surface that
|
||||
# distinctly so the Debug viewer shows BOTH ends (mirrors the
|
||||
# editor's separate "source missing" / "target missing" row
|
||||
# badges). A constraint registers by its own module position,
|
||||
# so it can sit in the bucket with neither end present.
|
||||
source_uuid = c.get("source_wildcard_id", "")
|
||||
if source_uuid and not _target_present_in_chain(
|
||||
source_uuid, modules, catalog,
|
||||
):
|
||||
src_row = (
|
||||
catalog.get(source_uuid) if isinstance(catalog, dict) else None
|
||||
)
|
||||
source_name = (
|
||||
(src_row.get("name", "") or "")
|
||||
if isinstance(src_row, dict) else ""
|
||||
)
|
||||
src_suffix = _build_ref_name_suffix(source_name)
|
||||
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
|
||||
if isinstance(warnings_bucket, list):
|
||||
warnings_bucket.append({
|
||||
"type": "constraint_source_missing",
|
||||
"severity": "warn",
|
||||
"module_id": cid,
|
||||
"source_field": "",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"constraint_id": cid,
|
||||
"source_wildcard_id": source_uuid,
|
||||
},
|
||||
"message": (
|
||||
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
|
||||
f"its source @{{{source_uuid}{src_suffix}}} wildcard isn't in "
|
||||
f"this chain, so it never registers a source pick."
|
||||
),
|
||||
})
|
||||
|
||||
# SP3 partial-reach finalisation: a constraint that DID fire but
|
||||
# whose `next N` / `pick` selector matched FEWER occurrences than
|
||||
|
||||
@@ -169,8 +169,19 @@ def _resolve_inline_pick(
|
||||
branches: list[str] = tok.meta.get("branches", [])
|
||||
if not branches:
|
||||
return ""
|
||||
chosen_idx = ctx.rng.randrange(len(branches))
|
||||
chosen = branches[chosen_idx]
|
||||
# Per-branch weights (`N::value`), same micro-syntax the multi-pick block
|
||||
# honors. Strip the prefix off every branch so it never leaks into output;
|
||||
# only switch to weighted selection when a branch actually carries a weight,
|
||||
# so unweighted `{a|b|c}` keeps its byte-for-byte seed sequence (legacy
|
||||
# randrange path — changing the draw would shift every existing seed).
|
||||
values = [_strip_branch_weight(b) for b in branches]
|
||||
has_weights = any(v != b for v, b in zip(values, branches, strict=True))
|
||||
if has_weights:
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
chosen_idx = _weighted_pick_index(weights, ctx.rng)
|
||||
else:
|
||||
chosen_idx = ctx.rng.randrange(len(branches))
|
||||
chosen = values[chosen_idx]
|
||||
if not chosen:
|
||||
return ""
|
||||
nested_tokens = tokenize_text(chosen)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.0",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.3.0"
|
||||
version = "2.5.0"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -157,14 +157,21 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// parity test) now; the upcoming reach/constraint-matrix UI phases need the
|
||||
// total budget headroom in the lazy ContextWidget chunk. Approved alongside
|
||||
// the SP3 constraint feature.
|
||||
// Bumped 430 -> 431 KB on 2026-06-18 for the editor robustness pass:
|
||||
// RichTextInput's scoped renderEpoch rebuild (nested-ref picker crash fix)
|
||||
// + useEditorDraft flush-on-unmount/unload + ErrorBoundary copy, plus the
|
||||
// clipboard-shield wired into _shared.ts + main.ts (Ctrl-A/C/V/X guard on
|
||||
// editable widget inputs). ~0.3 KB net gzip across the extension entry +
|
||||
// shared chunks. Landed on the bulk-editor branch (the bulk UI itself is
|
||||
// manager-only and adds nothing to js/).
|
||||
const TOTAL_LIMIT = 431 * 1024; // 431 KB
|
||||
// Bumped 430 -> 431 KB on 2026-06-18 — two same-day landings share the
|
||||
// ceiling (both CSS/glue; the manager-only bulk UI adds nothing to js/):
|
||||
// - editor robustness: RichTextInput renderEpoch crash fix + useEditorDraft
|
||||
// flush-on-unmount/unload + ErrorBoundary copy + clipboard-shield wired
|
||||
// into _shared.ts / main.ts (Ctrl-A/C/V/X guard on editable inputs).
|
||||
// - instance-modal scroll/sticky: sticky header + runtime/footer dock +
|
||||
// own-scrollbar inner lists (options/values/rules/exceptions) + compact
|
||||
// exceptions trash, across all 6 edit modals.
|
||||
// Bumped 431 -> 432 KB on 2026-06-18 for the syntax + UI fix batch: inline
|
||||
// `{N::a|b}` branch weights (engine + TS resolver mirror), the filter / pick /
|
||||
// exclude-null MOD-dot fix, the "X of Y options" + "picks N-M" wildcard
|
||||
// subtitle, the library-aware nested-ref check (kills the false BROKEN REF),
|
||||
// and the constraint source-missing debug warning + label. ~0.4 KB net gzip
|
||||
// across the ContextWidget + conflicts chunks; entry stays put.
|
||||
const TOTAL_LIMIT = 432 * 1024; // 432 KB
|
||||
|
||||
function gzipSize(path) {
|
||||
return gzipSync(readFileSync(path)).length;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-c
|
||||
import ModuleRow from "./ModuleRow.vue";
|
||||
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
|
||||
import { buildBundleInsertion, type BundleLibraryEntry } from "./bundles/insert";
|
||||
import { isEnabled, type WildcardOption, type InstanceLike } from "./editors/wildcard/probability";
|
||||
import { buildLibraryChildrenWithIntegrity, toChildSnapshot } from "./bundles/save";
|
||||
import {
|
||||
cascadeRestoreForBundle,
|
||||
@@ -2027,6 +2028,9 @@ const conflicts = computed<Conflict[]>(() => {
|
||||
props.downstreamWildcardUuids,
|
||||
props.downstreamNestedReachUuids,
|
||||
pairOrphanByUid,
|
||||
// Live-library uuids — a nested @{} ref to any of these resolves at run
|
||||
// time (DB-expanded catalog), so it must not read as a broken ref.
|
||||
new Set(Object.keys(libraryHashes.value ?? {})),
|
||||
);
|
||||
// Filter by user's validation-strictness preference. The accessor
|
||||
// reads from the same module-level state map the panel onChange
|
||||
@@ -2806,7 +2810,16 @@ function isModified(m: ModuleEntry): boolean {
|
||||
if (nonEmptyObj(inst.option_weights)) return true;
|
||||
if (inst.mode && inst.mode !== "random") return true;
|
||||
if (inst.pinned_option_id) return true;
|
||||
if (nonEmptyArr(inst.category_filter)) return true;
|
||||
// `category_filter` is a STRING expr — the old `nonEmptyArr()` test
|
||||
// never matched it, so toggling the pool filter never lit the dot.
|
||||
if (typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") return true;
|
||||
if (inst.exclude_null) return true;
|
||||
// Pick-range override (multi-pick / narrowed). Sparse — absent or 1..1
|
||||
// is the library default; anything else is a behavioural override.
|
||||
if (inst.pick_min != null && inst.pick_min !== 1) return true;
|
||||
if (inst.pick_max != null && inst.pick_max !== 1) return true;
|
||||
if (inst.pick_independent) return true;
|
||||
if (typeof inst.pick_separator === "string" && inst.pick_separator !== "") return true;
|
||||
return false;
|
||||
case "fixed_values":
|
||||
// Library-tracked: `values_overrides` non-empty = user edited entries,
|
||||
@@ -3181,14 +3194,37 @@ function summaryTokens(m: ModuleEntry): SummaryToken[] {
|
||||
// Card summary respects the per-instance binding override so a
|
||||
// renamed wildcard reads as `$<override>` on the canvas. Engine
|
||||
// applies the same precedence at run time.
|
||||
const inst = m.instance?.variable_binding;
|
||||
const instBind = m.instance?.variable_binding;
|
||||
const payloadBinding = (p.var_binding as string | undefined)?.trim();
|
||||
const binding = (typeof inst === "string" && inst.trim()) || payloadBinding;
|
||||
const opts = Array.isArray(p.options) ? p.options.length : 0;
|
||||
const binding = (typeof instBind === "string" && instBind.trim()) || payloadBinding;
|
||||
const head: SummaryToken = binding ? v(binding) : lit("wildcard");
|
||||
return opts
|
||||
? [head, lit(` · ${opts} option${opts === 1 ? "" : "s"}`)]
|
||||
: [head];
|
||||
const allOpts = (Array.isArray(p.options) ? p.options : []) as WildcardOption[];
|
||||
const total = allOpts.length;
|
||||
if (!total) return [head];
|
||||
const inst = (m.instance ?? {}) as InstanceLike & { pick_min?: number; pick_max?: number };
|
||||
const pmin = typeof inst.pick_min === "number" ? inst.pick_min : 1;
|
||||
const pmax = typeof inst.pick_max === "number" ? Math.max(pmin, inst.pick_max) : pmin;
|
||||
const multiActive = pmax > 1;
|
||||
// "X of Y options" only when a filter / enabled-subset actually narrows
|
||||
// the pool — a plain count when nothing is filtering (keeps the common
|
||||
// case clean). The null-option drop in multi mode alone never triggers it.
|
||||
const hasFilter =
|
||||
(typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") ||
|
||||
Array.isArray(inst.enabled_options);
|
||||
const eligible = allOpts.filter((o) => isEnabled(o, inst, multiActive)).length;
|
||||
const tokens: SummaryToken[] = [
|
||||
head,
|
||||
lit(
|
||||
hasFilter && eligible < total
|
||||
? ` · ${eligible} of ${total} options`
|
||||
: ` · ${total} option${total === 1 ? "" : "s"}`,
|
||||
),
|
||||
];
|
||||
// Multi-pick hint — surface when the instance resolves more than one.
|
||||
if (pmax > 1) {
|
||||
tokens.push(lit(pmin === pmax ? ` · picks ${pmin}` : ` · picks ${pmin}-${pmax}`));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
case "combine": {
|
||||
// Same per-instance precedence as wildcard: rebinding via the v2
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
.wp-wcm__head { --modal-accent: var(--wp-kind-wildcard); }
|
||||
|
||||
.wp-cbm__head, .wp-cnm__head, .wp-dvm__head, .wp-fvm__head, .wp-wcm__head {
|
||||
/* Pinned to the top of the scrolling modal body so the title + close
|
||||
* stay visible while the content scrolls. Opaque (gradient bottoms out
|
||||
* at --wp-bg2) so scrolled content doesn't bleed through. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
@@ -102,6 +102,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
:library-color="libraryColor"
|
||||
@update="(patch) => emit('update', patch)"
|
||||
/>
|
||||
<div class="wp-bdm__dock">
|
||||
<RuntimeSection
|
||||
:lock-state="lockState"
|
||||
:internal-state="internalState"
|
||||
@@ -157,12 +158,13 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
|
||||
.wp-bdm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
|
||||
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow-y: auto; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
|
||||
.wp-bdm__head { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
|
||||
.wp-bdm__head-icon { color: var(--modal-accent, var(--wp-accent)); font-size: 18px; width: 24px; text-align: center; }
|
||||
.wp-bdm__title-block { flex: 1; min-width: 0; }
|
||||
.wp-bdm__title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
@@ -175,6 +177,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
.wp-bdm__close { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 4px; }
|
||||
.wp-bdm__close:hover { color: var(--wp-text); }
|
||||
.wp-bdm__close .pi { font-size: 12px; }
|
||||
.wp-bdm__dock { position: sticky; bottom: 0; z-index: 2; background: var(--wp-bg2); }
|
||||
.wp-bdm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
|
||||
.wp-bdm__spa-link { display: inline-flex; align-items: center; gap: 5px; font: 10px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); text-decoration: none; }
|
||||
.wp-bdm__spa-link:hover { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
|
||||
@@ -109,6 +109,7 @@ function onSpaClick(): void {
|
||||
:sibling-vars="siblingVars"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<div class="wp-cbm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-cbm__foot">
|
||||
@@ -147,6 +148,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-cbm__btn" data-test="cbm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-cbm__btn wp-cbm__btn--primary" data-test="cbm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -160,7 +162,7 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
@@ -168,6 +170,12 @@ function onSpaClick(): void {
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). Every modal in this family shares
|
||||
* the same accent-tinted gradient + chip + close button. */
|
||||
.wp-cbm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-cbm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -691,14 +691,6 @@ function onSpaClick(): void {
|
||||
:stranded="hasDangling"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<TargetReachSection
|
||||
:model-value="targetSelect"
|
||||
:chain-modules="chainModules"
|
||||
:constraint-uid="constraintUid"
|
||||
:target-wildcard-id="targetWildcardId"
|
||||
:target-name="targetName"
|
||||
@update:model-value="onTargetSelect"
|
||||
/>
|
||||
<ExceptionsSection
|
||||
:module="module"
|
||||
:source-values="sourceValues"
|
||||
@@ -712,6 +704,15 @@ function onSpaClick(): void {
|
||||
@update="onUpdate"
|
||||
/>
|
||||
|
||||
<div class="wp-cnm__dock">
|
||||
<TargetReachSection
|
||||
:model-value="targetSelect"
|
||||
:chain-modules="chainModules"
|
||||
:constraint-uid="constraintUid"
|
||||
:target-wildcard-id="targetWildcardId"
|
||||
:target-name="targetName"
|
||||
@update:model-value="onTargetSelect"
|
||||
/>
|
||||
<footer class="wp-cnm__foot">
|
||||
<a
|
||||
v-if="isLibraryTracked"
|
||||
@@ -748,6 +749,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-cnm__btn" data-test="cnm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-cnm__btn wp-cnm__btn--primary" data-test="cnm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Feature 2: confirm pulling the missing dependency (+ its closure)
|
||||
from the community before any network/install side effect. -->
|
||||
@@ -772,13 +774,19 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). */
|
||||
.wp-cnm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-cnm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -304,6 +304,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
<section class="ex" data-test="ex-section">
|
||||
<div class="ex__label">Exceptions</div>
|
||||
|
||||
<div class="ex__list">
|
||||
<div
|
||||
v-for="(exc, i) in libraryExceptions"
|
||||
:key="libKey(exc)"
|
||||
@@ -551,6 +552,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
><i class="pi pi-trash" /></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!stranded"
|
||||
@@ -581,11 +583,18 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ex__list {
|
||||
/* Long exception lists scroll in place; the label + add button stay put. */
|
||||
max-height: 34vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ex__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
/* right padding keeps the trash off the section edge + the list
|
||||
* scrollbar when the exceptions list scrolls. */
|
||||
padding: 4px 8px 4px 0;
|
||||
font: 11px var(--wp-font-mono);
|
||||
border-bottom: 1px dashed var(--wp-border-soft, var(--wp-border));
|
||||
}
|
||||
@@ -801,17 +810,17 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: color-mix(in srgb, var(--wp-text) 3%, transparent);
|
||||
border: 1px solid var(--wp-border);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.ex__trash:hover {
|
||||
background: color-mix(in srgb, var(--wp-danger, #e05252) 18%, transparent);
|
||||
|
||||
@@ -160,6 +160,7 @@ function onSpaClick(): void {
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<div class="wp-dvm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-dvm__foot">
|
||||
@@ -198,6 +199,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-dvm__btn" data-test="dvm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-dvm__btn wp-dvm__btn--primary" data-test="dvm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -211,13 +213,19 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). */
|
||||
.wp-dvm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-dvm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -693,6 +693,9 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
/* Long rule lists scroll in place so identity / footer stay visible. */
|
||||
max-height: 44vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Rule card */
|
||||
|
||||
@@ -66,6 +66,7 @@ function onSpaClick(): void {
|
||||
|
||||
<IdentitySection :module="module" @update="onUpdate" />
|
||||
<ValuesSection :module="module" @update="onUpdate" />
|
||||
<div class="wp-fvm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-fvm__foot">
|
||||
@@ -105,6 +106,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-fvm__btn" data-test="fvm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-fvm__btn wp-fvm__btn--primary" data-test="fvm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -118,13 +120,19 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). */
|
||||
.wp-fvm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-fvm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -183,9 +183,14 @@ const addedCount = computed(() => draftRows.value.filter((r) => r.libraryId ===
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
/* Long value lists scroll in place; the header row stays pinned. */
|
||||
max-height: 42vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.vals__head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 22px 180px 1fr 28px 28px;
|
||||
gap: 10px;
|
||||
|
||||
@@ -101,6 +101,7 @@ function onSpaClick(): void {
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<PoolSection :module="module" :via-option-pairs="viaOptionPairs" @update="onUpdate" />
|
||||
<div class="wp-wcm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-wcm__foot">
|
||||
@@ -150,6 +151,7 @@ function onSpaClick(): void {
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -163,7 +165,7 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
@@ -171,6 +173,12 @@ function onSpaClick(): void {
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). Adopted from InjectorBindingModal
|
||||
* so every per-instance edit modal reads as one design family. */
|
||||
.wp-wcm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-wcm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -903,9 +903,15 @@ const skewedTowards = computed(() => {
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
/* Long option lists scroll in place so the axes / pick / footer stay
|
||||
* visible; the column-header row below stays pinned via sticky. */
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pool__opt-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
/* Match OptionRow column widths so cell alignment stays in lockstep. */
|
||||
grid-template-columns: 22px 1fr 110px 64px 96px;
|
||||
|
||||
@@ -182,6 +182,7 @@ interface WarningEntry {
|
||||
const WARNING_LABELS: Record<string, string> = {
|
||||
constraint_never_applied: "Constraint never applied",
|
||||
constraint_partial_reach: "Constraint partial reach",
|
||||
constraint_source_missing: "Constraint source missing",
|
||||
constraint_register_failed: "Constraint failed to register",
|
||||
constraint_factor_ignored_on_allow: "Constraint factor ignored (allow)",
|
||||
unknown_constraint_mode: "Unknown constraint mode",
|
||||
|
||||
@@ -1294,6 +1294,32 @@ describe("scanConflicts — wildcard_broken_nested_ref", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT flag when the ref resolves to a LIBRARY wildcard not in the chain", () => {
|
||||
// `deadbeef` isn't in the chain, but it's a live-library wildcard — the
|
||||
// Context node fills it from the DB at run (`_expand_catalog_via_live_db`),
|
||||
// so the ref resolves and must NOT read as broken.
|
||||
const value: ContextWidgetValue = {
|
||||
version: 1,
|
||||
modules: [wildcardOpt("w1", "phrase", "see @{deadbeef}")],
|
||||
};
|
||||
const out = scanConflicts(value, [], [], [], [], null, new Set(["deadbeef"]));
|
||||
expect(out.find((c) => c.type === "wildcard_broken_nested_ref")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("STILL flags a ref absent from BOTH the chain and the library", () => {
|
||||
const value: ContextWidgetValue = {
|
||||
version: 1,
|
||||
modules: [wildcardOpt("w1", "phrase", "see @{deadbeef}")],
|
||||
};
|
||||
const out = scanConflicts(value, [], [], [], [], null, new Set(["other999"]));
|
||||
expect(out).toContainEqual({
|
||||
moduleId: "w1",
|
||||
variable: "deadbeef",
|
||||
type: "wildcard_broken_nested_ref",
|
||||
severity: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
it("keys the conflict to the module's _uid when present", () => {
|
||||
const value: ContextWidgetValue = {
|
||||
version: 1,
|
||||
|
||||
@@ -451,6 +451,13 @@ export function scanConflicts(
|
||||
// handed and reads `isOrphan` off that. Same reach math, narrower
|
||||
// visibility.
|
||||
constraintPairOrphanByUid: Map<string, boolean> | null = null,
|
||||
// Uuids of every wildcard in the live library. A nested `@{uuid}` ref to a
|
||||
// library wildcard that ISN'T in this chain still resolves at run time — the
|
||||
// Context node expands its catalog from the live DB (`_expand_catalog_via_live_db`),
|
||||
// so the ref is NOT broken. Only flag refs absent from BOTH chain + library.
|
||||
// Constraint TARGET reachability deliberately stays chain-only (a constraint
|
||||
// needs a downstream instance to re-weight — library presence isn't enough).
|
||||
knownLibraryUuids: ReadonlySet<string> = new Set(),
|
||||
): Conflict[] {
|
||||
const upstream = new Set(upstreamVars);
|
||||
const upstreamUuids = new Set(upstreamWildcardUuids);
|
||||
@@ -618,6 +625,11 @@ export function scanConflicts(
|
||||
const inDownstreamNested = downstreamNestedReach.has(uuid);
|
||||
return inLocalDirect || inUpstream || inDownstream || inLocalNestedAfter || inDownstreamNested;
|
||||
};
|
||||
// Nested `@{uuid}` refs additionally resolve from the live library catalog
|
||||
// (the engine pulls them from the DB at run time even when they're not in
|
||||
// the chain), so a ref to any known library wildcard is NOT a broken ref.
|
||||
const isNestedRefResolvable = (uuid: string, i: number): boolean =>
|
||||
isUuidResolvable(uuid, i) || knownLibraryUuids.has(uuid);
|
||||
for (let i = 0; i < value.modules.length; i++) {
|
||||
const m = value.modules[i];
|
||||
if (!isModuleEffectivelyEnabled(m, bundleEnabled)) continue;
|
||||
@@ -828,7 +840,7 @@ export function scanConflicts(
|
||||
const uuid = match[1];
|
||||
if (seenRef.has(uuid)) continue;
|
||||
seenRef.add(uuid);
|
||||
if (!isUuidResolvable(uuid, i)) {
|
||||
if (!isNestedRefResolvable(uuid, i)) {
|
||||
out.push({ moduleId: m._uid ?? m.id, variable: uuid, type, severity: "warning" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
detectInstallCollisions,
|
||||
enforceClashSafety,
|
||||
installEnvelope,
|
||||
normalizeInvalidIds,
|
||||
type CollisionDecision,
|
||||
type InstallCollision,
|
||||
type InstallOptions,
|
||||
@@ -25,6 +26,37 @@ function emptySelection(): ResolvedSelection {
|
||||
} as unknown as ResolvedSelection;
|
||||
}
|
||||
|
||||
describe("normalizeInvalidIds", () => {
|
||||
it("mints a valid 8-hex id for a non-hex module id, rewrites it in place", () => {
|
||||
const selection = emptySelection();
|
||||
selection.wildcards = [
|
||||
{ entity: { id: "coloruni", name: "color" }, decision: { kind: "add" } },
|
||||
{ entity: { id: "ed1bccf8", name: "decor" }, decision: { kind: "add" } },
|
||||
] as unknown as ResolvedSelection["wildcards"];
|
||||
const map = normalizeInvalidIds(selection);
|
||||
expect(Object.keys(map)).toEqual(["coloruni"]); // valid id untouched
|
||||
expect(map.coloruni).toMatch(/^[0-9a-f]{8}$/);
|
||||
expect((selection.wildcards[0].entity as { id: string }).id).toBe(map.coloruni);
|
||||
expect((selection.wildcards[1].entity as { id: string }).id).toBe("ed1bccf8");
|
||||
});
|
||||
|
||||
it("returns an empty map when every id is already valid", () => {
|
||||
const selection = emptySelection();
|
||||
selection.wildcards = [
|
||||
{ entity: { id: "ed1bccf8", name: "decor" }, decision: { kind: "add" } },
|
||||
] as unknown as ResolvedSelection["wildcards"];
|
||||
expect(normalizeInvalidIds(selection)).toEqual({});
|
||||
});
|
||||
|
||||
it("normalizes bundle ids too", () => {
|
||||
const selection = emptySelection();
|
||||
selection.bundles = [
|
||||
{ entity: { id: "mybundle", name: "b" }, decision: { kind: "add" } },
|
||||
] as unknown as ResolvedSelection["bundles"];
|
||||
expect(normalizeInvalidIds(selection).mybundle).toMatch(/^[0-9a-f]{8}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectInstallCollisions — type-conflict flag", () => {
|
||||
it("flags a same-id DIFFERENT-kind collision as typeConflict", () => {
|
||||
const selection = emptySelection();
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type ResolvedEntity,
|
||||
type ResolvedSelection,
|
||||
} from "./commit";
|
||||
import { newShortId } from "../utils/ids";
|
||||
import { newShortId, isValidId } from "../utils/ids";
|
||||
import type { SchemaCatalogEntry } from "@/api/types";
|
||||
import { buildReattachRemap, type InstallDependencyEdge } from "./reattach";
|
||||
import {
|
||||
@@ -359,6 +359,37 @@ export function applyCollisionDecisions(
|
||||
return renameMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh valid id for any module / bundle / template whose id isn't a
|
||||
* canonical 8-hex uuid (e.g. a hand-authored `coloruni` that round-tripped
|
||||
* through import → community → download), rewriting `entity.id` in place.
|
||||
* Returns the old→new map so the caller folds it into the reattach remap —
|
||||
* `walkRemap` then rewrites every `@{oldid}` ref + constraint source/target
|
||||
* axis to the new id. Option ids are left alone (opaque; never `@{}`-matched).
|
||||
*
|
||||
* Runs BEFORE collision detection so the minted ids (fresh, non-colliding)
|
||||
* never trip the collision path and the two rename maps merge cleanly.
|
||||
*/
|
||||
export function normalizeInvalidIds(selection: ResolvedSelection): Record<string, string> {
|
||||
const renameMap: Record<string, string> = {};
|
||||
const buckets: Array<keyof Omit<ResolvedSelection, "categories">> = [
|
||||
"bundles",
|
||||
...MODULE_BUCKETS,
|
||||
"templates",
|
||||
];
|
||||
for (const bucket of buckets) {
|
||||
for (const row of selection[bucket]) {
|
||||
const id = (row.entity as { id?: unknown }).id;
|
||||
if (typeof id === "string" && !isValidId(id)) {
|
||||
const newId = newShortId();
|
||||
renameMap[id] = newId;
|
||||
(row.entity as { id: string }).id = newId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return renameMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety backstop for the shared id-space: a cross-kind id clash
|
||||
* (`typeConflict`) must NEVER resolve to `replace` — replacing the live
|
||||
@@ -444,7 +475,9 @@ export async function installEnvelope(
|
||||
// we fall through and let the server's commit endpoint surface the
|
||||
// conflict as a 4xx, preserving the original behaviour for callers
|
||||
// that haven't opted in to the new UX.
|
||||
let renameMap: Record<string, string> = {};
|
||||
// Normalize any non-8-hex module/bundle id (mint + rewrite in place) FIRST,
|
||||
// so the minted ids feed both collision detection and the reattach remap.
|
||||
const renameMap: Record<string, string> = normalizeInvalidIds(selection);
|
||||
if (opts.library && opts.resolveCollisions) {
|
||||
const collisions = detectInstallCollisions(selection, opts.library);
|
||||
if (collisions.length > 0) {
|
||||
@@ -458,7 +491,8 @@ export async function installEnvelope(
|
||||
error: { code: "cancelled", message: "Install cancelled by user." },
|
||||
};
|
||||
}
|
||||
renameMap = applyCollisionDecisions(selection, enforceClashSafety(collisions, decisions));
|
||||
// Merge (not replace) so the invalid-id renames from normalizeInvalidIds survive.
|
||||
Object.assign(renameMap, applyCollisionDecisions(selection, enforceClashSafety(collisions, decisions)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Boot wiring for the one-time library UUID repair (Part 3). Builds the
|
||||
* dependency-injected `RepairDeps` from the live api client + gates the run so
|
||||
* it fires once per repair version. Kept separate from `uuid-repair.ts` (which
|
||||
* stays pure + server-free) so the orchestrator unit-tests don't pull the api.
|
||||
*/
|
||||
import { api } from "../api/client";
|
||||
import type { ModuleType } from "../api/types";
|
||||
import { runLibraryRepair, type RepairableEntry, type RepairDeps } from "./uuid-repair";
|
||||
|
||||
const REPAIR_KEY = "wp_uuid_repair_version";
|
||||
/** Bump to force the repair to re-run after a future fix. */
|
||||
const REPAIR_VERSION = 1;
|
||||
|
||||
function apiRepairDeps(): RepairDeps {
|
||||
return {
|
||||
listModules: async () => (await api.modules.list({})).items as unknown as RepairableEntry[],
|
||||
listBundles: async () => (await api.bundles.list({})).items as unknown as RepairableEntry[],
|
||||
createModule: async (e) => {
|
||||
const row = await api.modules.create({
|
||||
type: e.type as ModuleType,
|
||||
name: String(e.name ?? ""),
|
||||
description: typeof e.description === "string" ? e.description : undefined,
|
||||
category_id: (e.category_id as string | null | undefined) ?? null,
|
||||
tags: Array.isArray(e.tags) ? (e.tags as string[]) : undefined,
|
||||
payload: e.payload ?? {},
|
||||
content_rating: e.content_rating as "safe" | "nsfw" | undefined,
|
||||
});
|
||||
return { id: row.id };
|
||||
},
|
||||
createBundle: async (e) => {
|
||||
const row = await api.bundles.create({
|
||||
name: String(e.name ?? ""),
|
||||
description: typeof e.description === "string" ? e.description : undefined,
|
||||
color: (e.color as string | null | undefined) ?? null,
|
||||
category_id: (e.category_id as string | null | undefined) ?? null,
|
||||
tags: Array.isArray(e.tags) ? (e.tags as string[]) : undefined,
|
||||
children: (e.children as Array<Record<string, unknown>> | undefined) ?? [],
|
||||
content_rating: e.content_rating as "safe" | "nsfw" | undefined,
|
||||
});
|
||||
return { id: row.id };
|
||||
},
|
||||
updateModule: async (id, payload) => {
|
||||
await api.modules.update(id, { payload });
|
||||
},
|
||||
updateBundle: async (id, children) => {
|
||||
await api.bundles.update(id, { children: children as Array<Record<string, unknown>> });
|
||||
},
|
||||
deleteModule: async (id) => {
|
||||
await api.modules.delete(id);
|
||||
},
|
||||
deleteBundle: async (id) => {
|
||||
await api.bundles.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the library UUID repair once per `REPAIR_VERSION`. Fire-and-forget from
|
||||
* boot — never throws, never blocks first paint. The version marker is set
|
||||
* ONLY after a successful run, so a crash (or an offline DB) simply retries on
|
||||
* the next load. Returns the number of rows rekeyed (0 = nothing to do / gated
|
||||
* / failed), so the caller can refresh its catalogs when something changed.
|
||||
*/
|
||||
export async function repairLibraryOnce(): Promise<number> {
|
||||
try {
|
||||
const done = Number(localStorage.getItem(REPAIR_KEY) ?? "0");
|
||||
if (done >= REPAIR_VERSION) return 0;
|
||||
const { repaired } = await runLibraryRepair(apiRepairDeps());
|
||||
localStorage.setItem(REPAIR_KEY, String(REPAIR_VERSION));
|
||||
if (repaired > 0) {
|
||||
console.info(`[wp] library uuid repair: rekeyed ${repaired} row(s) with invalid ids`);
|
||||
}
|
||||
return repaired;
|
||||
} catch (err) {
|
||||
console.warn("[wp] library uuid repair failed (will retry next load):", err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findInvalidIds,
|
||||
planRemapUpdates,
|
||||
runLibraryRepair,
|
||||
type RepairableEntry,
|
||||
type RepairDeps,
|
||||
} from "./uuid-repair";
|
||||
|
||||
describe("findInvalidIds", () => {
|
||||
it("returns the ids that fail the 8-hex shape", () => {
|
||||
expect(
|
||||
findInvalidIds([{ id: "coloruni" }, { id: "ed1bccf8" }, { id: "mybundle" }]),
|
||||
).toEqual(["coloruni", "mybundle"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planRemapUpdates", () => {
|
||||
it("rewrites nested @{} (suffix kept) + whole-string axes; skips unchanged rows", () => {
|
||||
const updates = planRemapUpdates(
|
||||
[
|
||||
{ id: "aabbccdd", payload: { options: [{ id: "o1", value: "a @{coloruni#color:light or muted}" }] } },
|
||||
{ id: "ccddee11", payload: { source_wildcard_id: "coloruni", matrix: {} } },
|
||||
{ id: "ffff0000", payload: { options: [{ id: "o2", value: "plain text" }] } },
|
||||
],
|
||||
{ coloruni: "c0000001" },
|
||||
);
|
||||
expect((updates.find((u) => u.id === "aabbccdd")?.payload?.options as Array<{ value: string }>)[0].value).toBe(
|
||||
"a @{c0000001#color:light or muted}",
|
||||
);
|
||||
expect(updates.find((u) => u.id === "ccddee11")?.payload?.source_wildcard_id).toBe("c0000001");
|
||||
expect(updates.find((u) => u.id === "ffff0000")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("an empty rename map yields no updates", () => {
|
||||
expect(planRemapUpdates([{ id: "x", payload: {} }], {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runLibraryRepair", () => {
|
||||
function mockDeps(modules: RepairableEntry[], bundles: RepairableEntry[] = []) {
|
||||
const calls: string[] = [];
|
||||
let n = 0;
|
||||
const deps: RepairDeps = {
|
||||
listModules: async () => modules,
|
||||
listBundles: async () => bundles,
|
||||
createModule: async (e) => { calls.push(`create:${e.id}`); return { id: `aaaa000${++n}` }; },
|
||||
createBundle: async (e) => { calls.push(`createB:${e.id}`); return { id: `bbbb000${++n}` }; },
|
||||
updateModule: async (id) => { calls.push(`update:${id}`); },
|
||||
updateBundle: async (id) => { calls.push(`updateB:${id}`); },
|
||||
deleteModule: async (id) => { calls.push(`delete:${id}`); },
|
||||
deleteBundle: async (id) => { calls.push(`deleteB:${id}`); },
|
||||
};
|
||||
return { deps, calls: () => calls };
|
||||
}
|
||||
|
||||
it("rekeys an invalid module + remaps its referrer in create→update→delete order", async () => {
|
||||
const { deps, calls } = mockDeps([
|
||||
{ id: "coloruni", type: "wildcard", payload: { options: [] } },
|
||||
{ id: "aabbccdd", type: "wildcard", payload: { options: [{ id: "o1", value: "@{coloruni}" }] } },
|
||||
]);
|
||||
const res = await runLibraryRepair(deps);
|
||||
expect(res.repaired).toBe(1);
|
||||
// create the new color row → update the referrer (now @{aaaa0001}) → delete old color.
|
||||
// The created color's own payload has no refs, so it isn't re-updated.
|
||||
expect(calls()).toEqual(["create:coloruni", "update:aabbccdd", "delete:coloruni"]);
|
||||
});
|
||||
|
||||
it("is a no-op on a clean library", async () => {
|
||||
const { deps, calls } = mockDeps([{ id: "ed1bccf8", type: "wildcard", payload: { options: [] } }]);
|
||||
const res = await runLibraryRepair(deps);
|
||||
expect(res.repaired).toBe(0);
|
||||
expect(calls()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Library UUID repair (Part 3 of the uuid-fixer).
|
||||
*
|
||||
* Modules/bundles authored with a non-8-hex id (e.g. `coloruni`) can't be
|
||||
* nested-referenced (`@{coloruni}` matches neither the editor chip tokenizer
|
||||
* nor the engine resolver — both `[0-9a-f]{8}`). PR #5 fixes ids on the way IN
|
||||
* (import/download); this repairs ids ALREADY in the local library.
|
||||
*
|
||||
* `create` server-mints the id (no `id` field on the create body), so the new
|
||||
* id is only known AFTER the row is created. The orchestrator therefore:
|
||||
* create copies (server mints) → build old→new map → remap referrers + the
|
||||
* created rows → update → delete the old rows.
|
||||
*
|
||||
* Pure helpers (`findInvalidIds`, `planRemapUpdates`, `remapAll`) are exported
|
||||
* for unit tests; `runLibraryRepair` is dependency-injected so it tests against
|
||||
* a mock store with no server. Scope: TOP-LEVEL module + bundle ids (bundle
|
||||
* child-snapshot ids are a documented follow-up).
|
||||
*/
|
||||
import { isValidId } from "../utils/ids";
|
||||
import { walkRemap } from "../../components/context/bundles/uuid-remap";
|
||||
|
||||
/** Minimal shape the repair needs from a library row (module or bundle). */
|
||||
export interface RepairableEntry {
|
||||
id: string;
|
||||
type?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
children?: unknown[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
function escapeRe(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite nested `@{oldid…}` refs whose id is one of the renamed ids.
|
||||
* `walkRemap`'s `REF_RE` only matches `[0-9a-f]{6,16}`, so it SILENTLY skips a
|
||||
* non-hex id like `@{coloruni}` — exactly the ids we repair. Matches up to a
|
||||
* ref delimiter (`}` `#` `:` `!`) so the `#name`/`:filter`/`!null` suffix is
|
||||
* preserved verbatim.
|
||||
*/
|
||||
function rewriteInvalidNestedRefs(value: unknown, renameMap: Record<string, string>): unknown {
|
||||
if (typeof value === "string") {
|
||||
let out = value;
|
||||
for (const [oldId, newId] of Object.entries(renameMap)) {
|
||||
out = out.replace(new RegExp(`@\\{${escapeRe(oldId)}([#:!}])`, "g"), `@{${newId}$1`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v) => rewriteInvalidNestedRefs(v, renameMap));
|
||||
if (value && typeof value === "object") {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
obj[k] = rewriteInvalidNestedRefs(v, renameMap);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Full ref remap: `walkRemap` (whole-string constraint axes + any hex nested
|
||||
* refs) THEN the literal pass for the non-hex ids walkRemap can't see. */
|
||||
export function remapAll(value: unknown, renameMap: Record<string, string>): unknown {
|
||||
return rewriteInvalidNestedRefs(walkRemap(value, renameMap), renameMap);
|
||||
}
|
||||
|
||||
/** Ids that fail the canonical 8-hex shape and therefore need rekeying. */
|
||||
export function findInvalidIds(entries: readonly RepairableEntry[]): string[] {
|
||||
return entries.filter((e) => !isValidId(e.id)).map((e) => e.id);
|
||||
}
|
||||
|
||||
export interface RemapUpdate {
|
||||
id: string;
|
||||
payload?: Record<string, unknown>;
|
||||
children?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the COMPLETE old→new id map, return every entry whose payload or
|
||||
* children actually change under `remapAll` — the rows that need a server
|
||||
* update. Caller passes the rows under their CURRENT id (freshly-created rows
|
||||
* under their new id + original payload, plus the untouched referrers), so the
|
||||
* returned `id` is the one to `update`.
|
||||
*/
|
||||
export function planRemapUpdates(
|
||||
entries: readonly RepairableEntry[],
|
||||
renameMap: Record<string, string>,
|
||||
): RemapUpdate[] {
|
||||
if (Object.keys(renameMap).length === 0) return [];
|
||||
const out: RemapUpdate[] = [];
|
||||
for (const e of entries) {
|
||||
const update: RemapUpdate = { id: e.id };
|
||||
let changed = false;
|
||||
if (e.payload !== undefined) {
|
||||
const next = remapAll(e.payload, renameMap) as Record<string, unknown>;
|
||||
if (JSON.stringify(next) !== JSON.stringify(e.payload)) {
|
||||
update.payload = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (e.children !== undefined) {
|
||||
const next = remapAll(e.children, renameMap) as unknown[];
|
||||
if (JSON.stringify(next) !== JSON.stringify(e.children)) {
|
||||
update.children = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) out.push(update);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Store surface the repair needs — modules + bundles. Dependency-injected so
|
||||
* the orchestrator unit-tests against a mock with no server. `create*` return
|
||||
* the server-minted id. */
|
||||
export interface RepairDeps {
|
||||
listModules(): Promise<RepairableEntry[]>;
|
||||
listBundles(): Promise<RepairableEntry[]>;
|
||||
createModule(entry: RepairableEntry): Promise<{ id: string }>;
|
||||
createBundle(entry: RepairableEntry): Promise<{ id: string }>;
|
||||
updateModule(id: string, payload: Record<string, unknown>): Promise<void>;
|
||||
updateBundle(id: string, children: unknown[]): Promise<void>;
|
||||
deleteModule(id: string): Promise<void>;
|
||||
deleteBundle(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RepairResult {
|
||||
/** Count of rows rekeyed (modules + bundles). 0 = nothing to do. */
|
||||
repaired: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time library repair. Idempotent: a clean library (all valid ids) is a
|
||||
* no-op. Order is create → update → delete so a crash never leaves a dangling
|
||||
* ref — referrers always point at an id that already exists; worst case is a
|
||||
* harmless unreferenced row, cleaned on the next run.
|
||||
*/
|
||||
export async function runLibraryRepair(deps: RepairDeps): Promise<RepairResult> {
|
||||
const modules = await deps.listModules();
|
||||
const bundles = await deps.listBundles();
|
||||
const invalidModules = modules.filter((m) => !isValidId(m.id));
|
||||
const invalidBundles = bundles.filter((b) => !isValidId(b.id));
|
||||
if (invalidModules.length + invalidBundles.length === 0) return { repaired: 0 };
|
||||
|
||||
// 1. Create copies first — the server mints the new ids.
|
||||
const renameMap: Record<string, string> = {};
|
||||
const createdModules: RepairableEntry[] = [];
|
||||
for (const m of invalidModules) {
|
||||
const { id } = await deps.createModule(m);
|
||||
renameMap[m.id] = id;
|
||||
createdModules.push({ ...m, id });
|
||||
}
|
||||
const createdBundles: RepairableEntry[] = [];
|
||||
for (const b of invalidBundles) {
|
||||
const { id } = await deps.createBundle(b);
|
||||
renameMap[b.id] = id;
|
||||
createdBundles.push({ ...b, id });
|
||||
}
|
||||
|
||||
// 2. With the full map, remap referrers + the freshly-created rows (their
|
||||
// payloads may reference other rekeyed ids). Survivors keep their id.
|
||||
const survivorModules = modules.filter((m) => isValidId(m.id));
|
||||
const survivorBundles = bundles.filter((b) => isValidId(b.id));
|
||||
const moduleUpdates = planRemapUpdates([...createdModules, ...survivorModules], renameMap);
|
||||
const bundleUpdates = planRemapUpdates([...createdBundles, ...survivorBundles], renameMap);
|
||||
for (const u of moduleUpdates) {
|
||||
if (u.payload !== undefined) await deps.updateModule(u.id, u.payload);
|
||||
}
|
||||
for (const u of bundleUpdates) {
|
||||
if (u.children !== undefined) await deps.updateBundle(u.id, u.children);
|
||||
}
|
||||
|
||||
// 3. Delete the old (invalid-id) rows last.
|
||||
for (const m of invalidModules) await deps.deleteModule(m.id);
|
||||
for (const b of invalidBundles) await deps.deleteBundle(b.id);
|
||||
|
||||
return { repaired: invalidModules.length + invalidBundles.length };
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useCommunityUpdateStore } from "../stores/communityUpdateStore";
|
||||
import { useCascadeStore } from "../cascade/cascade-store";
|
||||
import { useGuidedPublishStore } from "../import-export/guided-publish-store";
|
||||
import { repairLibraryOnce } from "../import-export/uuid-repair-boot";
|
||||
import { api } from "../api/client";
|
||||
import {
|
||||
hashes as libraryHashes,
|
||||
@@ -175,6 +176,20 @@ onMounted(() => {
|
||||
cascadeStore.rebuildFromCatalogs(
|
||||
moduleStore.catalog, bundleStore.catalog, categoryStore.items,
|
||||
);
|
||||
// One-time repair of any pre-existing non-8-hex module/bundle ids (a
|
||||
// `coloruni`-style id can't be nested-referenced). Gated + fire-and-forget;
|
||||
// refresh the catalogs + cascade index only if it actually rekeyed rows.
|
||||
void repairLibraryOnce().then((repaired) => {
|
||||
if (repaired <= 0) return;
|
||||
void Promise.all([
|
||||
moduleStore.fetchCatalog().catch(() => undefined),
|
||||
bundleStore.fetchCatalog().catch(() => undefined),
|
||||
]).then(() => {
|
||||
cascadeStore.rebuildFromCatalogs(
|
||||
moduleStore.catalog, bundleStore.catalog, categoryStore.items,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Cross-check community-installed rows against the community
|
||||
// for newer versions. Runs after the library snapshot is in
|
||||
// place; failures (offline, CORS, server down) clear the
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isValidId, VALID_ID_RE, newShortId } from "./ids";
|
||||
|
||||
describe("isValidId / VALID_ID_RE", () => {
|
||||
it("accepts exactly 8 lowercase hex chars", () => {
|
||||
expect(isValidId("ed1bccf8")).toBe(true);
|
||||
expect(isValidId("00000000")).toBe(true);
|
||||
expect(isValidId("deadbeef")).toBe(true);
|
||||
expect(VALID_ID_RE.test("c010c010")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-hex, wrong length, uppercase, and non-strings", () => {
|
||||
expect(isValidId("coloruni")).toBe(false); // the real bug: has o/r/u/n/i
|
||||
expect(isValidId("ED1BCCF8")).toBe(false); // uppercase
|
||||
expect(isValidId("ed1bccf")).toBe(false); // 7 chars
|
||||
expect(isValidId("ed1bccf80")).toBe(false); // 9 chars
|
||||
expect(isValidId("opt00001")).toBe(false); // option-id shape (o/p/t)
|
||||
expect(isValidId("")).toBe(false);
|
||||
expect(isValidId(undefined)).toBe(false);
|
||||
expect(isValidId(12345678)).toBe(false);
|
||||
});
|
||||
|
||||
it("every newShortId() passes isValidId", () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
expect(isValidId(newShortId())).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -35,3 +35,19 @@ export function newShortId(): string {
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical module / bundle id shape — exactly 8 lowercase hex chars,
|
||||
* matching `newShortId()` and the engine's `secrets.token_hex(4)`. Both
|
||||
* the nested-`@{uuid}` ref tokenizer (`engine/syntax/tokenize.py`,
|
||||
* `richTokenize.ts`) and the constraint axis refs key off this shape, so
|
||||
* an id outside it (e.g. a hand-authored slug like `coloruni`) silently
|
||||
* can't be nested-referenced. Option ids are intentionally NOT covered —
|
||||
* they're opaque strings the ref tokenizer never matches.
|
||||
*/
|
||||
export const VALID_ID_RE = /^[0-9a-f]{8}$/;
|
||||
|
||||
/** True when `id` is a canonical 8-lowercase-hex module/bundle id. */
|
||||
export function isValidId(id: unknown): id is string {
|
||||
return typeof id === "string" && VALID_ID_RE.test(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { expandInlineChoices } from "./resolver";
|
||||
|
||||
describe("expandInlineChoices — branch weights (engine parity)", () => {
|
||||
it("strips the N:: weight prefix — never leaks into output", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const out = expandInlineChoices("{2::a|b}");
|
||||
expect(["a", "b"]).toContain(out);
|
||||
expect(out).not.toContain("::");
|
||||
}
|
||||
});
|
||||
|
||||
it("a zero-weight branch is never picked", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(expandInlineChoices("{1::keep|0::drop}")).toBe("keep");
|
||||
}
|
||||
});
|
||||
|
||||
it("user repro: {0::|, long shadows} resolves to the non-empty branch", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(expandInlineChoices("{0::|, long shadows}")).toBe(", long shadows");
|
||||
}
|
||||
});
|
||||
|
||||
it("unweighted choices still resolve to one branch verbatim", () => {
|
||||
const out = expandInlineChoices("{a|b|c}");
|
||||
expect(["a", "b", "c"]).toContain(out);
|
||||
});
|
||||
|
||||
it("a literal foo::bar (non-numeric prefix) is left intact", () => {
|
||||
// Only a NUMERIC `N::` prefix is a weight; `http::x` style stays put.
|
||||
expect(expandInlineChoices("{x::y|x::y}")).toBe("x::y");
|
||||
});
|
||||
});
|
||||
@@ -49,10 +49,34 @@ export function pickWeightedOption(
|
||||
return options[options.length - 1];
|
||||
}
|
||||
|
||||
/** Parse the `N::` branch-weight prefix — 1 when absent, 0 when ≤0, 1 on a
|
||||
* non-numeric prefix. Mirrors the engine's `_parse_branch_weight`. */
|
||||
function parseBranchWeight(branch: string): number {
|
||||
const i = branch.indexOf("::");
|
||||
if (i < 0) return 1;
|
||||
const prefix = branch.slice(0, i);
|
||||
if (prefix.trim() === "") return 1;
|
||||
const n = Number(prefix);
|
||||
if (Number.isNaN(n)) return 1;
|
||||
return n <= 0 ? 0 : n;
|
||||
}
|
||||
|
||||
/** Strip a numeric `N::` weight prefix; leave the branch untouched when the
|
||||
* prefix isn't a number (a literal `foo::bar` survives). Mirrors the
|
||||
* engine's `_strip_branch_weight`. */
|
||||
function stripBranchWeight(branch: string): string {
|
||||
const i = branch.indexOf("::");
|
||||
if (i < 0) return branch;
|
||||
const prefix = branch.slice(0, i);
|
||||
if (prefix.trim() === "" || Number.isNaN(Number(prefix))) return branch;
|
||||
return branch.slice(i + 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand inline `{a|b|c}` choices in a value string. Each occurrence is
|
||||
* resolved to one of its `|`-separated branches at random. Nested braces are
|
||||
* resolved innermost-first via a fixpoint loop.
|
||||
* resolved to one of its `|`-separated branches at random (weighted when a
|
||||
* branch carries an `N::` prefix). Nested braces are resolved innermost-first
|
||||
* via a fixpoint loop.
|
||||
*
|
||||
* Important — this function MUST NOT touch `@{...}` refs. The previous
|
||||
* implementation delegated to `resolveTokens` which tokenized `@{8hex}`
|
||||
@@ -74,8 +98,21 @@ export function expandInlineChoices(text: string): string {
|
||||
const next = out.replace(
|
||||
/\{([^{}]*\|[^{}]*)\}/g,
|
||||
(_, content: string) => {
|
||||
const branches = content.split("|");
|
||||
return branches[Math.floor(Math.random() * branches.length)];
|
||||
const raw = content.split("|");
|
||||
const values = raw.map(stripBranchWeight);
|
||||
const hasWeights = values.some((v, i) => v !== raw[i]);
|
||||
if (!hasWeights) {
|
||||
return values[Math.floor(Math.random() * values.length)];
|
||||
}
|
||||
const weights = raw.map(parseBranchWeight);
|
||||
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
|
||||
if (total <= 0) return values[0];
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
r -= Math.max(0, weights[i]);
|
||||
if (r <= 0) return values[i];
|
||||
}
|
||||
return values[values.length - 1];
|
||||
},
|
||||
);
|
||||
if (next === out) break;
|
||||
|
||||
@@ -388,6 +388,33 @@ def test_resolve_inline_pick_empty_branch_valid():
|
||||
assert out in {"a", "", "c"}
|
||||
|
||||
|
||||
def test_resolve_inline_pick_strips_branch_weight_prefix():
|
||||
"""`{N::value}` weight prefix must never leak into the output. Regression:
|
||||
simple inline picks ignored `N::`, emitting the literal `2::a`."""
|
||||
for seed in range(12):
|
||||
ctx = _ctx(rng=random.Random(seed))
|
||||
out = resolve_text("{2::a|b}", ctx)
|
||||
assert out in {"a", "b"}, f"seed {seed}: {out!r}"
|
||||
assert "::" not in out, f"seed {seed}: weight prefix leaked: {out!r}"
|
||||
|
||||
|
||||
def test_resolve_inline_pick_respects_branch_weight():
|
||||
"""A zero-weight branch is never picked — weighting drives the choice."""
|
||||
for seed in range(12):
|
||||
ctx = _ctx(rng=random.Random(seed))
|
||||
assert resolve_text("{1::keep|0::drop}", ctx) == "keep", f"seed {seed}"
|
||||
|
||||
|
||||
def test_resolve_inline_pick_weighted_empty_branch():
|
||||
"""User repro `{2::|, long shadows}`: the `2::` empty branch emits empty,
|
||||
never the literal prefix. Zero-weight variant pins the assertion."""
|
||||
for seed in range(12):
|
||||
ctx = _ctx(rng=random.Random(seed))
|
||||
assert resolve_text("{0::|, long shadows}", ctx) == ", long shadows"
|
||||
out = resolve_text("{2::|, long shadows}", ctx)
|
||||
assert out in {"", ", long shadows"}, f"seed {seed}: {out!r}"
|
||||
|
||||
|
||||
def test_resolve_inline_pick_single_branch_is_text_token():
|
||||
"""`{just_one}` is NOT a pick (no pipe) — tokenizer returns it as text,
|
||||
so resolver passes through verbatim."""
|
||||
|
||||
@@ -603,6 +603,21 @@ class TestConstraintComposition:
|
||||
]
|
||||
assert excludes_all, "expected an excludes-all warning when both fire"
|
||||
|
||||
def test_constraint_both_ends_missing_emits_source_and_target_warnings(self):
|
||||
"""A constraint whose SOURCE and TARGET wildcards are both absent from
|
||||
the chain surfaces BOTH a `constraint_never_applied` (target-focused)
|
||||
AND a `constraint_source_missing` warning — so the Debug viewer shows
|
||||
both ends, matching the editor's separate per-end row badges."""
|
||||
ctx = _run([
|
||||
_wildcard("present", "color", [{"id": "c1", "value": "red", "weight": 1}]),
|
||||
_constraint("cX", source="missing_src", target="missing_tgt", exceptions=[
|
||||
{"source": "a", "target": "b", "mode": "exclude", "factor": 0},
|
||||
]),
|
||||
], seed=1)
|
||||
types = [w.get("type") for w in ctx.get("__wp_warnings__", [])]
|
||||
assert "constraint_never_applied" in types, types
|
||||
assert "constraint_source_missing" in types, types
|
||||
|
||||
def test_first_mode_constraint_yields_to_let_other_survive(self):
|
||||
"""The pre-SP3 'only the first constraint fires' outcome is still
|
||||
reachable by marking c2 `first` and giving it no earlier target:
|
||||
|
||||
Reference in New Issue
Block a user