Compare commits

...
3 Commits
Author SHA1 Message Date
semantic-release-bot d628a0687d chore(release): 2.5.0 [skip ci] 2026-06-18 04:36:29 +00:00
DumiFlex 02cc9f766f feat(manager): one-time library repair for non-hex module/bundle ids
Pre-existing ids that aren't 8-hex (e.g. an imported 'coloruni') can't be nested-referenced. A version-gated SPA-boot repair (runLibraryRepair, fired fire-and-forget from AppLayout) rekeys them. create() server-mints ids, so it creates copies -> builds the old->new map -> remaps every referrer (nested @{} via a literal pass since walkRemap's REF_RE is 8-hex only, plus whole-string constraint axes) -> updates -> deletes the old rows, ordered create->update->delete so a crash never leaves a dangling ref. Pure planning (findInvalidIds / planRemapUpdates / remapAll) unit-tested; orchestrator tested against a mock store.
2026-06-18 07:31:07 +03:00
DumiFlex a1d051172d fix(import): normalize non-hex module/bundle ids on import + download
A module/bundle id that isn't a canonical 8-hex uuid (e.g. a hand-authored 'coloruni' that round-tripped through import -> community -> download) silently can't be nested-referenced: @{coloruni} matches neither the editor chip tokenizer nor the engine resolver (both require [0-9a-f]{8}), so the ref renders as plain text and never resolves. install.ts now mints a fresh valid id for any invalid module/bundle id (new isValidId in utils/ids.ts) and folds it into the reattach rename map, so walkRemap rewrites every @{oldid} ref + constraint source/target axis to the new id. Covers both import and community download (same install path).
2026-06-18 07:00:09 +03:00
11 changed files with 475 additions and 5 deletions
+13
View File
@@ -1,3 +1,16 @@
## 🎉 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)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.4.0",
"version": "2.5.0",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "2.4.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"
@@ -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();
+37 -3
View File
@@ -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([]);
});
});
+178
View File
@@ -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 };
}
+15
View File
@@ -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
+28
View File
@@ -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);
}
});
});
+16
View File
@@ -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);
}