module

Krikri

PythonPattern - a scan of a user-supplied pattern through Python's own re._parser tokenizer semantics, shared by the file-editing plugins (replace, lineinfile). It rejects first what PCRE2 would happily compile but Ansible's Python rejects, rewrites the two escape forms Python accepts and PCRE2 does not (\uXXXX, \Uhhhhhhhh), and carries the positions python_pcre_error needs to translate a PCRE2 compile error into the re.error wording Ansible's user actually sees. Lived in the replace plugin until lineinfile needed the same machinery: real lineinfile.py compiles regexp: (and a non-literal insertafter:/insertbefore: anchor) with a bare re.compile no except clause catches, so a bad pattern crashes the module instead of silently matching nothing.

Constants

ANSIBLE_TARGET_VERSION = "2.21.6"

The ansible-core release krikri's output is verified against, byte for byte (see README "Compatibility target"). Also the value of the ansible_version magic variable.

ANSIBLE_VERSION_MAGIC_VAR = JSON.parse("{\n \"string\": \"2.21.6\", \"full\": \"2.21.6\", \"major\": 2, \"minor\": 21, \"revision\": 6\n }")

ansible_version - a Ansible magic var ({full, major, minor, revision, string}) giving the CONTROLLER's ansible-core version, used by real roles for feature-detection (ansible_version.string is version_compare(min_version, '>=')). Entirely unimplemented before - any reference to it (even the common ansible_version.string is version_compare(...) idiom, a BARE dotted lookup) resolved to this engine's own "undefined" sentinel and either silently mis-evaluated the comparison or (since 0.9.517's strict module-arg templating) hard failed the task outright. Found live re-benchmarking xanmanning.k3s (round 163 regression check) - its own pre_checks.yml gates on exactly this pattern before doing anything else, so the WHOLE role failed at task 1 on every rerun. Reports a ansible-core version (not this project's own "0.9.x" version number) deliberately: this engine's whole design goal is behavioral parity with Ansible, and every version-gated role feature in the wild was written expecting a 2.x-shaped comparison target, not a sub-1.0 one - reporting crystal's own version here would make EVERY such min-version check fail unconditionally, a worse outcome than picking one fixed real version. 2.19.11 matches the exact ansible-core release this project's own benchmark rounds compare against (see AGENTS.md/ROLES_TESTED.md). Key ORDER matters, not just the values: roles that render the bare dict into a debug string ("ansible_version => {{ ansible_version }}", buluma.os_updates' SmokeTests task) stringify it in Python dict insertion order, and ansible-core builds it as {string, full, major, minor, revision} - live-verified against ansible-core 2.21.6. Crystal's Hash iterates in insertion order and the Jinja str() path preserves it, so the literal must be written in ansible's own order; a sorted-looking order makes the rendered string diverge from ansible byte for byte.

BIGINT_SENTINEL_PREFIX = "\uE000bigint:"

Prefix marking a string as the decimal text of an integer too wide for Int64 (RSA moduli, EC coordinates, big certificate serial numbers). JSON::Any's raw union tops out at Int64, so such a value cannot exist as a number in the engine's result world: a module that returns a Python int beyond Int64 reaches the controller as bare digits on the JSON wire, the bigint-aware parse (Krikri::BigJson) stores it as this marked string, and every renderer that prints a value to a user (ResultDisplay's dumps, PyDump, the filters, string interpolation) prints it as bare unquoted digits again - the exact text Ansible's Python ints produce. The engine side (krikri-jinja) turns the marked string into a real BigIntValue for templating/tests/comparisons; its canonical constant is KrikriJinja::BIGINT_SENTINEL_PREFIX, duplicated here because the plugin binaries (which also emit wide integers, and mark them with this prefix before BasePlugin's wire rewrite unquotes them) link neither that shard nor the jinja engine. The private-use control character makes a false positive on real string data effectively impossible.

BINARY_WIRE_PREFIX = "\uE000binary:"

Prefix marking a param value whose bytes are NOT valid UTF-8 - binary module-arg data such as a slurp+b64decode'd corosync authkey passed as copy: content: (bloxberg.corosync_pacemaker, issue #835). The plugin wire is JSON, and a Crystal JSON round trip mangles invalid-UTF-8 strings (observed: 32 random authkey bytes became 52 bytes sprinkled with U+FFFD sequences), so real ansible's "content equals what the file already holds -> ok" idempotency check failed here and every run rewrote the file as changed. The executor base64-encodes any param string that fails valid_encoding? under this prefix before the config JSON is built (base64 output is pure ASCII, so every later JSON encode/parse round trip - including the remote transport's own - is lossless), and BasePlugin demotes it back to the exact original bytes when the plugin process parses its config. Like the other prefixes, the leading private-use control character makes a false positive on real user data effectively impossible.

BUILD_FLAVOR = {{ flag?(:release) ? "release" : "debug" }}

Baked in at compile time via the same --release flag build.sh passes through to crystal build. A timing-sensitive round run against a debug binary is ~1.8x slower wall-clock than release on identical work (measured on dev-sec os_hardening) - this lets krikri-role-tester warn when a round launches on one, instead of silently publishing inflated per-role timings.

FAILED_KEY_ORDER_ASSERT = ["failed", "evaluated_to", "assertion", "msg", "changed", "exception"]

assert: its own condition keys sit between failed and msg.

FAILED_KEY_ORDER_DEFAULT = ["failed", "msg", "changed", "exception"]

ansible-core 2.19.11's REGISTERED key order for a FAILED action-level result, live-verified per action with {{ r | to_json }} under ignore_errors: true (Ansible's registered dict keeps the module action's own insertion order - these orders genuinely differ per action, so each builder picks its own). The order travels with the result as the private _ansible_key_order marker key (stripped at register, like every other _ansible_* key) and is applied by TaskExecutor#register_result, which is also where the missing exception: "(traceback unavailable)" key is backfilled.

FAILED_KEY_ORDER_EXCEPTION_FIRST = ["failed", "exception", "msg", "changed"]

unarchive, set_fact and the task-arg finalization failure: exception leads, right after failed.

FAILED_KEY_ORDER_INCLUDE_VARS = ["failed", "message", "ansible_included_var_files", "ansible_facts", "changed", "exception", "msg"]

include_vars: the action's own keys lead, and the wrapped msg comes last (Ansible keeps it after changed/exception).

FAILED_KEY_ORDER_MSG_FIRST = ["failed", "msg", "exception", "changed"]

copy/template (a missing controller-side src) and add_host's own AnsibleActionFail: exception sits between msg and changed.

FAILED_KEY_ORDER_TIMEOUT = ["timedout", "failed", "exception", "msg", "changed"]

the task timeout: deadline failure: TaskTimeoutError's ContributesToTaskResult payload (the timedout dict) leads the merged registered dict, ahead of failed/exception/msg/changed (live-verified against 2.19.11 via {{ r | to_json }} - Atlantic round 3100000's KEYORDER probe).

INTERPRETER_FALLBACK = ["python3.13", "python3.12", "python3.11", "python3.10", "python3.9", "python3.8", "/usr/bin/python3", "python3"]

Ansible's INTERPRETER_PYTHON_FALLBACK default (ansible-core 2.19.11's config/base.yml), in its own order. Ansible's discovery runs command -v <name> over this list on the target and hands the module the FIRST hit, so the sys.executable basic.py reports is that interpreter's - python3.13 before /usr/bin/python3, which on a Debian box resolves to /usr/bin/python3.13 rather than the /usr/bin/python3 symlink a bare python3 probe would find.

JINJA_LITERAL_BAREWORDS = Set {"true", "True", "false", "False", "none", "None"}

Jinja2/Ansible GLOBAL names (functions/constants, not variables) - a root identifier from this set never means "look up this var", the same carve-out shape as SCAN_STRICT_BLOCK_TAG_BUILTIN_FILTERS. Everything here is callable or a constant in plain Jinja2/Ansible, so an expression rooted at one of these names (e.g. lookup('env', ...)) must not be reported as "'<root>' is undefined" even when no variable of that name exists. Ansible's own magic namespaces (hostvars/groups/ vars) are deliberately NOT listed - those ARE real lookups in every vars context a strict finalization runs against, so a genuinely missing one SHOULD raise, like any other undefined root.

Bare Jinja2/Python boolean/null literal spellings, in both capitalizations Jinja2 accepts (the vendored Crinja lexer's own SPECIAL_CONSTANTS maps the camelcase forms too). The strict-undefined probes below match these against REGEX_BARE_VAR_REF as if they were variable NAMES - {{ true }} and, one shape over, {{ true | bool }} both failed the task with "'true' is undefined" where ansible-playbook renders the literal (christiangda.amazon_ cloudwatch_agent's cwa_need_credentials: "{{ true | bool if cwa_agent_mode == 'onPremise' else cwa_use_credentials }}" behind a bare when:, round 952063). A filter chain or a when: can't be strict about a name that is a literal, never a lookup. omit keeps its own separate carve-outs (its "drop this parameter" semantics differ from a plain value literal).

LAZY_TEMPLATE_DICT_TYPE = "ansible._internal._templating._lazy_containers._AnsibleLazyTemplateDict"

ansible-core 2.19 words a dict-subscript miss differently by key: any key with text in it gets the templar's dict-typed rewording ("object of type 'dict' has no attribute 'nope'"), but an EMPTY-string key never reaches that rewording - it falls through to Python's getattr on the lazy templated-dict wrapper, which reports the wrapper's full dotted class path instead ("'ansible._internal._templating._lazy_containers. _AnsibleLazyTemplateDict object' has no attribute ''"; both live-verified vs 2.19.11 on {{ groups[k] }} with k: '' vs k: 'nope').

LAZY_TEMPLATE_LIST_TYPE = "ansible._internal._templating._lazy_containers._AnsibleLazyTemplateList"

Play/role/set_fact list variables are wrapped in ansible's lazy template list, and 2.19.11 reports an out-of-range INDEX on such a bare-identifier var with Jinja's own "no element" wording (its "object of type ... has no attribute" hint is only built for truthy names, so index 0 - the one shape live-verified on the issue #582 repro - falls through to it). Literal lists and other indexes keep the plain list wording.

MANDATORY_FAILED_PREFIX = "The filter plugin 'ansible.builtin.mandatory' failed: "

The head of ansible-core's mandatory filter-plugin failure text, as the finalization chain words it (see #mandatory_message for the full text).

NATIVE_TYPED_PREFIX = "\uE000native:"

Prefix marking a set_fact param value as the JSON encoding of the expression's NATIVELY-TYPED result, not substituted display text. The executor's param wire is strings-only, so a whole-single-span {{ expr }} set_fact value (the only shape ansible-core 2.19 native-typing keeps unstringified) would otherwise arrive at the set_fact plugin as bare text and get re-coerced by string shape - which is pre-2.19 ANSIBLE_JINJA2_NATIVE=off literal_eval behavior, not 2.19's "the expression's own type is the value's type" rule: a Jinja string expression stays a str even when it looks like a number (pluggero.openssh round 981024: "{{ '8.9' }}" became the float 8.9, so openssh_installed_version != openssh_pkg_mgr_version compared float-to-str and was always true, forcing a package reinstall every run). The control character makes a false positive on a literal (non-templated) set_fact value - which still takes the legacy string-shape coercion below - effectively impossible.

NON_STRING_MEMBER_PREFIX = "\uE000nonmember:"

The MEMBER counterpart: the parser's comma-joined list branch marks a non-string scalar list member with THIS prefix instead, so a single-element list whose only member is a non-string (parents: [7]) stays distinguishable from the whole-value scalar literal (parents: 7) that Ansible crashes on DIFFERENTLY ("object is not iterable" vs "object has no attribute 'replace'"). The demotion sites treat both prefixes identically - decode the JSON payload, emit the exact stringify_value text - so no plugin that never asks changes behavior; only the executor-side hooks mirroring Ansible's member-iterating action plugins (group_by/add_host) tell them apart.

NON_STRING_PARAM_PREFIX = "\uE000nonstring:"

Prefix marking a module-arg value as a NON-STRING YAML scalar literal - an int, float or bool the playbook wrote unquoted (copy: dest: 89, fetch: src: true, debug: msg: 1.5-style). Task#params is a strings-only wire (Hash(String, String)), so stringify_value erases the YAML type at parse time - and with it every behavior that hinges on the value's Python type rather than its text: ansible-core 2.19 passes the literal AS ITS NATIVE TYPE into the action plugin, so a non-string dest/src crashes Ansible's copy with "'_AnsibleTaggedInt' object has no attribute 'startswith'" (bools print as plain 'bool' - they are not tagged), fails fetch's action with "Invalid type supplied for dest option, it must be a string", renders through template as Python str(True) = "True" (not YAML's "true"), and makes falsy scalars (false, 0, 0.0) count as "not provided" in copy's truthiness checks (live-verified against 2.19.11 for the full dest/src x int/bool/float matrix). The parser prefixes such a literal with this control character plus the JSON encoding of the parsed YAML value; BasePlugin strips it back to the exact same plain string every plugin saw before (so no plugin that never asks changes behavior) and records the native value for the plugins that mirror Ansible's type-checking - query it with BasePlugin#non_string_param. Like NATIVE_TYPED_PREFIX, the leading private-use control character makes a false positive on real user data effectively impossible, and templated values (any {{/{%/{#) are never marked - their type belongs to the executor's whole-span evaluation, not to the literal.

NON_VAR_ROOT_NAMES = Set {"true", "false", "none", "omit", "lookup", "query", "q", "url", "range", "dict", "list", "tuple", "namespace", "now", "undef", "not", "and", "or", "in", "is", "if"}
NONE_SENTINEL = "__crystal_ansible_none__"

The null/None counterpart to OMIT_SENTINEL (variable_substitutor.cr). The param wire between the executor and a plugin binary is strings-only, so a module call whose param natively resolved to Python None (enablerepo: "{{ item.enablerepo | default('') }}" with a null item field - round 900905 officel.httpd) would collapse to the same "" as a real empty string, which Ansible's argument specs treat completely differently (an explicit None fails every type: list param; an empty string coerces to an empty list just fine - live-verified against ansible-core 2.19.11). The executor marks such params with this sentinel, BasePlugin demotes it back to "" (plus a null bookkeeping entry) so every plugin that never asks about it stays behavior-identical, and only plugins that mirror a real module's argspec consult BasePlugin#explicit_null_param?.

OMIT_SENTINEL = "__crystal_ansible_omit__"

Sentinel a rendered param value is compared against to detect Ansible's omit magic variable ({{ item.proto | default(omit) }} - konstruktoid-hardening's "Allow outgoing specified ports" task uses exactly this to drop proto: for loop items that don't specify one). Ansible's omit causes the parameter itself to be dropped from the module call entirely, not set to some placeholder value - can't be represented as a plain rendered string, so FilterEngine's default resolves a bare omit argument to this unique marker instead, and #substitute_task_params (the one place that assembles a task's final param hash) strips any key whose fully-substituted value equals it.

OWN_SHARDS = ["krikri-jinja", "krikri-xml"] of ::String

Fork annotation per runtime dependency, keyed by shard name. Several of the shards krikri ships are its own patched forks of upstream libraries (identified by the weirdbricks GitHub owner in shard.yml, which carries real behavioral changes, not just version pins); a deployed binary should say which repo it was actually built from. krikri-jinja and krikri-xml are krikri's own libraries, not forks, so they are annotated with their repo and pin but not labeled "fork".

PLAIN_CHAIN_SHAPE = /\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*|\[[^\[\]]+\])*\z/

The one grammar dict_attribute_miss_name can walk: a root identifier followed by .attr / bracket-free [...] steps - the same permit the probe hands to its operand pieces (see strict_undefined_message).

REGEX_BARE_VAR_REF = /\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*|\[(?:-?\d+|'[^']*'|"[^"]*")\])*\z/

Conservative "pure variable reference" shape - letters/digits/ underscore, .field and [0]/['key'] access only. No spaces, pipes, parens, quotes outside of a bracket index, or keywords - those all indicate a real expression, not a plain lookup, and stay on the lenient path.

RUNTIME_DEPENDENCY_FORK_NOTES = begin notes = {} of String => String (parse_shard_yml_dependency_pins(SHARD_YML_TEXT, "dependencies")).each do |name, pin| if (github = pin[:github]) && (github.starts_with?("weirdbricks/")) else next end suffix = "" if tag = pin[:tag] suffix = ", tag #{tag}" elsif branch = pin[:branch] suffix = ", branch #{branch}" elsif commit = pin[:commit] suffix = ", commit #{commit[0, 7]}" end notes[name] = " (#{github}#{OWN_SHARDS.includes?(name) ? "" : " fork"}#{suffix})" end notes end
RUNTIME_DEPENDENCY_VERSIONS = begin dev_names = parse_shard_yml_section_names(SHARD_YML_TEXT, "development_dependencies") (parse_shard_lock_versions(SHARD_LOCK_TEXT)) .reject do |name, _| dev_names.includes?(name) end .map do |name, version| {name, semantic_shard_version(version)} end .sort_by! do |entry| entry[0] end end

Runtime dependency list (dev-only shards like ameba filtered out by category, not by name), sorted for a stable, diffable listing.

SCAN_STRICT_BLOCK_TAG_BUILTIN_FILTERS = Set {"abs", "attr", "batch", "capitalize", "center", "count", "d", "default", "dictsort", "e", "escape", "escapejs", "filesizeformat", "first", "float", "forceescape", "format", "groupby", "indent", "int", "items", "join", "last", "length", "list", "lower", "map", "max", "min", "pprint", "random", "reject", "rejectattr", "replace", "reverse", "round", "safe", "select", "selectattr", "slice", "sort", "string", "striptags", "sum", "title", "tojson", "trim", "truncate", "unique", "upper", "urlencode", "urlize", "wordcount", "wordwrap", "xmlattr", "as_json", "as_yaml", "b64decode", "b64encode", "sha1", "sha256", "md5", "flatten", "combine", "items2dict", "dict2items", "to_datetime", "from_json", "from_yaml", "to_yaml", "to_nice_yaml", "to_nice_json", "from_csv", "regex_search", "regex_findall", "regex_replace", "product", "log", "permutations", "combinations", "extract", "type_debug", "shuffle", "comment", "password_hash", "b32decode", "b32encode", "human_readable", "human_to_bytes", "to_bytes", "subelements", "start_with", "end_with", "match", "search", "bool", "checksum", "shorthash", "hash", "mandatory", "match_regex", "search_regex", "ternary"}

Jinja2/Ansible built-in filter names - a filter invocation never means "look up this identifier as a var". The same allowlist shape as UNDEFINED_TOLERANT_FILTERS but more complete (the tolerant set is only the subset of filters that pass undefined through; this one is the set of filter NAMES the strict block-tag scan must not treat as a var). Includes the ansible.builtin.X collection-prefixed forms used in real playbooks (ansible.builtin.default, etc.) - stripped of the prefix by the bare-ref regex's own \. step, but only if the form is a filter invocation; bare ansible.builtin.foo is still a var lookup, so the whole allowlist is needed either way.

SCAN_STRICT_BLOCK_TAG_KEYWORDS = Set {"if", "elif", "else", "endif", "for", "endfor", "set", "endset", "include", "extends", "block", "endblock", "macro", "endmacro", "filter", "endfilter", "call", "endcall", "in", "is", "not", "and", "or", "true", "false", "none", "True", "False", "None", "recursive", "loop", "self", "super", "caller", "args", "kwargs", "varargs", "import", "from", "as", "with", "without", "scoped", "endscoped", "autoescape", "endautoescape", "raw", "endraw", "do", "enddo", "case", "when", "endcase", "default", "applymacro", "endapplymacro", "defined", "undefined", "divisibleby", "even", "odd", "mapping", "sequence", "number", "string", "boolean", "integer", "float", "iterable", "callable", "sameas", "lower", "upper", "eq", "ne", "lt", "le", "gt", "ge", "failed", "changed", "succeeded", "success", "skipped", "reachable", "omit"}

Jinja keywords and tests - identifiers the {% %} block-tag strict scan must NEVER flag, even when they appear in a {% if %} condition. Includes the basic block keywords (if/else/endif/etc.), the boolean operators (and/or/ not/in/is), the constant literals (true/false/none), the Jinja2 test names that follow is (is defined, is mapping, is failed, is iterable, etc.), and Ansible's task-result tests (is changed/is failed/is success/etc.). Tuned against the round-194 andrewrothstein.openjdk openjdk_app==<literal> shape; is defined/is failed are the common two that need not flag. omit is Ansible's own magic bareword sentinel ("drop this parameter entirely"), never a variable anyone sets - a strict scan flagging it (Stouts.openvpn's own {{ ansible_lsb.codename | default(omit) }} candidate, where the scan of the task's raw vars: params recursed into default(omit)'s ARGUMENT and raised "'omit' is undefined" there) fails a task Ansible runs.

SCAN_STRICT_BLOCK_TAG_REF = /\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*|\[(?:-?\d+|'[^']*'|"[^"]*")\])*)\b/

Same identifier-class regex as REGEX_BARE_VAR_REF but anchored for substring matches inside {% %} block-tag conditions. The full anchor isn't right for the block-tag use - we need to find every bare reference in the condition, not just ones that span the whole string.

TAGGED_FLOAT_TYPE = "ansible.module_utils._internal._datatag._AnsibleTaggedFloat"
TAGGED_INT_TYPE = "ansible.module_utils._internal._datatag._AnsibleTaggedInt"
TAGGED_STR_TYPE = "ansible.module_utils._internal._datatag._AnsibleTaggedStr"

ansible-core 2.19 tags every scalar flowing through templating with a datatag wrapper class, and a FAILED bracket subscript on one names that wrapper's full dotted class path in its falsy-key/empty-key wordings - not the plain type name the truthy-key attribute wording uses (live-verified vs 2.19.11 on "C"[none], "C"[''], 5[0], 1.5[0]). bool and None are never tagged, so they keep their plain Python names.

UNDEFINED_LEAF_MARKER_CLOSE = "\uE001"
UNDEFINED_LEAF_MARKER_OPEN = "\uE000undefleaf:"

Marker a chain-undefined templated leaf rides inside the template action plugin's variable scope ("'bind_addr' is undefined" wrapped in private-use control characters, see JinjaRenderer#rerender_string_value). Both characters are the same private-use code points the param-wire sentinels (param_sentinels.cr) already rely on to be unrepresentable in real playbook content, so the scope conversion in TemplateActionPlugin decodes the marker without ever mistaking authored text for one.

UNDEFINED_MARKER_SENTINEL = "__crystal_ansible_undefined_marker__"

Sentinel a strict +/- operand resolves to when Ansible 2.19 would have produced an UndefinedMarker rather than a value: a method call on a None-valued variable (nil_var.rstrip(';') - the NoneType itself coming from a {% for %} over an empty list, inofix.yapkg issue #531) never runs, it short-circuits to undefined, so the enclosing operator fails with can only concatenate list (not "UndefinedMarker") to list instead of naming NoneType. Same role as OMIT_SENTINEL: a unique marker the combine step must fail on rather than silently string-concatenate.

UNDEFINED_TOLERANT_FILTERS = Set {"default", "d", "type_debug"}

The only filters Ansible lets a genuinely UNDEFINED value reach without failing the task. Everything else in Jinja2/Ansible raises on AnsibleUndefined - differentialed against the local ansible-core 2.19.4 with msg: "{{ nope | <filter> }}" over 24 filters (dict2items, items2dict, list, first, join, length, string, bool, int, ternary, flatten, map, select, unique, sort, lower, trim, to_json, combine, count, min, mandatory all FAIL; only these three succeed), so a tolerant ALLOWLIST is the accurate model here, not a denylist of the handful of filters a benchmark round happened to hit.

VALID_STRATEGIES = ["linear", "free", "host_pinned"]

Represents a play (collection of tasks for specific hosts) The strategies this engine implements. host_pinned is accepted and behaves as free: its only difference is worker affinity, which this engine has no equivalent of. debug is Ansible's interactive debugger strategy and is deliberately NOT accepted here - see KNOWN_MISSING.md's note on debugger:.

VERSION = "0.9.2522"

Class methods

bigint_cmp(a : String, b : String) : Int32

Ordering comparison of two signed decimal integer strings of arbitrary width (the payloads of BIGINT_SENTINEL_PREFIX-marked values), for the comparison paths that must treat a marked value as a real number. Sign first, then magnitude by length (a longer magnitude is the larger number), then lexicographic on equal lengths - exact, no Float64 rounding anywhere.

Source
bigint_digits(text : String | Nil) : String | Nil

The decimal digits a BIGINT_SENTINEL_PREFIX-marked string carries (sign included), or nil when the text is not a marked value. A prefix occurrence with a non-integer payload (only possible via hostile string data, never via the parse boundary) is not a bigint.

Source
bigint_digits(value : JSON::Any) : String | Nil

The bigint-sentinel check over a JSON::Any node: the decimal digits when the node is a marked string, nil otherwise.

Source
bigint_sentinel(digits : String) : String

Builds the marked string for a decimal integer too wide for Int64.

Source
bracket_index_failure_message(expr : String, vars : Hash(String, JSON::Any)) : String | Nil

Round 812045 (pluggero.bibata_cursor): a bracket index applied to a FILTER-CHAIN or parenthesized base ((x.stdout | regex_search('...', '\\1', multiline=True))[0]) is invisible to dict_attribute_miss_name above (its shape regex only accepts plain bare-var chains), so a base that resolves to Python None (regex_search with no match at all) or an index past the end of a real-but-too-short list silently rendered the "undefined" sentinel and the play ran green where Ansible hard-fails the task. Live-verified against ansible-core 2.19.11 - both at task-arg finalization and in when: evaluation, which are DISTINCT Python error shapes, deliberately not collapsed into one message: None/JSON-null base -> "None has no element 0" list index past end -> "object of type 'list' has no attribute 5" Walks the TRAILING top-level integer-index brackets off expr (so a | default(...) guard after the index, which Ansible answers leniently, never reaches the check), structurally evaluates the remaining base via Crinja, and returns Ansible's own message for the first failing index. nil for every shape this can't pin down (undefined base - the generic "'x' is undefined" probe already owns that; non-integer index; dict-key miss, lenient by long-standing convention here; or a base containing a side-effecting lookup(...)/ query(...) call that must not run a second time).

Source
builtin_module_names
Source
builtin_runtime_redirects
Source
chain_failure_message(msg : String, vars : Hash(String, JSON::Any)) : String | Nil

The innermost strict-failure message of the templated value owned by the name a strict probe blamed, or nil when the name's value renders fine (the outer message was a genuine miss) or the name is not a plain {{ }} template at all.

Source
chain_first_filter?(expr : String, name : String) : Bool

Returns the offending variable name when expr is a filter chain whose SOURCE is a genuinely undefined bare variable reference and whose FIRST filter is not one of UNDEFINED_TOLERANT_FILTERS - i.e. exactly the shape Ansible hard-fails - and nil otherwise.

Why this exists (round185, buluma.environment's loop: "{{ environment_list | dict2items }}", with no default anywhere in the role): the strict-undefined machinery only ever looked at BARE {{ var }} references, so the moment an undefined value flowed through any filter it stopped being strict - and FilterEngine's own as_hash/as_array helpers independently coerced the missing value to {}/[] before anything upstream could notice. The task then produced zero loop items and silently no-op'd where Ansible fails ("dict2items requires a dictionary, got ...AnsibleUndefined").

Only the FIRST filter is consulted, which is what Ansible does too: x | default([]) | dict2items is fine (default consumes the undefined - the legitimate, extremely common idiom), while x | dict2items | default([]) still fails, because dict2items has already raised by the time default is reached.

Deliberately narrow in the same spirit as REGEX_BARE_VAR_REF: the source has to be a plain variable reference that is genuinely absent from vars (a straight lookup, no evaluation), so none of this evaluator's documented expression-syntax gaps can turn into a spurious task failure here. Whether expr (a filter chain) pipes its source through the named filter FIRST - shared shape logic with #undefined_tolerant_first_filter?.

Source
decode_binary_wire_value(value : String) : String

Plugin-side decode: restores the exact original bytes behind an encoded value; anything not carrying the prefix is returned as-is.

Source
dict_attribute_miss_message(missing_key : String, miss_type : String) : String
Source
dict_attribute_miss_name(expr : String, vars : Hash(String, JSON::Any)) : String | Nil
Source
discovered_target_python

The target's Python interpreter, the way Ansible's interpreter discovery picks it: the first INTERPRETER_FALLBACK entry command -v resolves on the target. nil when the target has none.

This runs on the TARGET (each plugin binary is uploaded there), so both the probe and the hostname it reports are the target's own - the same two values Ansible's import failure reports.

Source
encode_binary_wire_value(value : String) : String

Executor-side encode: passes valid-UTF-8 text through untouched (the common case - no behavior change), base64-wraps everything else.

Source
expression_resolves_to_undefined?(expr : String, vars : Hash(String, JSON::Any)) : Bool

A chained-subscript/dot expression that ultimately resolves to nothing (the inner-most lookup misses, the entire expression renders to the literal text "undefined") - pkg_upgrade_update_ cmds[ansible_distribution_major_version]["update"] on Rocky 9.6, where ansible_distribution_major_version is "9" but the role's vars/RedHat.yml only has keys "7" and "8", is the canonical case found in round-194's andrewrothstein.pkg-upgrade. Used by raise_if_strict_undefined's chained-subscript branch (which has to decide whether the inner expression ultimately renders to "undefined" without itself recursing into substitute_impl). The detection is ExpressionEvaluator's undefined-typed #evaluate_or_undefined, not a string comparison against its own rendered output: the older rendered == "undefined" check could not tell a genuine miss from a REAL value that happens to be the text "undefined" (printf 'undefined' + register: s2, then {{ s2.stdout_lines.0 }} - juju4.pocketid round 60151 - failed the task where Ansible renders the string; the bracket form, decided structurally, was never affected). Doesn't apply to the bare-ref or filter-chain shapes the OTHER raise_if_strict_undefined branches already cover.

Source
jinja_attribute_miss_wording(engine_message : String) : String

The engine's own attribute-miss wording ("'dict object' has no attribute 'k'") rewritten into ansible-core's, including the empty-string-key exception dict_attribute_miss_message covers.

Source
lenient_boolean_true?(value : String | Nil) : Bool

Ansible's boolean(value, strict=False) (the Ansible module convert_bool.py) answering TRUE - the exact predicate the assemble action plugin applies to remote_src to pick between its module branch (truthy: the assemble module itself runs and re-converts remote_src with its own strict type: bool, which can then only ever succeed) and its controller-side branch (everything else: falsy spellings, invalid spellings like 'timjjr', explicit None, native 2/0.5 - boolean() returns False for all of them under strict=False, so the action assembles locally and delegates the placement to the copy module, whose spec - with remote_src STRIPPED - is what validates the rest). Only BOOLEANS_TRUE counts: the spellings y/yes/on/1/true/t (lowercased, whitespace-stripped) and the native true/1/1.0.

Source
mandatory_message(chain : String, undefined_name : String) : String

mandatory's failure text: Ansible's filter plugin uses its msg argument when given ("custom message here"), else "Mandatory variable 'X' not defined." quoting the UNDEFINED variable's name (live-verified).

Source
mark_failed_key_order(result : JSON::Any, order : Array(String)) : JSON::Any

Tags result with the real key order its registered FAILED form must carry; see FAILED_KEY_ORDER_DEFAULT.

Source
miss_type
Source
missing_python_library(library : String, import_stmt : String) : NamedTuple(msg: String, detail: String) | Nil

nil when <import_stmt> imports cleanly under the target's Python - the module carries on, exactly as Ansible's does. Otherwise Ansible's exact failure text.

detail is the ImportError's own text for the modules that pass their caught exception along (module.fail_json(msg= missing_required_lib("pexpect"), exception=PEXPECT_IMP_ERR)), which real shows in the [ERROR] block only - never in the result's msg - so it is returned SEPARATELY, for the caller to pass as the [ERROR]-only detail rather than appended to the message.

Source
missing_required_lib_message(library : String, python : String, reason : String | Nil = nil) : String

Ansible's missing_required_lib(<library>) message, verbatim, for a target that cannot import <library>.

The shape of the parity problem this covers: several Ansible modules import a Python library at MODULE level, so a target without it fails the task with this message before the module validates a single one of its own arguments. krikri implements those modules natively (its own pty layer for expect, its own XML parser for xml) and therefore does NOT need the library - which is why it used to carry on past the point where Ansible stops, reporting its own downstream failure (or, worse, success) instead. The dependency still decides the outcome, so it is reproduced here: same message, same ordering, same point in the flow. Same precedent as the python3-debian gate in deb822_repository, the gssapi gate in nsupdate/url_preflight and the boto3 gate in the aws modules.

Callers whose library has a reason line (gssapi, ...) pass it as reason; it sits between the first sentence and the generic advice.

Source
non_string_list_members(value : String | Nil) : Array(JSON::Any) | Nil

The members of a parser comma-joined list param value, in wire order: a member the parser marked with NON_STRING_MEMBER_PREFIX (see that prefix's comment) as its decoded native scalar, every other member as its own plain text. nil when the value carries no member marker at all - a plain string, or a whole-value scalar literal (which rides NON_STRING_PARAM_PREFIX instead and stays #non_string_scalar's business). The plugin-side counterpart of the demotion every param-parse site performs: a plugin mirroring a real module's per-MEMBER type checking can still see the native types here, since the demoted @params text has lost the markers.

Source
non_string_member_scalar(value : String | Nil) : JSON::Any | Nil

Same decode for a NON_STRING_MEMBER_PREFIX-prefixed list member (or a single-member list whose whole wire value carries the member prefix).

Source
non_string_param_text(native : JSON::Any) : String

The plain string form a marked literal demotes to on the plugin wire. Bools render as Python str() ("True"/"False") - what Ansible's argspec finalization puts on the wire for a str-typed option carrying a native bool literal (2.21 live-verified: cmd: false executes the literally- named "False", rc 2 ENOENT) - matching #python_str_scalar, whose template's-dest note records the same behavior live-verified against 2.19.11. Int/float spellings are identical either way. A marked nil (YAML key: with no value) demotes to "" like stringify_value's own Nil case - the explicit-null argspec flow handles real Nones.

Source
non_string_scalar(value : String | Nil) : JSON::Any | Nil

Decodes a NON_STRING_PARAM_PREFIX-prefixed param value back into the native YAML scalar (JSON::Any wrapping Int64/Float64/Bool), or nil when the value is not a marked literal. Shared by BasePlugin's param parse (plugin binaries) and the executor/ArgspecValidator side (main executable) - the one file both can require.

Source
parse_json_or_python_literal(rendered : String) : JSON::Any

Parses rendered (text an evaluator's own .evaluate/.evaluate_output already rendered, from a whole-value {{ }} template being re-rendered to recover its real type - see every rerender_if_ templated-shaped helper across this codebase) back into a real JSON::Any, tolerating Python's OWN literal spellings that plain JSON.parse rejects outright (True/False/None - capitalized, not JSON's lowercase true/false/null; a Python dict/list repr with single-quoted strings, {'a': 1} not {"a": 1}). Every one of those independent copies previously just did (JSON.parse(rendered) rescue nil) || JSON::Any.new(rendered) - for a real Python bool/ None this ALWAYS falls to the rescue branch (JSON.parse("False") raises, "Unexpected char 'F'"), wrapping the STRING "False" instead of recovering the real boolean. Found via sscheib.openwrt_ bootstrap's own vars/main.yml: _bts_install_full_python: "{{ bts_install_full_python | default(_def_bts_install_full_python) }}" (a real Python bool default, false) rendered to the STRING "False" here instead of a real bool, so the role's own _bts_install_full_ python is boolean assert always failed regardless of the real (correct) underlying value.

Source
parse_shard_lock_versions(content : String) : Hash(String, String)

Parses a shard.lock's shards: section into {name => version}. Line-based rather than YAML-lib based so it stays a pure, fixture- testable function; the lock file's own emitted shape (2-space shard names, 4-space keys) is stable across shards versions.

Source
parse_shard_yml_dependency_pins(content : String, section : String) : Hash(String, ShardYmlPin)
Source
parse_shard_yml_section_names(content : String, section : String) : Array(String)

Extracts the shard names listed under a top-level section header (e.g. "dependencies:" / "development_dependencies:") of a shard.yml.

Source
probe_has_side_effecting_call?(expr : String) : Bool

Whether expr must NOT be probed because a second evaluation would run side-effecting lookups twice (the same convention bracket_index_failure_message applies to its own probe).

Source
python_falsy?(value : JSON::Any) : Bool

Python truthiness of a JSON value (None/False/0/0.0/""/[]/{} are falsy) - ansible's display callbacks gate the => (item=...) suffix and non-string miss wording on the label/key OBJECT, not its text.

Source
python_join_type_name(native : JSON::Any) : String

The Python type name CPython's str.join puts in its own "sequence item N: expected str instance, X found" TypeError - the message Ansible's debconf module dies with when a non-string value: literal reaches its ' '.join([pkg, question, vtype, value]) (debconf.py:179, live-verified vs 2.19.11). These values are PLAIN Python objects by then - the same value: literal that would be reported as an _AnsibleTaggedInt in an "'X' object has no attribute" crash names plain int here - so this is the builtin name, unlike #python_value_type_name.

Source
python_param_truthy?(value : String | Nil) : Bool

Python truthiness of a task-param value, native-type aware: a marked non-string literal carries its own truthiness (false/0/0.0 are falsy exactly like in Python), a nil/absent param is falsy, and a plain string is falsy only when empty - matching Python's bool("") without changing what any existing plugin saw (they got the same "" before).

Source
python_scalar_type_name(native : JSON::Any) : String

The Python type name ansible-core 2.19 reports for a non-string scalar literal in an "'X' object has no attribute ..." crash: YAML ints/floats arrive natively tagged (_AnsibleTaggedInt/_AnsibleTaggedFloat), bools are plain Python bools (live-verified vs 2.19.11).

Source
python_str_scalar(native : JSON::Any) : String

Python str() of a marked non-string scalar - the coercion Ansible's action plugins effectively put dest/src through when they use a non-string literal as text (template's dest: true writes a file named "True", not "true" - live-verified vs 2.19.11; int/float spellings are identical to the demoted text).

Source
python_string_literal_operand?(operand : String) : Bool

Whether operand is a quoted STRING LITERAL written directly in the expression ('10', "10") rather than a variable reference. Jinja builds a plain Python str for a literal, so ansible's TypeError names it 'str'; only a value READ from a yaml document carries the loader's tag and reports '_AnsibleTaggedStr' (live-verified vs 2.21.6: when: "'10' >= 1" says 'str' while v: "10" from vars says '_AnsibleTaggedStr').

Source
python_value_class_path(native : JSON::Any) : String

The FULL Python class path Ansible reports for a non-string value in its AddHost result field validation ("AddHost.host_name must be <class 'str'> instead of <class '<type>'>"): datatag-wrapped YAML scalars AND containers are fully qualified, plain Python bool/NoneType are not (live-verified vs 2.21.6 for int, float, bool, dict and list).

Source
python_value_type_name(native : JSON::Any) : String

The Python type name ansible-core 2.19 reports for ANY non-string value in an "'X' object has no attribute ..." crash - scalars (see #python_scalar_type_name) plus the container/None shapes a YAML list member or name: value can carry (live-verified vs 2.19.11: dict and list members report _AnsibleTaggedDict/_AnsibleTaggedList, a nil one plain 'NoneType').

Source
python_yaml_time_text(time : Time) : String

PyYAML resolves an unquoted date-like YAML scalar the way YAML 1.1's core schema says (X-GitHub-Api-Version: 2026-03-10 arrives as a real Python datetime.date, round 5460281), and every place Python then turns one into text - a templated render, an HTTP header value, a JSON dump - produces str(date)/str(datetime): "2026-03-10" for a date-only scalar, "%Y-%m-%d %H:%M:%S" (microseconds only when nonzero, "+HH:MM" only when tz-aware) for a full timestamp. Crystal's YAML parser resolves the same scalars to a Time, whose own to_json/to_s render ISO-Z forms ("2026-03-10T00:00:00Z") Python never produces - the exact divergence that made krikri's GitHub API request fail HTTP 400 where real ansible-playbook succeeded.

A date-only scalar and a naive midnight datetime both arrive here as a zero-offset UTC-midnight Time and are indistinguishable after the parse, so the date-only form is chosen for that shape (the overwhelmingly common YAML usage; a literal 2026-03-10 00:00:00 is vanishingly rare and only its seconds text diverges).

Source
raise_if_chain_source_value_undefined(expr : String, vars : Hash(String, JSON::Any)) : Nil

The nested-undefined companion to undefined_filter_chain_source: expr is a filter chain whose head IS a defined bare variable, but whose stored VALUE is itself unrendered Jinja that bottoms out at a name set nowhere (php_fpm_site_errorlog: "/home/{{ system_user }}/ logs/x.log" with system_user never defined - inmotionhosting.php_fpm, round 82024). Ansible's recursive re-templating renders the head's own value strictly BEFORE the first filter ever applies, so a when: using the chain fails with the innermost missing name; this engine's lenient re-render baked the "undefined" sentinel into the string and the conditional silently answered falsy instead. The same tolerant-first- filter rule as undefined_filter_chain_source applies, and it applies to the nested case too - live-verified against ansible-core 2.19.4: site_errorlog | default('x') | length > 0 runs, site_errorlog | length > 0 fails. Raises (rather than returning a name to raise on) because the correct message names the innermost undefined reference, which only the strict render itself knows.

Source
restore_native_param_value(value : JSON::Any) : JSON::Any

One param value's restoration: a whole-value marked literal goes back to the JSON value it encoded (an int/float/bool keeps its type, which is the whole point - a JSON number no longer looks like a string to as_i64?/as_bool?), and a comma-joined list's marked members go back to their own plain text. Anything unmarked is returned untouched.

Source
restore_native_param_values(params : JSON::Any | Nil) : JSON::Any | Nil

The plugin-side view of BasePlugin#initialize's param parse, for the callers that read the RAW config JSON rather than the demoted @params hash - the FactsGatherer behind setup:/facts: does exactly that, and used to see a gather_timeout: 75 written unquoted in the playbook as the literal STRING "nonstring:75" (the parser's marker rides the strings-only wire), so a type-specific read failed the module with "argument 'gather_timeout' is of type str and we were unable to convert to int" where Ansible - which converts the native int - happily gathers. Restores each marked literal to the JSON value it stood for, exactly as the demotion sites above do, so a reader sees the same numbers/lists/strings the playbook actually wrote.

Source
result_failed_flag(result : JSON::Any) : Bool

A module result's "failed" flag read the way Ansible's Python truthiness reads it: the wire protocol normally carries a JSON bool, but ansible-core's TaskExecutor puts INTEGER 0 in the async fire-and-forget launch result ("failed: 0" - confirmed via the podman-diff async_status cases), and a hard as_bool cast crashes the executor on it. 0 is falsy, 1 truthy, matching Python.

Source
run_captured_helper(python : String, args : Array(String), stdin : String) : NamedTuple(status: Process::Status, stdout: String, stderr: String) | Nil

Runs a short-lived controller-side helper process, feeding stdin and capturing stdout/stderr, with the same spawn classification as LocalExecutor's #704 rule: only a failure BEFORE the process exists raises Krikri::SpawnError (the simulated-failure hook included); a File::Error - the executable vanished or is bad - returns nil; and anything raised AFTER the process ran keeps the caller's pre-#705 verdict, nil (a wrapper that ran is not a spawn failure). stdin is written in a fiber concurrently with draining stdout/stderr, so a chatty child cannot deadlock a payload larger than the pipe buffer against a full pipe.

Source
semantic_shard_version(version : String) : String

"0.9.0+git.commit.<sha>" -> "0.9.0" - the semantic version a user comparing "what version of X am I running" actually wants, matching how pip reports jinja2/pyyaml in Ansible --version.

Source
simulated_python_spawn_failure

Test-only spawn-failure hook for the controller-side Python runners (issue #705): KRIKRI_TEST_FORCE_PYTHON_SPAWN_FAILURE names a counter file; while that file exists with a positive integer, each python3 spawn attempt consumes one count and fails with a simulated environment-level error - the same failure class LocalExecutor's KRIKRI_TEST_FORCE_SPAWN_FAILURE hook raises - letting a spec force exactly N transient spawn failures before the real interpreter runs. Read from process ENV like LocalExecutor's hook: the runners spawn in-process with no env: parameter to carry it, so specs using it declare exclusive!.

Source
stat_atime_f(stat : LibC::Stat) : Float64

Float-seconds variants matching Python's own os.stat_result st_atime/st_mtime/st_ctime, which are tv_sec + tv_nsec / 1e9 computed as float64 (CPython combines the two halves the same way) - Ansible's stat and find results carry that float straight through (e.g. "atime": 1789308974.764945), so truncating to whole seconds broke sub-second timestamp comparisons against real-Ansible output. The *_sec Int64 variants above stay for Time.unix() call sites (file module touch-time change detection) that genuinely want whole seconds.

Source
stat_atime_sec(stat : LibC::Stat) : Int64

LibC::Stat's timestamp fields are named differently per libc: glibc (Linux) uses st_atim/st_mtim/st_ctim, while Darwin's BSD-derived libc uses st_atimespec/st_mtimespec/st_ctimespec for the same Timespec struct. Only matters for compiling a macOS controller binary - the plugin binaries this stats normally run on the (Linux) target host, but ansible_connection=local and the controller's own bookkeeping exercise this on whatever host krikri-playbook itself runs on.

Source
stat_ctime_f(stat : LibC::Stat) : Float64
Source
stat_ctime_sec(stat : LibC::Stat) : Int64
Source
stat_mtime_f(stat : LibC::Stat) : Float64
Source
stat_mtime_sec(stat : LibC::Stat) : Int64
Source
strict_probe_candidate?(expr : String, vars : Hash(String, JSON::Any)) : Bool

Whether expr references any variable whose stored value is itself template text - a cheap syntactic pre-filter for the strict probe's second trigger. The engine's miss signal does not fire when a demoted Undefined flows through ~/arithmetic (the resolver hands the engine its own Undefined without the context's miss counter), so a chained failing value (badvar: "{{ undefined_deep }}" consumed by "{{ 'a' ~ badvar ~ 'b' }}") must be probed even on a clean signal: the probe's own strict evaluation is what raises, and a tolerated shape (badvar | default('x'), live-verified vs 2.19.11) probes clean and costs one extra evaluation.

Source
strict_undefined_message(expr : String, vars : Hash(String, JSON::Any)) : String

The full strict-undefined error message for a failed lookup: a dict-subscript miss on a resolvable chain gets Ansible's attribute-error wording, everything else the classic "'x' is undefined".

Source
strict_undefined_probe_message(expr : String, vars : Hash(String, JSON::Any)) : String | Nil

The strict-undefined PROBE for missing-attribute access on NATIVE dict/list/scalar values (ansible-core 2.19 StrictUndefined semantics, live-verified against 2.19.11): re-evaluates expr ONCE on the krikri-jinja engine with the strict-undefined environment and returns Ansible's error message when a defined-but-missing attribute/key read was actually CONSUMED (comparison operand, interpolation, filter input/argument, arithmetic operand, boolean coercion, ternary condition, any is <test> other than defined/undefined) - nil when the expression only tolerated the miss the way real Ansible does (is defined/is undefined, | default(...), .get(...), a short-circuited and/or arm, default(omit) flowing into a task arg).

The caller gates this on a cheap miss SIGNAL (KrikriJinja.miss_count delta across the lenient evaluation - a signal means some lookup produced an undefined value somewhere in the span), so spans that resolve cleanly never pay the second evaluation, and the lenient pipeline's output is untouched whenever the probe declines to raise. A signal is only a hint: the probe's own strict evaluation is the sole authority, with real Jinja's short-circuit and tolerate shapes, so a false-positive signal costs one wasted evaluation and nothing else.

Deliberately declines to raise for shapes whose strict handling is owned elsewhere with (possibly different) real-Ansible wording: an out-of-range list index ("has no element N" / "... no attribute 5" - bracket_index_failure_message's machinery) - those keep the existing behavior rather than risk a wording regression. Also returns nil for every non-undefined engine failure (unknown construct, syntax gap): the probe must never turn an evaluator capability gap into a task failure - the lenient pipeline stays the fallback for those.

Source
strip_non_string_markers_in_value(value : String) : String

Demotes every non-string marker in ONE param value: a whole-value marked literal (either prefix at the very start, its JSON payload consuming the entire rest) back to its plain scalar text, and - since the parser's comma-joined list branch marks non-string MEMBERS in place - marked members inside a comma-joined list back to their own plain text. The comma-join invariant (a scalar JSON payload never contains a comma) keeps the split exact, and a marker occurrence whose payload does not parse as a complete JSON document can only sit in a member position ("\u{E000}nonmember:7,g1"), so the member path takes over.

Source
strip_non_string_param_markers(params : Hash(String, String)) : Hash(String, String)

Executor-side demotion: every param value the parser marked as a non-string YAML literal is stripped back to its plain string form, so the spec checks (and every other executor-side consumer) see exactly the text they always did. Returns the same hash object when nothing is marked (the common case - no copying).

Source
target_python_executable

The interpreter's own sys.executable, which is what Ansible's missing_required_lib names (never the bare command name). Ansible's module runs UNDER that interpreter, so the two are the same path; asking the interpreter is just how Ansible's own message gets built.

Source
undefined_access_chain_source(expr : String, vars : Hash(String, JSON::Any)) : String | Nil

The ATTRIBUTE/SUBSCRIPT/CALL companion to undefined_filter_chain_source: returns the ROOT variable name when expr starts with a plain identifier that is genuinely absent from vars and immediately accesses something off it - root.split(':'), root.attr, root['key'], root(...), optionally piped onward (root.split(':') | map(...) | list). Ansible's strict finalization raises the moment the undefined root is ACCESSED (Jinja2's StrictUndefined raises on attribute/subscript/call), BEFORE any later tolerant filter in the chain could see it - so unlike the bare-ref chain case, no first-filter tolerance carve-out applies here: x.split(':') | default([]) fails in Ansible too.

Why this exists (round 0.9.879, wcm_io_devops.conga_host_facts' very first task): _host_pattern_variants: "{{ conga_host_facts_pattern .split(':') | map('regex_replace', ...) | list }}" with the variable never defined. undefined_filter_chain_source rejected the source (conga_host_facts_pattern.split(':')) because it contains parens, and the chained-subscript branch rejects ( and |, so the expression silently rendered to [] and the task succeeded where Ansible fatally fails ("'conga_host_facts_pattern' is undefined").

Deliberately narrow, same spirit as the other strict probes: the root must be a plain identifier directly followed by ./[/( (so binary-operator and is defined/if shapes never match), it must be absent from vars by a straight lookup (no evaluation), and known Jinja globals are excluded - none of this evaluator's documented expression-syntax gaps can turn into a spurious task failure here.

Source
undefined_filter_chain_source(expr : String, vars : Hash(String, JSON::Any)) : String | Nil
Source
undefined_leaf_message?(text : String) : String | Nil

Unwraps a marked leaf (its real-Ansible message), nil for any text not marked. Shared with TemplateActionPlugin's engine-scope conversion.

Source
undefined_tolerant_first_filter?(expr : String) : Bool

Whether expr (a full {{ }} span's content, possibly a filter chain) pipes its source through an undefined-TOLERANT filter first (x | default(y), x | d(y), x | type_debug) - the shape Ansible's own strict templating never fails, because the tolerant filter consumes the undefined before anything can choke on it. Shared by the strict-undefined checks that probe a chain's ROOT rather than the whole chain (raise_if_strict_undefined's undefined-access branch), so both agree on the tolerant-first-filter rule undefined_filter_chain_source already implements.

Source
unquote_bigint_json(text : String) : String

Rewrites every BIGINT_SENTINEL_PREFIX-marked string in serialized JSON text to its bare digits: the wire form a module's Python int takes. Used by BasePlugin#run_and_capture (plugin result -> wire) and by the serializers that have no per-node hook (Crystal's own JSON::Any#to_pretty_json, whose quoted-sentinel bytes this post- processes back to unquoted digits).

Source
version_info(name : String, version : String, tagline : String) : String

Shared --version shape for every krikri binary (playbook, ad-hoc CLI, lint): tool name/version, tagline, then the exact Crystal and shard versions compiled in.

Source
version_info
Source

Nested types