"""Token-walking resolver. Mirrors `resolveTokens.ts` (Task 12 will land that). Each token kind has a `_resolve_` helper. The main loop walks tokens left-to-right, dispatches by kind, and concatenates results. Recursion happens inside `_resolve_ref` (Task 9) and brace resolvers (Tasks 10–11). """ from __future__ import annotations import logging from contextlib import nullcontext from typing import Any from engine.syntax.streams import site_label, sub_stream 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, RecursionLimitExceeded, RefOutOfSurfaceError, ResolveContext, Token, TokenKind, UnknownRefError, deref_var_value, ) logger = logging.getLogger(__name__) def resolve_text(text: str, ctx: ResolveContext) -> str: """Resolve `text` against `ctx`, returning the final string. Walks tokens once; for each token, dispatches to a per-kind resolver. `depth` and `visited` are managed internally during ref recursion. """ tokens = tokenize_text(text) return _resolve_tokens(tokens, ctx, depth=0, visited=()) def _resolve_tokens( tokens: list[Token], ctx: ResolveContext, depth: int, visited: tuple[str, ...], ) -> str: parts: list[str] = [] empty_refs: list[int] = [] # Each pick site rolls on its own stream (engine/syntax/streams.py), so # one site's draws never move another's. # A context with no stream key (unit tests, previews) skips the site # labels: building one per token was most of a big resolve's time. seen: dict[str, int] = {} keyed = bool(getattr(ctx, "stream_key", None)) def site(i: int) -> Any: return sub_stream(ctx, site_label(tokens[i].raw, _after(tokens, i), seen)) \ if keyed else _NO_STREAM for i, tok in enumerate(tokens): if tok.kind == TokenKind.TEXT: parts.append(tok.raw) elif tok.kind == TokenKind.ESCAPE: parts.append(tok.meta.get("literal", "")) elif tok.kind == TokenKind.VAR: parts.append(_resolve_var(tok, ctx)) elif tok.kind == TokenKind.REF: with site(i): resolved = _resolve_ref(tok, ctx, depth, visited) if not resolved: empty_refs.append(len(parts)) parts.append(resolved) elif tok.kind == TokenKind.DP_BRACE: with site(i): parts.append(_resolve_inline_pick(tok, ctx, depth, visited)) elif tok.kind == TokenKind.DP_MULTI: with site(i): parts.append(_resolve_multi_pick(tok, ctx, depth, visited)) elif tok.kind == TokenKind.DP_PIPE: # DP_PIPE is never emitted as a top-level token by the current # tokenizer (pipes are absorbed into branch boundaries). If one # appears here, that's a tokenizer bug. raise AssertionError("DP_PIPE should never be top-level") else: raise AssertionError(f"unknown token kind: {tok.kind}") return _join_tidy(parts, empty_refs) _NO_STREAM = nullcontext() def _after(tokens: list[Token], i: int) -> str: """The literal text right after token `i` (its site's context).""" nxt = tokens[i + 1] if i + 1 < len(tokens) else None return nxt.raw if nxt is not None and nxt.kind == TokenKind.TEXT else "" _HWS = " \t" def _join_tidy(parts: list[str], empty_refs: list[int]) -> str: """Join resolved parts, closing the gap each empty ``@{}`` ref leaves. A ref that resolves to nothing (a placeholder or deleted module, a filter that matched no option, an empty option) used to leave its surroundings behind: ``red @{x} dress`` became ``red dress`` and ``red, @{x}, dress`` became ``red, , dress``. At each such seam this drops one doubled comma, then one side of doubled spaces/tabs, and at the start or end of the text the dangling comma and spaces. Newlines and everything away from the seam are left exactly as written. Mirrored in ``resolveTokens.ts``. """ if not empty_refs: return "".join(parts) out = list(parts) for i in empty_refs: left = next((j for j in range(i - 1, -1, -1) if out[j]), None) right = next((j for j in range(i + 1, len(out)) if out[j]), None) if left is None or right is None: if right is not None: out[right] = _strip_lead(out[right]) elif left is not None: out[left] = _strip_trail(out[left]) continue if out[left].rstrip(_HWS).endswith(",") and out[right].lstrip(_HWS).startswith(","): out[right] = out[right].lstrip(_HWS)[1:] if out[right] and out[left][-1:] in (" ", "\t") and out[right][0] in _HWS: out[right] = out[right].lstrip(_HWS) return "".join(out) def _strip_lead(text: str) -> str: text = text.lstrip(_HWS) return text[1:].lstrip(_HWS) if text.startswith(",") else text def _strip_trail(text: str) -> str: text = text.rstrip(_HWS) return text[:-1].rstrip(_HWS) if text.endswith(",") else text _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.""" name = tok.meta.get("name", "") if name.startswith("__"): 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" ), ) # As written, accessor included (`$c.0` stays `$c.0`, not `$c`). return tok.raw # An axis read is a different lookup against a different bucket, so it # branches BEFORE the var lookup — `$outfit.SHOES` must resolve even when # nothing bound `$outfit` itself, and must not raise `unknown_var` on the # way past. axis = tok.meta.get("axis") if axis == NEG_ACCESSOR: # `$name.neg` reads the variable's negatives. Not a read of the # variable itself: it carries no negatives along (that would echo # them) and does not count as rendering `$name`. return _resolve_negative(tok, ctx) if axis == BOOL_ACCESSOR: return _resolve_bool(tok, ctx) if axis: out = _resolve_axis(tok, ctx, str(axis)) _note_read(ctx, name, tok.meta.get("index"), out) return out 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}", ) 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. out = deref_var_value(value, tok.meta.get("index")) _note_read(ctx, name, tok.meta.get("index"), out) return out #: The accessor that reads a variable's negatives (`$name.neg`, #: `$name.K.neg`). Reserved: an `accepts` tag group cannot use this name. NEG_ACCESSOR = "neg" #: The accessor that reads a WP Toggles variable's on/off (`$name.bool`), #: whatever value the toggle writes. Reserved like `neg`. BOOL_ACCESSOR = "bool" #: Names an `accepts` tag group may not use: they are accessors. RESERVED_ACCESSORS = frozenset({NEG_ACCESSOR, BOOL_ACCESSOR}) def _resolve_bool(tok: Token, ctx: ResolveContext) -> str: """Render `$name.bool`: "true" / "false". Not a read of `$name` itself, so it carries no negatives along.""" name = str(tok.meta.get("name", "")) getter: Any = getattr(ctx, "get_bool", None) got: Any = getter(name) if callable(getter) else None if got is None: own = ctx.get_var(name) if isinstance(own, str) and own.strip().lower() in ("true", "false"): return own.strip().lower() if own 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}", ) return "" return str(got) def _resolve_negative(tok: Token, ctx: ResolveContext) -> str: """Render `$name.neg` / `$name.K.neg`: the negatives filed for `name` (all of them, or pick K's plus the whole-value ones), tag-deduped. A variable with no negatives renders empty without a warning, since most variables have none. Only an unbound name that also has none warns.""" name = str(tok.meta.get("name", "")) index = tok.meta.get("index") getter: Any = getattr(ctx, "get_negative", None) got: Any = getter(name, index if isinstance(index, int) else None) if callable(getter) else None if got is None and ctx.get_var(name) 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}", ) return got or "" def _note_read(ctx: ResolveContext, name: str, index: Any, out: str) -> None: """Tell a collecting context (send-to-negative) which binding this read rendered, and which pick. An indexed read that rendered nothing (out of range) carries nothing.""" note = getattr(ctx, "note_var_read", None) if not callable(note): return if index is not None and not out: return note(name, index if isinstance(index, int) else None) def _note_ref_negative(ctx: ResolveContext, option: dict | None) -> None: note = getattr(ctx, "note_ref_negative", None) if callable(note) and isinstance(option, dict): note(option) def _resolve_axis(tok: Token, ctx: ResolveContext, axis: str) -> str: """Render `$name.AXIS`, `$name.K.AXIS` and `$name.AXIS.K`. Never re-rolls: the choice was made when the wildcard picked, and this reads it back — that is what makes two modules, or two nodes, agree. Deliberately does NOT route through `deref_var_value`. An axis value is not a ListVar, and keeping it off that path leaves the SP2a accessor contract untouched. """ name = str(tok.meta.get("name", "")) got = ctx.get_axis(name, axis) if got is None: declared = ctx.declared_axes(name) if declared is not None and axis in declared: # The axis exists; this roll's option just carries no tag on it. # Legitimate (the editor already advises on such options), so it # is an info note, and asking whether the group is marked # `accepts` would send the user to fix something that is fine. _push_warning( ctx, type="axis_untagged_pick", severity="info", module_id="", source_field="", position=tok.start, token_index=None, detail={"name": name, "axis": axis, "surface": ctx.surface}, message=( f"${name}.{axis} is empty: the picked ${name} option " f"has no '{axis}' tag" ), ) return "" if declared: hint = ( f"its wildcard's accepts groups are {', '.join(declared)}. " f"Is '{axis}' a classify group, or misspelled?" ) elif declared is not None: hint = ( "its wildcard has no accepts groups. Is that tag group " "marked 'accepts'?" ) else: hint = "is that tag group marked 'accepts'?" _push_warning( ctx, type="unknown_tag_axis", severity="warn", module_id="", source_field="", position=tok.start, token_index=None, detail={ "name": name, "axis": axis, "surface": ctx.surface, "declared": declared, }, message=f"${name} has no '{axis}' axis — {hint}", ) return "" index = tok.meta.get("index") if isinstance(got, list): if index is None: return ", ".join(g for g in got if g) if isinstance(index, int) and 0 <= index < len(got): return got[index] or "" return "" # A single pick behaves as a one-element list, mirroring deref_var_value so # `$x.0.AXIS` == `$x.AXIS` and any higher index is empty. if index is not None: return str(got) if index == 0 else "" return str(got) def _push_warning(ctx: ResolveContext, **fields) -> None: """Append a warning dict to ctx.warnings. Helper to keep call sites tidy.""" ctx.warnings.append(fields) def _pick_weighted(options: list[dict], rng) -> dict | None: """Pick one option weighted by `weight`. Returns None for empty list. Mirrors the pre-Phase-5 wildcard_handler._pick_weighted but takes `rng` as an explicit parameter instead of using random.random(). """ if not options: return None total = sum(max(0.0, float(o.get("weight", 1))) for o in options) if total <= 0: # Nothing is selectable — every option is either weight 0 (documented # as "disable without deleting") or was excluded by a constraint. # Returning options[0] here looked like a working pick: a real option, # right shape, right place, and wrong. It stayed wrong silently until # the list was reordered. Callers turn None into an empty binding, # which is what the null option already means. return None r = rng.random() * total acc = 0.0 for opt in options: acc += max(0.0, float(opt.get("weight", 1))) if r <= acc: return opt return options[-1] def _resolve_inline_pick( tok: Token, ctx: ResolveContext, depth: int, 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 "" # 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) return _resolve_tokens(nested_tokens, ctx, depth=depth, visited=visited) def _parse_branch_weight(branch: str) -> float: """Parse `N::value` micro-syntax. Returns 1.0 if no prefix.""" if "::" not in branch: return 1.0 prefix, _, _ = branch.partition("::") try: n = float(prefix) if n <= 0: return 0.0 return n except ValueError: return 1.0 def _strip_branch_weight(branch: str) -> str: """Return branch text without the `N::` weight prefix, if present.""" if "::" not in branch: return branch prefix, _, rest = branch.partition("::") try: float(prefix) return rest except ValueError: return branch def _weighted_pick_index(weights: list[float], rng) -> int: """Return an index into `weights` weighted by value. Total > 0 assumed.""" total = sum(max(0.0, w) for w in weights) if total <= 0: return 0 r = rng.random() * total acc = 0.0 for idx, w in enumerate(weights): acc += max(0.0, w) if r <= acc: return idx 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`` -> ``[]``. Items weighted 0 are never picked, so fewer than ``k`` come back once the live ones run out. 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): # Stop once nothing live is left: a weight-0 item (disabled, or shut # out by a constraint) is never drawn, even to fill the count. if not available_idx or sum(max(0.0, w) for w in available_w) <= 0: 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`. SP2b: `count` is a range `[min, max]` (a fixed `{N$$}` has min==max), and `independent` (the `~` flag) toggles unique-without-replacement (default) vs N draws WITH replacement. A lone nested-ref body in unique mode draws N DISTINCT options from the ref's filtered pool (a per-site SP2a). Each chosen value is recursively resolved. """ # Seedless assembler surface — see _resolve_inline_pick. Render verbatim # rather than freezing a deterministic seed-0 multi-pick. if ctx.surface == "assembler": return tok.raw cmin: int = tok.meta.get("min", tok.meta.get("count", 0)) cmax: int = tok.meta.get("max", tok.meta.get("count", 0)) independent: bool = bool(tok.meta.get("independent", False)) sep: str = tok.meta.get("sep", "") branches: list[str] = list(tok.meta.get("branches", [])) # Range count: a fixed N (min==max) does NOT draw from rng, so a `{N$$}` # reproduces the pre-SP2b seed sequence byte-for-byte. A true range draws. count = cmin if cmin == cmax else ctx.rng.randint(cmin, cmax) if count <= 0: return "" # 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", "") # Weight 0 is how a constraint (or the user) shuts an option # out: it stays out of the multi-pick too. A missing weight is 1. pool = [o for o in ref_option_pool(ref_tok, ctx) if float(1 if o.get("weight") is None else o["weight"]) > 0] if not pool: return "" values = [str(o.get("value", "")) for o in pool] weights = [float(1 if o.get("weight") is None else o["weight"]) 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: Any = getattr(ctx, "get_carrier", None) set_carrier: Any = getattr(ctx, "set_carrier", None) saved_carrier: Any = ( get_carrier() if callable(get_carrier) else None ) if callable(set_carrier): set_carrier(ref_uuid, None) try: parts: list[str] = [] id_of = {str(o.get("value", "")): o.get("id") for o in pool} opt_of = {str(o.get("value", "")): o for o in pool} for i, v in enumerate(chosen): _note_ref_negative(ctx, opt_of.get(v)) logged = _log_ref_pick(ctx, ref_uuid, id_of.get(v), depth, v) with sub_stream(ctx, f"item{i}"): out = _resolve_tokens( tokenize_text(v), ctx, depth=depth + 1, visited=visited + (ref_uuid,), ) if v else "" if logged is not None: logged["value"] = out parts.append(out) return sep.join(parts) 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 i in range(count): j = _weighted_pick_index(weights, ctx.rng) v = branch_values[j] with sub_stream(ctx, f"item{i}"): resolved.append( _resolve_tokens(tokenize_text(v), ctx, depth=depth, visited=visited) if v else "" ) return sep.join(resolved) # Unique over branches (legacy path, now range-aware): N distinct branches. n_picks = min(count, len(branches)) if n_picks == 0: return "" chosen_values = pick_k_unique(branch_values, weights, n_picks, ctx.rng) for i, branch_text in enumerate(chosen_values): if not branch_text: resolved.append("") continue nested = tokenize_text(branch_text) with sub_stream(ctx, f"item{i}"): resolved.append(_resolve_tokens(nested, ctx, depth=depth, visited=visited)) 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: pool_total = len(options) ast = _parse_subcat(filter_expr) if isinstance(filter_expr, str) else None if ast is not None and isinstance(filter_expr, str) and "$" in filter_expr: # Schema v11: `$name` terms read the run's variables (as a use's # pool filter does). Quiet resolves (negatives) file nothing. from engine.syntax.subcat_filter import bind, filter_variable_warning, values_of from engine.syntax.subcat_filter import wildcard_tags as _wildcard_tags src = payload_dict if isinstance(payload_dict, dict) else module ast, notes = bind( ast, lambda n: values_of(ctx.get_var(n)), _wildcard_tags(src), src.get("tag_groups") if isinstance(src, dict) else None, ) for note in notes: ctx.warnings.append(filter_variable_warning( note, where=f"@{{{uuid}:{filter_expr}}}: ", )) 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: # "Matched no options" on its own is true and useless — it cannot # tell a wrong filter from a right filter aimed at a stale pool. # Report WHICH pool was searched, how big it was, and what tags it # actually offers, so the panel answers the question instead of # restating the symptom. available = sorted({ t for o in (payload_dict.get("options", []) if isinstance(payload_dict, dict) else module.get("options", [])) if isinstance(o, dict) for t in (o.get("sub_categories") or []) if isinstance(t, str) }) pool_origin = module.get("pool_origin") library_count = module.get("library_option_count") detail = { "uuid": uuid, "filter_expr": filter_expr, "exclude_null": exclude_null, "pool_total": pool_total, "available_sub_categories": available, } if pool_origin: detail["pool_origin"] = pool_origin if isinstance(library_count, int): detail["library_option_count"] = library_count where = ( "this node's copy" if pool_origin == "node" else "the library" if pool_origin == "library" else "the pool" ) drift = ( f"; the library now has {library_count}" if isinstance(library_count, int) else "" ) _push_warning( ctx, type="ref_subcategory_empty_pool", severity="warn", module_id="", source_field="", position=tok.start, token_index=None, detail=detail, message=( f"@{{{uuid}:{filter_expr or ''}" f"{'!null' if exclude_null else ''}}} matched no options — " f"searched {where} ({pool_total} option" f"{'' if pool_total == 1 else 's'}){drift}" ), ) return [] # Schema v9 fallback (engine/modules/_fallback.py): the flagged option # rolls normally and returns as the whole pool when the constraints below # leave nothing to pick. from engine.modules._fallback import fallback_warning, find_fallback, pool_is_dead fallback = find_fallback(options) any_applied = False get_constraints: Any = getattr(ctx, "get_constraints", None) get_picks: Any = getattr(ctx, "get_picks", None) if callable(get_constraints) and callable(get_picks): constraints: Any = get_constraints() if constraints: from engine.modules._constraints import ( apply_constraints_for_target, warn_excludes_all, ) from engine.modules.wildcard_handler import _accepts_axes # 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: Any = getattr(ctx, "get_constraint_hits", None) hits = get_hits() if callable(get_hits) else {} get_carrier_ctx: Any = getattr(ctx, "get_carrier_ctx", None) carrier_ctx = get_carrier_ctx() if callable(get_carrier_ctx) else None picks: Any = get_picks() options, any_applied = apply_constraints_for_target( options, uuid, constraints, picks, ctx.warnings, hits=hits, firing_uid=None, carrier_ctx=carrier_ctx, # TARGET-side accepts axes for a nested @{} target, so its # multi-tag accepts option survives when the source allows any # alternative (same as the top-level path). target_axes=( _accepts_axes(payload_dict) if isinstance(payload_dict, dict) else {} ), ) if any_applied and fallback is None: warn_excludes_all(options, uuid, ctx.warnings) if fallback is not None and pool_is_dead(options): ctx.warnings.append(fallback_warning(uuid, fallback, constrained=any_applied)) return [{**fallback, "weight": 1}] return options def _log_ref_pick( ctx: ResolveContext, uuid: str, option_id: Any, depth: int, raw: str, ) -> dict[str, Any] | None: """Record one nested `@{uuid}` pick in the opt-in ref log, if the context carries one (the Test Runner seeds it; canvas runs don't). Returns the stored row so the caller can add the expanded `value` after recursing, or None when nothing is being logged.""" log_ref: Any = getattr(ctx, "log_ref", None) if not callable(log_ref): return None module = ctx.get_module(uuid) row: Any = log_ref({ "uuid": uuid, "name": (module or {}).get("name") or uuid, "option_id": option_id, "depth": depth, "raw": raw, }) return row def _resolve_ref( tok: Token, ctx: ResolveContext, depth: int, visited: tuple[str, ...], ) -> str: uuid = tok.meta.get("uuid", "") # Depth check (first — prevents infinite recursion even on cyclic graphs) if depth >= ctx.max_ref_depth: chain = list(visited) + [uuid] if ctx.strict: raise RecursionLimitExceeded( f"max ref depth {ctx.max_ref_depth} exceeded: " + " → ".join(f"@{{{u}}}" for u in chain) ) _push_warning( ctx, type="recursion_limit", severity="error", module_id="", source_field="", position=tok.start, token_index=None, detail={"chain": chain, "limit": ctx.max_ref_depth}, message=f"Recursion limit reached at depth {ctx.max_ref_depth}", ) return "" # Cycle check if uuid in visited: chain = list(visited) + [uuid] if ctx.strict: raise CycleDetectedError(chain) _push_warning( ctx, type="cycle_detected", severity="error", module_id="", source_field="", position=tok.start, token_index=None, detail={"chain": chain}, message="Cycle: " + " → ".join(f"@{{{u}}}" for u in chain), ) 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"): if ctx.strict: raise RefOutOfSurfaceError(uuid, ctx.surface) # Try to resolve module name for the warning detail (best-effort) module = ctx.get_module(uuid) name = module.get("var_binding") if module else None _push_warning( ctx, type="ref_out_of_surface", severity="info", module_id="", source_field="", position=tok.start, token_index=None, detail={"uuid": uuid, "name": name, "surface": ctx.surface}, message=f"@{{{uuid}}} ignored in {ctx.surface!r} surface", ) 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: return "" chosen = _pick_weighted(options, ctx.rng) if chosen is None: return "" # Send-to-negative: the picked option's negative belongs to whatever this # text resolves into (the carrier binding). Noted even for an empty pick, # so a "none" option can still say what it rules out. _note_ref_negative(ctx, chosen) chosen_value = str(chosen.get("value", "")) 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. nested_tokens = tokenize_text(chosen_value) # Opt-in ref log (Test Runner): record this pick before recursing so # the log reads in pre-order, then fill in what it expanded to. logged = _log_ref_pick(ctx, uuid, chosen.get("id"), depth, chosen_value) get_carrier: Any = getattr(ctx, "get_carrier", None) set_carrier: Any = getattr(ctx, "set_carrier", None) if callable(get_carrier) and callable(set_carrier): saved_carrier: Any = get_carrier() set_carrier(uuid, chosen.get("id")) try: out = _resolve_tokens( nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,) ) finally: set_carrier(*saved_carrier) else: out = _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,)) if logged is not None: logged["value"] = out return out