Skip to main content

helm_schema_ir/fragment_eval/
eval.rs

1//! The fragment interpreter: one abstract evaluation of a templated-YAML
2//! document over the `helm-schema-syntax` CST, producing a
3//! [`Guarded<AbstractFragment>`] plus the pathless value reads that never
4//! render (condition reads, assignment right-hand sides, helper-internal
5//! guard reads).
6//!
7//! Control regions become guarded arms: each branch's contributions are
8//! evaluated under the branch's decoded [`PathCondition`] and dissolve into
9//! the surrounding container, so guard structure lives in the tree instead
10//! of ambient per-row stacks. The interpreter still keeps a small predicate
11//! stack, but only to stamp root-to-leaf guards onto the pathless reads that
12//! have no tree position.
13//!
14//! Reused machinery (nothing here re-derives what the pipeline already
15//! knows how to compute):
16//!
17//! - condition decoding: [`ValuePathContext`] predicate decoding over
18//!   `TemplateHeader`s,
19//! - expression evaluation: the `AbstractValue` lattice with bound-helper
20//!   resolution (`document_result_from_expr`), where helper calls resolve
21//!   through their in-domain fragment summaries (`super::summary`),
22//! - range headers: `range_header_from_source` /
23//!   `range_has_destructured_variable_definition` on the shared Go-template
24//!   parse (body shape comes from the CST),
25//! - local state: [`SymbolicLocalState`] with shared branch-join rules.
26//!
27//! The same interpreter evaluates documents and helper bodies; a helper
28//! scope additionally carries the call's root bindings, the resolved dot
29//! pair, and the active call chain, and applies the summary lane's
30//! flattening rules where the caller consumes summary facts (truthy range
31//! markers, dependency-lane demotions, sibling-condition scoping).
32//!
33//! Known boundaries: document-scope ranges run one symbolic iteration
34//! (helper scopes iterate statically known lists exactly); destructured
35//! range variables stay unbound at document scope; static file templates
36//! evaluate as nested fragments at output holes; ill-nested regions
37//! re-adopt escaped siblings by span in both directions, so branch-window
38//! content of a container opened before (or closed after) the region
39//! inherits the branch guard.
40
41use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
42use std::rc::Rc;
43
44use helm_schema_ast::{
45    ResourceSpan, TemplateExpr, TemplateHeader, parse_expr_text,
46    range_has_destructured_variable_definition, range_header_from_source,
47};
48use helm_schema_syntax as syntax;
49use helm_schema_syntax::{
50    Node, OpaqueKind, ScalarPart, ScalarParts, Span, TemplatedDocument, parse_go_template,
51};
52
53use crate::abstract_value::AbstractValue;
54use crate::analysis_db::IrAnalysisDb;
55use crate::eval_effect::{CaptureKind, FailCapture};
56use crate::fragment_expr_eval::FragmentEvalContext;
57use crate::helper_meta::{HelperOutputMeta, merge_provenance_sites};
58use crate::node_eval::control_header;
59use crate::symbolic_local_state::SymbolicLocalState;
60use crate::value_path_context::ValuePathContext;
61use crate::{ContractProvenance, Guard, ResourceRef, SourceSpan};
62use helm_schema_core::{GuardDnf, Predicate};
63
64use super::domain::{
65    AbstractFragment, AbstractString, EntryKey, Guarded, Mapping, MappingEntry, Opaque,
66    PathCondition, Sequence, SiteFacts, StringPart, and_conditions,
67};
68
69/// The result of evaluating one template source: the abstract rendered
70/// document plus the pathless value reads observed along the way.
71#[derive(Debug, Default)]
72pub struct EvaluatedDocument {
73    /// The guarded abstract fragment for the whole source (all YAML
74    /// documents merged; per-document projection is a later-stage concern).
75    pub root: Guarded<AbstractFragment>,
76    /// `.Values` reads that never render: condition reads, assignment
77    /// right-hand sides, helper-internal guard reads, and range headers.
78    pub reads: Vec<ValueRead>,
79    /// Declared input-type hints observed at unconditional rendered holes.
80    pub(crate) type_hints: BTreeMap<String, BTreeSet<String>>,
81    /// Input-type hints observed only under branch predicates: they hold
82    /// where those branches render, never at the unconditional base.
83    pub(crate) guarded_type_hints: BTreeMap<String, BTreeSet<String>>,
84    /// Input-type hints from literal `default`/`coalesce` fallbacks: they
85    /// type only the truthy arm of the path, never its Helm-falsy states.
86    pub(crate) fallback_type_hints: BTreeMap<String, BTreeSet<String>>,
87    /// Fallback hints observed under branch predicates: fallback-grade
88    /// intent that may type conditional overlays, but never a branch whose
89    /// renders all totally format.
90    pub(crate) guarded_fallback_type_hints: BTreeMap<String, BTreeSet<String>>,
91    /// Paths consumed through total stringifications (`quote`, `toString`,
92    /// `join`, `printf`) anywhere in the source: the chart tolerates any
93    /// input type at them even when no placed row exists.
94    pub(crate) shape_erased_paths: BTreeSet<String>,
95    /// Paths carrying a real runtime string contract (`trunc`, `b64enc`,
96    /// `fromYaml`, a dynamic `printf` format) somewhere in the source.
97    pub(crate) string_contract_paths: BTreeSet<String>,
98    /// The observed per-path range facts (direct iteration, JSON-decoded
99    /// values, key/value destructuring).
100    pub(crate) range_modes: crate::range_modes::RangeModes,
101    /// Chart value subtrees supplying defaults to the effective values tree.
102    pub(crate) values_default_sources: BTreeSet<crate::ValuesDefaultSource>,
103    /// Values subtrees merged in place over the values root: root contracts
104    /// project back onto the prefixed spellings.
105    pub(crate) values_root_overlay_prefixes: BTreeSet<String>,
106    /// Helper names through which the values root was replaced.
107    pub(crate) values_root_helper_includes: BTreeSet<String>,
108    /// Strictly string-consumed paths whose consumers execute BEFORE the
109    /// values-root wrapper rewrite: their nodes must not gain the wrapper
110    /// alternative (see the interpreter field of the same name).
111    pub(crate) pre_rewrite_strict_paths: BTreeSet<String>,
112    /// `fail` captures (see [`FailCapture`]): no valid values document may
113    /// satisfy one of these conjunctions.
114    pub(crate) fail_conditions: Vec<FailCapture>,
115}
116
117/// One pathless `.Values` read with the guards active at the read site.
118#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
119pub struct ValueRead {
120    /// The dotted `.Values` path that was read.
121    pub values_path: String,
122    /// The value shape observed at the read (helper rows demoted at capture
123    /// sites keep their fragment/scalar kind).
124    pub kind: crate::ValueKind,
125    /// The disjunction of predicate conjunctions under which the read occurs.
126    pub condition: GuardDnf,
127    /// The resource containing the read site, for site-scoped read classes
128    /// (condition, bound-value, templated-key, and rendered-effect reads);
129    /// helper-internal reads carry none.
130    pub resource: Option<ResourceRef>,
131    /// Source sites justifying the read.
132    pub provenance: Vec<ContractProvenance>,
133    /// Whether the read belongs to the dependency lane (helper rows demoted
134    /// at capture sites) instead of the document lane.
135    pub dependency: bool,
136}
137
138/// Evaluate one template source into its abstract fragment.
139pub(crate) fn eval_document(
140    source: &str,
141    source_path: Option<&str>,
142    db: &IrAnalysisDb,
143) -> EvaluatedDocument {
144    let Some(tree) = parse_go_template(source) else {
145        return EvaluatedDocument::default();
146    };
147    let document = TemplatedDocument::parse_with_root(source, tree.root_node());
148    let mut interpreter = Interpreter::for_source(source, source_path, db, &tree, &document);
149    let roots: Vec<NodeView<'_>> = document.roots().iter().map(NodeView::plain).collect();
150    let contributions = interpreter.eval_node_list(&roots);
151    EvaluatedDocument {
152        root: contributions.assemble(),
153        reads: interpreter.reads,
154        type_hints: interpreter.type_hints,
155        guarded_type_hints: interpreter.guarded_type_hints,
156        fallback_type_hints: interpreter.fallback_type_hints,
157        guarded_fallback_type_hints: interpreter.guarded_fallback_type_hints,
158        shape_erased_paths: interpreter.shape_erased_paths,
159        string_contract_paths: interpreter.string_contract_paths,
160        range_modes: interpreter.range_modes,
161        values_default_sources: interpreter.values_default_sources_observed,
162        values_root_overlay_prefixes: interpreter.values_root_overlay_prefixes_observed,
163        values_root_helper_includes: interpreter.values_root_helper_includes_observed,
164        pre_rewrite_strict_paths: interpreter.pre_rewrite_strict_paths,
165        fail_conditions: interpreter.fail_conditions,
166    }
167}
168
169/// Control-header facts parsed once from the shared Go-template tree, keyed
170/// by the region's opening-bracket byte (which equals the action node's
171/// start byte).
172pub(crate) struct ControlFacts {
173    pub(super) header: Option<TemplateHeader>,
174    pub(super) is_range: bool,
175    pub(super) range_destructured: bool,
176    /// The VALUE variable of a destructured range header (`$v` in
177    /// `range $k, $v := …`).
178    pub(super) range_value_variable: Option<String>,
179    /// The KEY variable of a destructured range header (`$k` in
180    /// `range $k, $v := …`).
181    pub(super) range_key_variable: Option<String>,
182    /// The whole region's end byte (through `{{ end }}`), for regions that
183    /// only surface as holes (block-scalar bodies).
184    pub(super) region_end: usize,
185}
186
187/// Source-only evaluation facts of one template body: independent of call
188/// bindings, so helper bodies compute them once and reuse them across every
189/// memoized-summary miss.
190pub(crate) struct BodyEvalFacts {
191    pub(super) control_facts: HashMap<usize, ControlFacts>,
192    pub(super) resource_spans: Vec<ResourceSpan>,
193}
194
195impl BodyEvalFacts {
196    pub(crate) fn collect(
197        source: &str,
198        db: &IrAnalysisDb,
199        tree: &tree_sitter::Tree,
200        document: &TemplatedDocument<'_>,
201    ) -> Self {
202        let mut control_facts = HashMap::new();
203        collect_control_facts(tree.root_node(), source, &mut control_facts);
204        Self {
205            control_facts,
206            resource_spans: crate::resource_identity::collect_resource_spans(document, db),
207        }
208    }
209}
210
211fn collect_control_facts(
212    node: tree_sitter::Node<'_>,
213    source: &str,
214    out: &mut HashMap<usize, ControlFacts>,
215) {
216    match node.kind() {
217        "if_action" | "with_action" => {
218            out.insert(
219                node.start_byte(),
220                ControlFacts {
221                    header: control_header(source, node),
222                    is_range: false,
223                    range_destructured: false,
224                    range_value_variable: None,
225                    range_key_variable: None,
226                    region_end: node.end_byte(),
227                },
228            );
229        }
230        "range_action" => {
231            out.insert(
232                node.start_byte(),
233                ControlFacts {
234                    header: range_header_from_source(node, source),
235                    is_range: true,
236                    range_destructured: range_has_destructured_variable_definition(node),
237                    range_value_variable: helm_schema_ast::range_destructured_value_variable(
238                        node, source,
239                    ),
240                    range_key_variable: helm_schema_ast::range_destructured_key_variable(
241                        node, source,
242                    ),
243                    region_end: node.end_byte(),
244                },
245            );
246        }
247        _ => {}
248    }
249    let mut cursor = node.walk();
250    for child in node.named_children(&mut cursor) {
251        collect_control_facts(child, source, out);
252    }
253}
254
255/// The byte where a container's first deeper *content* child appears (the
256/// open-slot query's "mark": action lines, comments, and blanks are
257/// transparent; only visible YAML lines deeper than the container mark it).
258fn content_child_mark(children: &[Node], container_indent: usize) -> Option<usize> {
259    children
260        .iter()
261        .filter_map(|child| match child {
262            Node::Mapping(entry) if entry.indent > container_indent => Some(entry.span.start),
263            Node::Sequence(item) if item.indent > container_indent => Some(item.span.start),
264            Node::Scalar(line) if line.indent > container_indent => Some(line.span.start),
265            _ => None,
266        })
267        .min()
268}
269
270fn collect_inline_regions(nodes: &[Node], out: &mut Vec<Span>) {
271    for node in nodes {
272        match node {
273            Node::Opaque(opaque) if opaque.kind == OpaqueKind::InlineRegion => {
274                out.push(opaque.span);
275            }
276            Node::Mapping(entry) => collect_inline_regions(&entry.children, out),
277            Node::Sequence(item) => collect_inline_regions(&item.children, out),
278            Node::Control(region) => {
279                for branch in &region.branches {
280                    collect_inline_regions(&branch.body, out);
281                }
282            }
283            _ => {}
284        }
285    }
286}
287
288/// What one level of nodes contributes to its enclosing container.
289#[derive(Default)]
290pub(super) struct Contributions {
291    pub(super) entries: Vec<MappingEntry>,
292    pub(super) items: Vec<Guarded<AbstractFragment>>,
293    pub(super) values: Guarded<AbstractFragment>,
294    /// Fragment output carrying an explicit rendered indent
295    /// (`… | nindent N`): it attaches to the nearest enclosing container
296    /// whose indent is shallower than `N`, floating up past deeper open
297    /// containers exactly like the attribution index's open-slot query.
298    pub(super) floating: Vec<FloatingOutput>,
299    pub(super) loop_control: LoopControl,
300}
301
302#[derive(Default)]
303pub(super) struct LoopControl {
304    pub(super) breaks: Vec<PathCondition>,
305    pub(super) continues: Vec<PathCondition>,
306}
307
308impl LoopControl {
309    pub(super) fn exit_condition(&self) -> PathCondition {
310        any_conditions(self.breaks.iter().chain(&self.continues).cloned().collect())
311    }
312
313    pub(super) fn break_condition(&self) -> PathCondition {
314        any_conditions(self.breaks.clone())
315    }
316
317    fn guard_all(&mut self, condition: &PathCondition) {
318        for exit in self.breaks.iter_mut().chain(&mut self.continues) {
319            *exit = and_conditions(condition.clone(), exit.clone());
320        }
321    }
322
323    fn extend(&mut self, other: Self) {
324        self.breaks.extend(other.breaks);
325        self.continues.extend(other.continues);
326    }
327}
328
329fn any_conditions(mut conditions: Vec<PathCondition>) -> PathCondition {
330    conditions.retain(|condition| *condition != Predicate::False);
331    if conditions.contains(&Predicate::True) {
332        return Predicate::True;
333    }
334    conditions.sort();
335    conditions.dedup();
336    match conditions.as_slice() {
337        [] => Predicate::False,
338        [condition] => condition.clone(),
339        _ => Predicate::Or(conditions),
340    }
341}
342
343/// One explicitly-indented fragment output looking for its container.
344pub(super) struct FloatingOutput {
345    /// The rendered indent (`nindent`/`indent` width).
346    pub(super) width: usize,
347    /// The action's byte position (decides whether a same-indent container
348    /// had already been "marked" by a deeper child when the output ran).
349    pub(super) origin: usize,
350    pub(super) value: Guarded<AbstractFragment>,
351}
352
353impl Contributions {
354    pub(super) fn merge_entry(&mut self, key: EntryKey, value: Guarded<AbstractFragment>) {
355        if let EntryKey::Literal(name) = &key
356            && let Some(existing) = self.entries.iter_mut().find(
357                |entry| matches!(&entry.key, EntryKey::Literal(existing_name) if existing_name == name),
358            )
359        {
360            existing.value.extend(value);
361            return;
362        }
363        self.entries.push(MappingEntry { key, value });
364    }
365
366    pub(super) fn push_value_arm(&mut self, arm: (PathCondition, AbstractFragment)) {
367        self.values.arms.push(arm);
368    }
369
370    pub(super) fn guard_all(&mut self, condition: &PathCondition) {
371        for entry in &mut self.entries {
372            entry.value.guard_all(condition);
373        }
374        for item in &mut self.items {
375            item.guard_all(condition);
376        }
377        self.values.guard_all(condition);
378        for floating in &mut self.floating {
379            floating.value.guard_all(condition);
380        }
381        self.loop_control.guard_all(condition);
382    }
383
384    pub(super) fn extend(&mut self, other: Self) {
385        for entry in other.entries {
386            self.merge_entry(entry.key, entry.value);
387        }
388        self.items.extend(other.items);
389        self.values.extend(other.values);
390        self.floating.extend(other.floating);
391        self.loop_control.extend(other.loop_control);
392    }
393
394    pub(super) fn take_loop_control(&mut self) -> LoopControl {
395        std::mem::take(&mut self.loop_control)
396    }
397
398    /// Split off the floating output that renders *inside* a container of
399    /// the given indent, returning it as one guarded value; shallower output
400    /// keeps floating for an ancestor. A container opened without an inline
401    /// value also accepts output rendered at its own indent, unless a deeper
402    /// child had already "marked" it before the output ran (both rules from
403    /// the open-slot query).
404    pub(super) fn take_floating_below(
405        &mut self,
406        container_indent: usize,
407        accepts_same_indent: bool,
408        marked_at: Option<usize>,
409    ) -> Guarded<AbstractFragment> {
410        let mut attached = Guarded::empty();
411        let mut keep = Vec::new();
412        for floating in std::mem::take(&mut self.floating) {
413            let same_indent_ok = floating.width == container_indent
414                && accepts_same_indent
415                && marked_at.is_none_or(|marked| marked >= floating.origin);
416            if floating.width > container_indent || same_indent_ok {
417                attached.extend(floating.value);
418            } else {
419                keep.push(floating);
420            }
421        }
422        self.floating = keep;
423        attached
424    }
425
426    pub(super) fn assemble(self) -> Guarded<AbstractFragment> {
427        let mut out = Guarded::empty();
428        if !self.entries.is_empty() {
429            out.arms.push((
430                Predicate::True,
431                AbstractFragment::Mapping(Mapping {
432                    entries: self.entries,
433                }),
434            ));
435        }
436        if !self.items.is_empty() {
437            out.arms.push((
438                Predicate::True,
439                AbstractFragment::Sequence(Sequence { items: self.items }),
440            ));
441        }
442        out.extend(self.values);
443        // Floating output that never found a shallower container attaches
444        // at this level.
445        for floating in self.floating {
446            out.extend(floating.value);
447        }
448        out
449    }
450}
451
452/// A node reference plus an optional adoption child limit: children whose
453/// spans start at or beyond the limit belong *after* the adopting control
454/// region (source order) and are evaluated there instead of inside the
455/// branch scope.
456#[derive(Clone, Copy)]
457pub(super) struct NodeView<'n> {
458    pub(super) node: &'n Node,
459    pub(super) child_limit: Option<usize>,
460}
461
462impl<'n> NodeView<'n> {
463    pub(super) fn plain(node: &'n Node) -> Self {
464        Self {
465            node,
466            child_limit: None,
467        }
468    }
469
470    /// The node's children that evaluate in place (before the child limit).
471    /// The limit propagates so deeper descendants past it stay excluded too;
472    /// the adopting control region re-attaches them outside its scope.
473    pub(super) fn in_scope_children(&self) -> Vec<NodeView<'n>> {
474        let children = match self.node {
475            Node::Mapping(entry) => &entry.children,
476            Node::Sequence(item) => &item.children,
477            _ => return Vec::new(),
478        };
479        children
480            .iter()
481            .filter(|child| {
482                self.child_limit
483                    .is_none_or(|limit| child.span_start() < limit)
484            })
485            .map(|child| NodeView {
486                node: child,
487                child_limit: self.child_limit,
488            })
489            .collect()
490    }
491}
492
493/// One adopted escaped sibling: its bounded in-scope view plus the
494/// enclosing region bound that caps this region's deferral window.
495pub(super) struct Adopted<'n> {
496    pub(super) view: NodeView<'n>,
497    pub(super) defer_upper: Option<usize>,
498}
499
500/// One arm's decoded activation.
501pub(super) enum ArmSpec {
502    If(Option<TemplateHeader>),
503    With(Option<TemplateHeader>),
504    Range {
505        header: Option<TemplateHeader>,
506        destructured: bool,
507        value_variable: Option<String>,
508        key_variable: Option<String>,
509    },
510    Else,
511}
512
513pub(super) struct Interpreter<'a> {
514    pub(super) source: &'a str,
515    pub(super) source_path: Option<&'a str>,
516    /// Byte offset of this source within its file (helper bodies evaluate
517    /// over the define body text; provenance spans stay file-absolute).
518    pub(super) source_offset: usize,
519    pub(super) db: &'a IrAnalysisDb,
520    /// Source-only facts (control headers, resource spans), shared across
521    /// the memoized evaluations of one helper body.
522    pub(super) body_facts: Rc<BodyEvalFacts>,
523    pub(super) inline_regions: Vec<Span>,
524    /// Static file templates currently being inlined (cycle prevention for
525    /// `.Files.Get`-style template requests).
526    pub(super) inline_files: Vec<String>,
527    /// Whether this interpreter evaluates a helper body (a summary run).
528    pub(super) helper_scope: bool,
529    /// The active helper call chain, threaded into expression evaluation so
530    /// nested bound calls cut cycles.
531    pub(super) helper_seen: HashSet<String>,
532    pub(super) locals: SymbolicLocalState,
533    pub(super) dot_stack: Vec<Option<AbstractValue>>,
534    /// The value-flavor dot of a helper scope's root frame (the call
535    /// boundary resolves both flavors; see `DotFrame`). Document scope has
536    /// none: its value dot derives from the fragment dot.
537    pub(super) root_value_dot: Option<AbstractValue>,
538    pub(super) root_bindings: HashMap<String, AbstractValue>,
539    pub(super) root_truthy_predicates: HashMap<String, Predicate>,
540    /// Exhaustive per-arm value alternatives for root-context fields set
541    /// across complete if/else chains (see [`RootValueDispatch`]); condition
542    /// decoding resolves root-field equalities through them.
543    pub(super) root_value_dispatches: HashMap<String, crate::eval_effect::RootValueDispatch>,
544    /// Root-context replacements observed in source order and exported by helper summaries.
545    pub(super) root_set_mutations_observed: BTreeMap<String, AbstractValue>,
546    pub(super) root_set_predicates_observed: BTreeMap<String, Predicate>,
547    pub(super) root_value_dispatches_observed:
548        BTreeMap<String, crate::eval_effect::RootValueDispatch>,
549    pub(super) values_default_sources_observed: BTreeSet<crate::ValuesDefaultSource>,
550    pub(super) values_root_overlay_prefixes_observed: BTreeSet<String>,
551    pub(super) values_root_helper_includes_observed: BTreeSet<String>,
552    /// Values paths with a strict STRING consumer that executed before the
553    /// first values-root program-wrapper rewrite in this source: a wrapper
554    /// map at such a path reaches the consumer raw and aborts (nats'
555    /// `fullname | trunc` reads `nameOverride` before `nats.defaultValues`
556    /// substitutes the tplYaml result), so those nodes must not gain the
557    /// wrapper alternative. Tolerant pre-rewrite reads (`default`
558    /// selections that only copy the value) stay wrapper-eligible.
559    pub(super) pre_rewrite_strict_paths: BTreeSet<String>,
560    pub(super) active_predicates: Vec<Predicate>,
561    /// Loop nesting depth of the evaluation point (block and inline range
562    /// bodies): first-iteration reasoning is only sound at depth one.
563    pub(super) loop_depth: usize,
564    pub(super) reads: Vec<ValueRead>,
565    /// Dedup shadow of `reads` (order lives in the vec).
566    reads_seen: HashSet<ValueRead>,
567    pub(super) type_hints: BTreeMap<String, BTreeSet<String>>,
568    /// Paths consumed as serialized YAML by `fromYaml`; document-scope
569    /// helper conditions import this narrow input contract without importing
570    /// unrelated helper-body output transformations.
571    pub(super) parsed_yaml_input_paths: BTreeSet<String>,
572    /// Paths whose helper output was serialized with `toYaml`; callers use
573    /// this to recognize a matching `fromYaml` as a structural round trip.
574    pub(super) yaml_serialized_paths: BTreeSet<String>,
575    /// Input-type hints observed while branch predicates were active: they
576    /// hold only where those branches render, so they may type conditional
577    /// overlays but never the unconditional base.
578    pub(super) guarded_type_hints: BTreeMap<String, BTreeSet<String>>,
579    /// Input-type hints from literal `default`/`coalesce` fallbacks: they
580    /// type only the truthy arm of the path, never its Helm-falsy states.
581    pub(super) fallback_type_hints: BTreeMap<String, BTreeSet<String>>,
582    /// Fallback hints observed under branch predicates.
583    pub(super) guarded_fallback_type_hints: BTreeMap<String, BTreeSet<String>>,
584    /// Paths consumed only through total stringifications (`quote`,
585    /// `toString`, `join`, `printf`): the chart tolerates any input type at
586    /// them even when no placed row exists.
587    pub(super) shape_erased_paths: BTreeSet<String>,
588    /// Paths on which a string-consuming transform bound a real runtime
589    /// string contract somewhere in the source.
590    pub(super) string_contract_paths: BTreeSet<String>,
591    /// The per-path range facts observed anywhere in this source (direct
592    /// iteration, JSON-decoded values, key/value destructuring).
593    pub(super) range_modes: crate::range_modes::RangeModes,
594    /// `fail` captures (see [`FailCapture`]): no valid values document may
595    /// satisfy one of these conjunctions.
596    pub(super) fail_conditions: Vec<FailCapture>,
597    /// Object-producing mutations observed before subsequent member reads.
598    pub(super) member_host_conversions: BTreeSet<crate::eval_effect::MemberHostConversion>,
599    /// Directly ranged paths active on the walk. Helper-scope ranges mark
600    /// membership with truthy predicates (the summary lane's flavor), so
601    /// fail capture re-adds the range facts from here.
602    pub(super) active_direct_ranged_paths: Vec<String>,
603    /// Approximate conjuncts for exact-range items that not every iterable
604    /// alternative executes (nats' jsonpatch conditionally appends "from"
605    /// to `$opPathKeys`). They condition CAPTURE conjunctions only — rows
606    /// and type hints keep the join semantics ordinary contributions have —
607    /// so strict captures inside such an item cannot bind unconditionally.
608    pub(super) alternative_capture_approximates: Vec<Predicate>,
609    /// Predicate paths severed by index-call narrowing anywhere in this
610    /// source: guard reads of their strict ancestors are dropped from the
611    /// summary (the narrowing proves the ancestor probe was a traversal
612    /// step, not a condition on the ancestor itself).
613    pub(super) suppress_predicate_paths: BTreeSet<String>,
614    /// Every chart-level `set … default` normalization observed anywhere in
615    /// this source, unconditionally. `locals.chart_value_defaults` keeps the
616    /// branch-intersected "definitely ran in source order" view for render
617    /// sites; helper summaries export this accumulator instead (a summary's
618    /// defaults are declarations for the caller, like they always were).
619    pub(super) chart_defaults_observed: BTreeSet<String>,
620    /// The site facts of the hole or control region currently being
621    /// evaluated; reads recorded during that evaluation carry them.
622    pub(super) current_site: Option<Rc<SiteFacts>>,
623}
624
625impl<'a> Interpreter<'a> {
626    /// A fresh interpreter over one parsed source: control-header facts,
627    /// inline-region spans, and resource spans are collected up front; all
628    /// evaluation state starts empty.
629    pub(super) fn for_source(
630        source: &'a str,
631        source_path: Option<&'a str>,
632        db: &'a IrAnalysisDb,
633        tree: &tree_sitter::Tree,
634        document: &TemplatedDocument<'_>,
635    ) -> Self {
636        let body_facts = Rc::new(BodyEvalFacts::collect(source, db, tree, document));
637        Self::with_body_facts(source, source_path, db, document, body_facts)
638    }
639
640    /// A fresh interpreter reusing precomputed source-only facts (helper
641    /// bodies share them across memoized evaluations).
642    pub(super) fn with_body_facts(
643        source: &'a str,
644        source_path: Option<&'a str>,
645        db: &'a IrAnalysisDb,
646        document: &TemplatedDocument<'_>,
647        body_facts: Rc<BodyEvalFacts>,
648    ) -> Self {
649        let mut inline_regions = Vec::new();
650        collect_inline_regions(document.roots(), &mut inline_regions);
651        Self {
652            source,
653            source_path,
654            source_offset: 0,
655            db,
656            body_facts,
657            inline_regions,
658            inline_files: Vec::new(),
659            helper_scope: false,
660            helper_seen: HashSet::new(),
661            locals: SymbolicLocalState::default(),
662            dot_stack: Vec::new(),
663            root_value_dot: None,
664            root_bindings: HashMap::new(),
665            root_truthy_predicates: HashMap::new(),
666            root_value_dispatches: HashMap::new(),
667            root_set_mutations_observed: BTreeMap::new(),
668            root_set_predicates_observed: BTreeMap::new(),
669            root_value_dispatches_observed: BTreeMap::new(),
670            values_default_sources_observed: BTreeSet::new(),
671            values_root_overlay_prefixes_observed: BTreeSet::new(),
672            values_root_helper_includes_observed: BTreeSet::new(),
673            pre_rewrite_strict_paths: BTreeSet::new(),
674            active_predicates: Vec::new(),
675            loop_depth: 0,
676            reads: Vec::new(),
677            reads_seen: HashSet::new(),
678            type_hints: BTreeMap::new(),
679            parsed_yaml_input_paths: BTreeSet::new(),
680            yaml_serialized_paths: BTreeSet::new(),
681            guarded_type_hints: BTreeMap::new(),
682            fallback_type_hints: BTreeMap::new(),
683            guarded_fallback_type_hints: BTreeMap::new(),
684            shape_erased_paths: BTreeSet::new(),
685            string_contract_paths: BTreeSet::new(),
686            range_modes: crate::range_modes::RangeModes::default(),
687            fail_conditions: Vec::new(),
688            member_host_conversions: BTreeSet::new(),
689            active_direct_ranged_paths: Vec::new(),
690            alternative_capture_approximates: Vec::new(),
691            suppress_predicate_paths: BTreeSet::new(),
692            chart_defaults_observed: BTreeSet::new(),
693            current_site: None,
694        }
695    }
696
697    pub(super) fn text(&self, span: Span) -> &'a str {
698        self.source.get(span.start..span.end).unwrap_or("")
699    }
700
701    /// The site facts of one output hole: the smallest resource span
702    /// containing the hole's start byte plus the hole's own provenance.
703    pub(super) fn hole_site(&self, span: Span) -> Option<Rc<SiteFacts>> {
704        let resource_span = self
705            .body_facts
706            .resource_spans
707            .iter()
708            .filter(|resource| resource.start <= span.start && span.start < resource.end)
709            .min_by(|left, right| {
710                let left_len = left.end.saturating_sub(left.start);
711                let right_len = right.end.saturating_sub(right.start);
712                left_len
713                    .cmp(&right_len)
714                    .then_with(|| right.start.cmp(&left.start))
715            });
716        self.site_facts(resource_span.map(|span| self.span_resource(span)), span)
717    }
718
719    /// The site facts of one control region: the region's resource is the
720    /// unique resource intersecting the region span (a region spanning
721    /// several manifest documents claims none).
722    pub(super) fn region_site(&self, span: Span) -> Option<Rc<SiteFacts>> {
723        let mut unique: Option<&ResourceSpan> = None;
724        for resource in &self.body_facts.resource_spans {
725            if resource.start >= span.end || span.start >= resource.end {
726                continue;
727            }
728            match unique {
729                Some(existing) if existing.resource != resource.resource => {
730                    unique = None;
731                    break;
732                }
733                Some(_) => {}
734                None => unique = Some(resource),
735            }
736        }
737        self.site_facts(unique.map(|span| self.span_resource(span)), span)
738    }
739
740    /// The span's resource for one tagged use, with any inline-conditional
741    /// kind arms predicate-qualified through the CURRENT scope: the
742    /// selecting locals bind above the header, so each guard lowers exactly
743    /// as the body's own branch conditions do and the builder can match row
744    /// conjunctions to arms structurally.
745    fn span_resource(&self, resource_span: &ResourceSpan) -> (ResourceRef, Vec<String>) {
746        let mut resource = resource_span.resource.clone();
747        resource.kind_branches = self.resolved_kind_branches(&resource_span.kind_branch_sources);
748        (resource, resource_span.path_prefix.clone())
749    }
750
751    /// Lower an inline kind chain's raw guard texts into per-arm
752    /// predicates. An arm holds where its own guard does AND every earlier
753    /// guard failed. Any undecodable guard abstains entirely: dropping one
754    /// arm would leave an incomplete partition that misassigns rows.
755    fn resolved_kind_branches(
756        &self,
757        sources: &[helm_schema_ast::KindBranchSource],
758    ) -> Vec<helm_schema_core::KindBranch> {
759        if sources.is_empty() {
760            return Vec::new();
761        }
762        let context = self.value_path_context();
763        let mut prior_negations: Vec<Predicate> = Vec::new();
764        let mut branches = Vec::new();
765        for source in sources {
766            let mut conjuncts = prior_negations.clone();
767            if let Some(text) = &source.condition {
768                let wrapped = format!("{{{{ {} }}}}", text.trim());
769                let exprs = helm_schema_ast::parse_action_expressions(&wrapped);
770                let [expr] = exprs.as_slice() else {
771                    return Vec::new();
772                };
773                if !context.condition_lowering_is_faithful(expr) {
774                    return Vec::new();
775                }
776                let predicate = context.condition_predicate_expr(expr);
777                prior_negations.push(predicate.negated());
778                conjuncts.push(predicate);
779            }
780            branches.push(helm_schema_core::KindBranch {
781                predicate: Predicate::all(conjuncts),
782                kind: source.kind.clone(),
783            });
784        }
785        branches
786    }
787
788    fn site_facts(
789        &self,
790        resource: Option<(ResourceRef, Vec<String>)>,
791        span: Span,
792    ) -> Option<Rc<SiteFacts>> {
793        let provenance = self.source_path.map(|source_path| {
794            let helper_chain = self
795                .inline_files
796                .iter()
797                .filter_map(|entry| entry.strip_prefix("define:"))
798                .map(std::string::ToString::to_string)
799                .collect();
800            ContractProvenance::new(
801                source_path,
802                SourceSpan::new(
803                    self.source_offset + span.start,
804                    self.source_offset + span.end,
805                ),
806                helper_chain,
807            )
808        });
809        let (resource, path_prefix) = match resource {
810            Some((resource, path_prefix)) => (Some(resource), path_prefix),
811            None => (None, Vec::new()),
812        };
813        if resource.is_none() && provenance.is_none() {
814            return None;
815        }
816        Some(Rc::new(SiteFacts {
817            resource,
818            path_prefix,
819            provenance,
820        }))
821    }
822
823    /// Run one evaluation step under the site facts of `span`, restoring the
824    /// previous site afterwards.
825    pub(super) fn enter_hole_site(&mut self, span: Span) -> Option<Rc<SiteFacts>> {
826        let site = self.hole_site(span);
827        std::mem::replace(&mut self.current_site, site)
828    }
829
830    pub(super) fn restore_site(&mut self, previous: Option<Rc<SiteFacts>>) {
831        self.current_site = previous;
832    }
833
834    pub(super) fn current_dot_fragment(&self) -> Option<AbstractValue> {
835        self.dot_stack.last().cloned().flatten()
836    }
837
838    pub(super) fn current_dot_binding(&self) -> Option<AbstractValue> {
839        if self.dot_stack.len() <= 1
840            && let Some(root) = &self.root_value_dot
841        {
842            return Some(root.clone());
843        }
844        self.dot_stack
845            .last()
846            .and_then(|binding| binding.as_ref())
847            .and_then(AbstractValue::to_current_dot_context_value)
848    }
849
850    /// The value-flavor dot for expression evaluation: a helper scope's root
851    /// frame carries the call boundary's own value dot; everywhere else the
852    /// fragment dot's context-value projection stands in.
853    pub(super) fn current_value_dot(&self) -> Option<AbstractValue> {
854        if self.dot_stack.len() <= 1
855            && let Some(root) = &self.root_value_dot
856        {
857            return Some(root.clone());
858        }
859        self.current_dot_fragment()
860            .map(|value| value.to_context_value())
861            .or_else(|| self.current_dot_binding())
862    }
863
864    pub(super) fn value_path_context(&self) -> ValuePathContext<'_> {
865        // Member bindings resolve for conditions and assignments; explicit
866        // fragment values shadow them where both exist.
867        let mut template_bindings = self.locals.range_member_values.clone();
868        template_bindings.extend(
869            self.locals
870                .fragment_values
871                .iter()
872                .map(|(name, value)| (name.clone(), value.clone())),
873        );
874        ValuePathContext {
875            root_bindings: &self.root_bindings,
876            root_truthy_predicates: &self.root_truthy_predicates,
877            root_value_dispatches: &self.root_value_dispatches,
878            template_bindings,
879            range_domains: &self.locals.range_domains,
880            get_bindings: &self.locals.get_bindings,
881            template_default_paths: &self.locals.default_paths,
882            template_output_meta: &self.locals.output_meta,
883            template_truthy_reductions: &self.locals.truthy_reductions,
884            typeof_bindings: &self.locals.typeof_sources,
885            int_cast_bindings: &self.locals.int_cast_sources,
886            kube_version_bindings: &self.locals.kube_version_sources,
887            fragment_context: FragmentEvalContext::new(self.db),
888            current_dot_fragment: self.current_dot_fragment(),
889            current_dot_binding: self.current_value_dot(),
890        }
891    }
892
893    /// Record that rendering FAILS unconditionally under the currently
894    /// active predicates (`fail` calls): no valid values document may
895    /// satisfy them. The RAW predicates are kept — the guard-DNF
896    /// conversion drops conjuncts it cannot represent, which negation
897    /// cannot tolerate.
898    pub(super) fn record_fail_condition(&mut self) {
899        let capture = FailCapture {
900            conjunction: self.fail_capture_conjunction(Vec::new()),
901            ranged: self.capture_ranged_modes(),
902            kind: CaptureKind::Fail,
903        };
904        if capture
905            .conjunction
906            .iter()
907            .any(|p| matches!(p, Predicate::False))
908        {
909            return;
910        }
911        if !self.fail_conditions.contains(&capture) {
912            self.fail_conditions.push(capture);
913        }
914    }
915
916    /// Record a `required(message, subject)` guardrail: rendering fails
917    /// under the ambient predicates whenever the subject is Helm-empty
918    /// (absent, null, or the empty string).
919    pub(super) fn record_required_condition(&mut self, subject_path: &str) {
920        let empty = Predicate::Or(vec![
921            Predicate::from(Guard::Absent {
922                path: subject_path.to_string(),
923            }),
924            Predicate::from(Guard::Eq {
925                path: subject_path.to_string(),
926                value: helm_schema_core::GuardValue::Null,
927            }),
928            Predicate::from(Guard::Eq {
929                path: subject_path.to_string(),
930                value: helm_schema_core::GuardValue::string(""),
931            }),
932        ]);
933        let capture = FailCapture {
934            conjunction: self.fail_capture_conjunction(vec![empty]),
935            ranged: self.capture_ranged_modes(),
936            kind: CaptureKind::Fail,
937        };
938        if capture
939            .conjunction
940            .iter()
941            .any(|p| matches!(p, Predicate::False))
942        {
943            return;
944        }
945        if !self.fail_conditions.contains(&capture) {
946            self.fail_conditions.push(capture);
947        }
948    }
949
950    /// The ambient predicates plus `tail`, with active direct range facts
951    /// present even when a caller constructed the capture indirectly.
952    pub(super) fn fail_capture_conjunction(&self, tail: Vec<Predicate>) -> Vec<Predicate> {
953        let mut conjunction = self.active_predicates.clone();
954        for path in &self.active_direct_ranged_paths {
955            let range = Predicate::from(Guard::Range { path: path.clone() });
956            if !conjunction.contains(&range) {
957                conjunction.push(range);
958            }
959        }
960        for approximate in &self.alternative_capture_approximates {
961            if !conjunction.contains(approximate) {
962                conjunction.push(approximate.clone());
963            }
964        }
965        conjunction.extend(tail);
966        conjunction
967    }
968
969    /// The range facts a fail capture rides: paths actively ranged at the
970    /// capture site are `direct` (only these have member identities), while
971    /// the JSON-decoded and destructured flavors carry every occurrence
972    /// observed in this source.
973    pub(super) fn capture_ranged_modes(&self) -> crate::range_modes::RangeModes {
974        let mut ranged = crate::range_modes::RangeModes::default();
975        for path in &self.active_direct_ranged_paths {
976            ranged.mark_direct(path);
977        }
978        for (path, mode) in self.range_modes.iter() {
979            if mode.json_decoded {
980                ranged.mark_json_decoded(path);
981            }
982            if mode.destructured {
983                ranged.mark_destructured(path);
984            }
985        }
986        ranged
987    }
988
989    pub(super) fn ambient_condition(&self) -> GuardDnf {
990        GuardDnf::from_conjunction(self.active_predicates.iter().cloned())
991    }
992
993    pub(super) fn push_predicate(&mut self, predicate: Predicate) {
994        // `False` is load-bearing: a decoded-dead branch (a `hasKey` probe
995        // into a folded literal table that misses) must poison the captures
996        // recorded under it. Only `True` is skippable noise.
997        if !matches!(predicate, Predicate::True) && !self.active_predicates.contains(&predicate) {
998            self.active_predicates.push(predicate);
999        }
1000    }
1001
1002    /// Whether an enclosing condition's lowering is APPROXIMATE (truthy
1003    /// fallbacks, dropped conjuncts). Activation pushes the approximation
1004    /// onto the predicate stack as a [`Predicate::Approximate`] conjunct
1005    /// (later arms carry it inside the negated prior), so scanning the
1006    /// active predicates sees exactly the enclosing approximations. Rows
1007    /// tolerate wider conditions; string-contract metadata and fail
1008    /// NEGATION abstain under one.
1009    pub(super) fn under_approximate_condition(&self) -> bool {
1010        self.active_predicates
1011            .iter()
1012            .any(Predicate::contains_approximation)
1013    }
1014
1015    /// A site-scoped pathless read: condition operands, bound-value reads,
1016    /// templated-key splices, and rendered-effect reads carry the current
1017    /// site's resource and provenance (the same scoping the emission
1018    /// terminal applied to their rows).
1019    pub(super) fn push_read(&mut self, values_path: &str, extra_guards: &[Guard]) {
1020        let (resource, provenance) = match &self.current_site {
1021            Some(site) => (
1022                site.resource.clone(),
1023                site.provenance.iter().cloned().collect(),
1024            ),
1025            None => (None, Vec::new()),
1026        };
1027        self.push_read_row(
1028            values_path,
1029            crate::ValueKind::Scalar,
1030            extra_guards,
1031            resource,
1032            provenance,
1033            false,
1034        );
1035    }
1036
1037    pub(super) fn push_read_row(
1038        &mut self,
1039        values_path: &str,
1040        kind: crate::ValueKind,
1041        extra_guards: &[Guard],
1042        resource: Option<ResourceRef>,
1043        provenance: Vec<ContractProvenance>,
1044        dependency: bool,
1045    ) {
1046        let condition = self
1047            .ambient_condition()
1048            .conjoined_with_guards(extra_guards.iter().cloned());
1049        self.push_read_row_with_condition(
1050            values_path,
1051            kind,
1052            condition,
1053            resource,
1054            provenance,
1055            dependency,
1056        );
1057    }
1058
1059    fn push_read_row_with_condition(
1060        &mut self,
1061        values_path: &str,
1062        kind: crate::ValueKind,
1063        condition: GuardDnf,
1064        resource: Option<ResourceRef>,
1065        provenance: Vec<ContractProvenance>,
1066        dependency: bool,
1067    ) {
1068        if values_path.trim().is_empty() {
1069            return;
1070        }
1071        let read = ValueRead {
1072            values_path: values_path.to_string(),
1073            kind,
1074            condition,
1075            resource,
1076            provenance,
1077            dependency,
1078        };
1079        if self.reads_seen.insert(read.clone()) {
1080            self.reads.push(read);
1081        }
1082    }
1083
1084    /// Absorb one nested interpreter's read verbatim (nested static-file
1085    /// evaluations already stamped their own guards and sites).
1086    pub(super) fn push_nested_read(&mut self, read: ValueRead) {
1087        if self.reads_seen.insert(read.clone()) {
1088            self.reads.push(read);
1089        }
1090    }
1091
1092    /// Pathless reads for the splices of a templated mapping key. Keys have
1093    /// no guarded arms in the tree, so their reads are recorded at the eval
1094    /// site where the ambient predicates (branch and range conditions) are
1095    /// still active; the projection deliberately does not re-derive them.
1096    pub(super) fn push_key_reads(&mut self, key: &EntryKey) {
1097        let EntryKey::Dynamic(string) = key else {
1098            return;
1099        };
1100        for part in &string.parts {
1101            match part {
1102                StringPart::Text(_) => {}
1103                // A rendered RANGE KEY in a templated key says nothing about
1104                // the collection's VALUE domain.
1105                StringPart::Splice(splice) if splice.meta.range_key => {}
1106                StringPart::Splice(splice) => {
1107                    let mut extra = Vec::new();
1108                    if splice.meta.defaulted {
1109                        extra.push(Guard::Default {
1110                            path: splice.values_path.clone(),
1111                        });
1112                    }
1113                    // A key position formats every SCALAR (a numeric label
1114                    // key renders `7:` and YAML-to-JSON stringifies it), so
1115                    // the read carries the partial-scalar kind: a declared
1116                    // string default widens to the scalar union instead of
1117                    // pinning the raw input as a string.
1118                    let (resource, provenance) = match &self.current_site {
1119                        Some(site) => (
1120                            site.resource.clone(),
1121                            site.provenance.iter().cloned().collect(),
1122                        ),
1123                        None => (None, Vec::new()),
1124                    };
1125                    self.push_read_row(
1126                        &splice.values_path,
1127                        crate::ValueKind::PartialScalar,
1128                        &extra,
1129                        resource,
1130                        provenance,
1131                        false,
1132                    );
1133                }
1134                StringPart::Taint(taint) => {
1135                    for path in &taint.paths {
1136                        self.push_read(path, &[]);
1137                    }
1138                }
1139            }
1140        }
1141    }
1142
1143    /// Pathless reads for a helper meta row retain its predicate disjunction
1144    /// as one condition. Helper rows have no site resource; their provenance
1145    /// is the read site's plus the helper body sites recorded in the meta.
1146    pub(super) fn push_meta_reads(
1147        &mut self,
1148        values_path: &str,
1149        kind: crate::ValueKind,
1150        meta: &HelperOutputMeta,
1151        sibling_claims: &BTreeSet<String>,
1152        dependency: bool,
1153    ) {
1154        let helper_condition = if meta.predicates.is_empty() {
1155            GuardDnf::unconditional()
1156        } else {
1157            GuardDnf::from_disjunction(meta.predicates.iter().map(|branch| branch.iter().cloned()))
1158        };
1159        let mut provenance: Vec<ContractProvenance> = self
1160            .current_site
1161            .as_ref()
1162            .and_then(|site| site.provenance.clone())
1163            .into_iter()
1164            .collect();
1165        merge_provenance_sites(&mut provenance, &meta.provenance);
1166        let mut condition = self
1167            .claim_scoped_ambient_condition(values_path, sibling_claims)
1168            .conjoined(&helper_condition);
1169        if meta.defaulted {
1170            condition = condition.conjoined_with_guards([Guard::Default {
1171                path: values_path.to_string(),
1172            }]);
1173        }
1174        self.push_read_row_with_condition(
1175            values_path,
1176            kind,
1177            condition,
1178            None,
1179            provenance,
1180            dependency,
1181        );
1182    }
1183
1184    /// The ambient condition scoped to one helper claim: a truthiness
1185    /// condition about a *different* claim path of the same call describes a
1186    /// sibling's branch, not this row's, and is dropped unless the paths are
1187    /// related (the summary lane's sibling-source rule).
1188    fn claim_scoped_ambient_condition(
1189        &self,
1190        claim_path: &str,
1191        sibling_claims: &BTreeSet<String>,
1192    ) -> GuardDnf {
1193        if !self.helper_scope {
1194            return GuardDnf::from_conjunction(self.active_predicates.iter().cloned());
1195        }
1196        GuardDnf::from_conjunction(
1197            self.active_predicates
1198                .iter()
1199                .filter(|predicate| {
1200                    let path = match predicate {
1201                        Predicate::Guard(Guard::Truthy { path }) => path,
1202                        Predicate::Not(inner) => match inner.as_ref() {
1203                            Predicate::Guard(Guard::Truthy { path }) => path,
1204                            _ => return true,
1205                        },
1206                        _ => return true,
1207                    };
1208                    path == claim_path
1209                        || !sibling_claims.contains(path)
1210                        || crate::helper_meta::values_paths_are_related(path, claim_path)
1211                })
1212                .cloned(),
1213        )
1214    }
1215
1216    /// Absorb helper-body reads at a call site: each read keeps its
1217    /// helper-internal guards and gains the site's ambient guards; the
1218    /// site's provenance leads the read's helper-body sites. Helper-internal
1219    /// reads carry no resource of their own, so site-less rows stay
1220    /// resource-free exactly like the summary lane always was.
1221    /// Absorb called-helper fail conjunctions: the body recorded its
1222    /// internal predicates; the call site prepends its ambient predicates,
1223    /// the same scoping helper reads get.
1224    /// Values paths a strict STRING consumer captured so far, regardless
1225    /// of branch scope: a program-wrapper map reaching such a consumer raw
1226    /// aborts rendering (`trunc`/`contains` type-assert strings, and a
1227    /// wrapper map is truthy, so even self-guarded consumers run). The
1228    /// pre-rewrite wrapper-exclusion snapshot reads this — conditional
1229    /// captures count because engines guard their whole body with an
1230    /// idempotence flag exactly as conditional as the rewrite itself.
1231    pub(super) fn strict_string_capture_paths(&self) -> BTreeSet<String> {
1232        let mut paths = self.string_contract_paths.clone();
1233        for capture in &self.fail_conditions {
1234            match &capture.kind {
1235                crate::eval_effect::CaptureKind::ValueType { path, schema_type }
1236                    if schema_type == "string" =>
1237                {
1238                    paths.insert(path.clone());
1239                }
1240                crate::eval_effect::CaptureKind::ValuePattern { path, .. } => {
1241                    paths.insert(path.clone());
1242                }
1243                _ => {}
1244            }
1245        }
1246        paths.retain(|path| !path.trim().is_empty() && !path.contains('*'));
1247        paths
1248    }
1249
1250    pub(super) fn absorb_helper_fails(&mut self, fails: &[FailCapture]) {
1251        for body_capture in fails {
1252            let mut ranged = self.capture_ranged_modes();
1253            ranged.merge(&body_capture.ranged);
1254            let conjunction = self.fail_capture_conjunction(body_capture.conjunction.clone());
1255            let mut kind = body_capture.kind.clone();
1256            if let CaptureKind::MemberAccess { handled_kinds } = &mut kind {
1257                let target = conjunction.iter().find_map(|predicate| match predicate {
1258                    Predicate::Not(inner) => match inner.as_ref() {
1259                        Predicate::Guard(Guard::TypeIs { path, schema_type })
1260                            if schema_type == "object" =>
1261                        {
1262                            Some(path)
1263                        }
1264                        _ => None,
1265                    },
1266                    _ => None,
1267                });
1268                if let Some(target) = target {
1269                    handled_kinds.extend(
1270                        self.member_host_conversions
1271                            .iter()
1272                            .filter(|conversion| {
1273                                &conversion.path == target
1274                                    && conversion
1275                                        .outer_predicates
1276                                        .iter()
1277                                        .all(|predicate| conjunction.contains(predicate))
1278                            })
1279                            .map(|conversion| conversion.input_kind.clone()),
1280                    );
1281                }
1282            }
1283            let capture = FailCapture {
1284                conjunction,
1285                ranged,
1286                kind,
1287            };
1288            if capture
1289                .conjunction
1290                .iter()
1291                .any(|p| matches!(p, Predicate::False))
1292            {
1293                continue;
1294            }
1295            if !self.fail_conditions.contains(&capture) {
1296                self.fail_conditions.push(capture);
1297            }
1298        }
1299    }
1300
1301    pub(super) fn absorb_helper_reads_with_suppression(
1302        &mut self,
1303        reads: &[ValueRead],
1304        suppressed: &BTreeSet<&String>,
1305        sibling_claims: &BTreeSet<String>,
1306    ) {
1307        let site_provenance: Vec<ContractProvenance> = self
1308            .current_site
1309            .as_ref()
1310            .and_then(|site| site.provenance.clone())
1311            .into_iter()
1312            .collect();
1313        for read in reads {
1314            // Guard-path reads that are strict ancestors of a predicate path
1315            // the helper explicitly severed (index-call narrowing) are
1316            // dropped, the same way the summary lane always skipped them.
1317            if !read.dependency
1318                && !suppressed.contains(&read.values_path)
1319                && suppressed.iter().any(|narrowed| {
1320                    helm_schema_core::values_path_is_descendant(narrowed, &read.values_path)
1321                })
1322            {
1323                continue;
1324            }
1325            let mut provenance = site_provenance.clone();
1326            merge_provenance_sites(&mut provenance, &read.provenance);
1327            let condition = self
1328                .claim_scoped_ambient_condition(&read.values_path, sibling_claims)
1329                .conjoined(&read.condition);
1330            self.push_read_row_with_condition(
1331                &read.values_path,
1332                read.kind,
1333                condition,
1334                read.resource.clone(),
1335                provenance,
1336                read.dependency,
1337            );
1338        }
1339    }
1340
1341    pub(super) fn eval_node_list(&mut self, nodes: &[NodeView<'_>]) -> Contributions {
1342        // The CST appends children escaping an ill-nested region at
1343        // container-close time, which can put them before the region in list
1344        // order; span order is document order, and adoption depends on it.
1345        let mut ordered: Vec<NodeView<'_>> = nodes.to_vec();
1346        ordered.sort_by_key(|view| view.node.span_start());
1347        let nodes = &ordered;
1348        let mut out = Contributions::default();
1349        let mut index = 0;
1350        let mut remaining = Predicate::True;
1351        while let Some(view) = nodes.get(index) {
1352            if remaining == Predicate::False {
1353                break;
1354            }
1355            let entry_predicates = self.active_predicates.len();
1356            self.push_predicate(remaining.clone());
1357            let mut next = Contributions::default();
1358            match view.node {
1359                Node::Control(region) => {
1360                    // Re-adopt siblings that escaped an ill-nested region:
1361                    // their spans still lie inside the region, so they belong
1362                    // to a branch body (with its guards and dot bindings).
1363                    // Children of an adopted node that start after the region
1364                    // end stay outside its scope via the child limit.
1365                    let region_index = index;
1366                    let mut adopted = Vec::new();
1367                    while let Some(next) = nodes.get(index + 1) {
1368                        if next.node.span_start() < region.span.end {
1369                            // In-scope evaluation is bounded by the innermost
1370                            // region end; deferral hands descendants past this
1371                            // region (but within the enclosing bound) back to
1372                            // this region, and the rest to the enclosing one.
1373                            let in_scope = next
1374                                .child_limit
1375                                .map_or(region.span.end, |limit| limit.min(region.span.end));
1376                            adopted.push(Adopted {
1377                                view: NodeView {
1378                                    node: next.node,
1379                                    child_limit: Some(in_scope),
1380                                },
1381                                defer_upper: next.child_limit,
1382                            });
1383                            index += 1;
1384                        } else {
1385                            break;
1386                        }
1387                    }
1388                    // Descendants of *earlier* siblings that escaped forward
1389                    // into the region (a branch contributing to a container
1390                    // opened before it) belong to branch bodies too; their
1391                    // in-place evaluation was bounded at the region start.
1392                    let mut escaped = Vec::new();
1393                    for prior in nodes.get(..region_index).unwrap_or_default() {
1394                        if matches!(prior.node, Node::Control(_)) {
1395                            continue;
1396                        }
1397                        let mut chain = Vec::new();
1398                        super::control::collect_deferred(
1399                            prior.node,
1400                            region.span.start,
1401                            prior.child_limit,
1402                            &mut chain,
1403                            &mut escaped,
1404                        );
1405                    }
1406                    next.extend(self.eval_control(region, &adopted, escaped));
1407                }
1408                Node::Output(action) => {
1409                    let consumed = self.eval_output_with_lookahead(action, nodes, index, &mut next);
1410                    index += consumed;
1411                }
1412                _ => {
1413                    // Evaluation stops at the next control sibling's start:
1414                    // descendants escaping into that region evaluate inside
1415                    // its branches instead of unguarded in place.
1416                    let mut bounded = *view;
1417                    if let Some(region_start) = nodes
1418                        .get(index + 1..)
1419                        .into_iter()
1420                        .flatten()
1421                        .find_map(|next| match next.node {
1422                            Node::Control(region) => Some(region.span.start),
1423                            _ => None,
1424                        })
1425                    {
1426                        bounded.child_limit = Some(
1427                            bounded
1428                                .child_limit
1429                                .map_or(region_start, |limit| limit.min(region_start)),
1430                        );
1431                    }
1432                    next.extend(self.eval_node(bounded));
1433                }
1434            }
1435            self.active_predicates.truncate(entry_predicates);
1436            let exit_condition = next.loop_control.exit_condition();
1437            next.guard_all(&remaining);
1438            out.extend(next);
1439            remaining = match exit_condition {
1440                Predicate::False => remaining,
1441                Predicate::True => Predicate::False,
1442                exit => and_conditions(remaining, exit.negated()),
1443            };
1444            index += 1;
1445        }
1446        out
1447    }
1448
1449    /// Evaluate a standalone output action, recognizing the templated
1450    /// mapping-key line shape (`{{ key-expr }}: value…`) from the trailing
1451    /// action-line text node. Returns how many extra sibling nodes were
1452    /// consumed.
1453    fn eval_output_with_lookahead(
1454        &mut self,
1455        action: &syntax::OutputAction,
1456        nodes: &[NodeView<'_>],
1457        index: usize,
1458        out: &mut Contributions,
1459    ) -> usize {
1460        let key_line = nodes.get(index + 1).and_then(|next| match next.node {
1461            Node::Opaque(opaque) if opaque.kind == OpaqueKind::ActionLineText => {
1462                let text = self.text(opaque.span);
1463                syntax::structural_mapping_colon(text).map(|colon| {
1464                    (
1465                        opaque.span,
1466                        text.get(..colon).unwrap_or("").to_string(),
1467                        text.get(colon + 1..).unwrap_or("").to_string(),
1468                    )
1469                })
1470            }
1471            _ => None,
1472        });
1473        let Some((text_span, key_suffix, rest)) = key_line else {
1474            let (value, width) = self.eval_output_action(action.span);
1475            match width {
1476                Some(width) => out.floating.push(FloatingOutput {
1477                    width,
1478                    origin: action.span.start,
1479                    value,
1480                }),
1481                None => out.values.extend(value),
1482            }
1483            return 0;
1484        };
1485
1486        // `{{ key }}…: …` — a dynamic mapping entry: the action (plus any
1487        // literal key suffix before the structural colon) is the key; the
1488        // inline value is the literal text after the colon or a same-line
1489        // action.
1490        let previous_site = self.enter_hole_site(action.span);
1491        let mut key_string = self.hole_string(action.span);
1492        if !key_suffix.is_empty() {
1493            key_string
1494                .parts
1495                .push(StringPart::Text([key_suffix].into_iter().collect()));
1496        }
1497        let key = EntryKey::Dynamic(key_string);
1498        self.push_key_reads(&key);
1499        self.restore_site(previous_site);
1500        let mut consumed = 1;
1501        let value = if rest.trim().is_empty() {
1502            match nodes.get(index + 2).map(|view| view.node) {
1503                Some(Node::Output(value_action))
1504                    if self.same_line(text_span.end, value_action.span.start) =>
1505                {
1506                    consumed = 2;
1507                    self.eval_entire_hole(value_action.span)
1508                }
1509                _ => Guarded::empty(),
1510            }
1511        } else if rest.trim().starts_with('|') || rest.trim().starts_with('>') {
1512            // `{{ key }}…: |` — a block scalar under a templated key. The
1513            // layout cannot open a block frame for templated keys, so the
1514            // body arrives as deeper-indented sibling lines; consume them as
1515            // the entry's render-suppressed blob.
1516            let key_indent = self.line_indent(action.span.start);
1517            let (block, block_consumed) =
1518                self.consume_dynamic_block_body(nodes, index + 2, key_indent);
1519            consumed += block_consumed;
1520            block
1521        } else {
1522            Guarded::unconditional(AbstractFragment::Scalar(AbstractString::literal(
1523                rest.trim().to_string(),
1524            )))
1525        };
1526        out.merge_entry(key, value);
1527        consumed
1528    }
1529
1530    /// Consume the deeper-indented sibling lines forming the body of a
1531    /// templated-key block scalar, evaluating their holes as suppressed
1532    /// parts. Returns the suppressed scalar and how many nodes were
1533    /// consumed.
1534    fn consume_dynamic_block_body(
1535        &mut self,
1536        nodes: &[NodeView<'_>],
1537        start_index: usize,
1538        key_indent: usize,
1539    ) -> (Guarded<AbstractFragment>, usize) {
1540        let mut parts: Vec<StringPart> = Vec::new();
1541        let mut consumed = 0;
1542        while let Some(next) = nodes.get(start_index + consumed) {
1543            let node = next.node;
1544            if self.line_indent(node.span_start()) <= key_indent {
1545                break;
1546            }
1547            match node {
1548                Node::Output(action) => {
1549                    for (_, hole_parts) in self.eval_hole_parts(action.span) {
1550                        parts.extend(hole_parts);
1551                    }
1552                }
1553                Node::Scalar(line) => {
1554                    for part in &line.content.parts {
1555                        match part {
1556                            ScalarPart::Text(span) => {
1557                                let text = self.text(*span);
1558                                if !text.is_empty() {
1559                                    parts.push(StringPart::Text(
1560                                        [text.to_string()].into_iter().collect(),
1561                                    ));
1562                                }
1563                            }
1564                            ScalarPart::Hole(span) => {
1565                                for (_, hole_parts) in self.eval_hole_parts(*span) {
1566                                    parts.extend(hole_parts);
1567                                }
1568                            }
1569                        }
1570                    }
1571                }
1572                Node::Opaque(opaque) if opaque.kind == OpaqueKind::ActionLineText => {
1573                    let text = self.text(opaque.span);
1574                    if !text.is_empty() {
1575                        parts.push(StringPart::Text([text.to_string()].into_iter().collect()));
1576                    }
1577                }
1578                _ => break,
1579            }
1580            consumed += 1;
1581        }
1582        let value = Guarded::unconditional(AbstractFragment::Scalar(AbstractString {
1583            parts,
1584            suppressed: true,
1585        }));
1586        (value, consumed)
1587    }
1588
1589    fn same_line(&self, from: usize, to: usize) -> bool {
1590        self.source
1591            .get(from..to)
1592            .is_some_and(|between| !between.contains('\n') && between.trim().is_empty())
1593    }
1594
1595    /// The rendered indent of a templated mapping-entry line (the key
1596    /// hole's explicit `nindent` width when present, else the line indent).
1597    pub(super) fn dynamic_entry_render_indent(&self, span: Span) -> usize {
1598        let line_start = self
1599            .source
1600            .get(..span.start)
1601            .and_then(|prefix| prefix.rfind('\n'))
1602            .map_or(0, |newline| newline + 1);
1603        let line_end = self
1604            .source
1605            .get(span.start..)
1606            .and_then(|rest| rest.find('\n'))
1607            .map_or(self.source.len(), |offset| span.start + offset);
1608        let line = self.source.get(line_start..line_end).unwrap_or("");
1609        for expr in helm_schema_ast::parse_action_expressions(line) {
1610            if let Some(width) = expr.fragment_indent_width() {
1611                return width;
1612            }
1613        }
1614        self.line_indent(span.start)
1615    }
1616
1617    /// The structural indent of one node: containers report their own
1618    /// indent, scalars their line indent, plain outputs their line indent,
1619    /// and control regions the minimum over their branch bodies (a region's
1620    /// rendered content sits at the body indent; the header line is
1621    /// conventionally unindented). Outputs with an explicit rendered indent
1622    /// (`… | nindent N`) report `None`: they float, and the float rules own
1623    /// their placement (line columns are layout noise for them).
1624    pub(super) fn structural_content_indent(&self, node: &Node) -> Option<usize> {
1625        match node {
1626            Node::Mapping(entry) => Some(entry.indent),
1627            Node::Sequence(item) => Some(item.indent),
1628            Node::Scalar(line) => Some(line.indent),
1629            Node::Control(region) => region
1630                .branches
1631                .iter()
1632                .flat_map(|branch| &branch.body)
1633                .filter_map(|child| self.structural_content_indent(child))
1634                .min(),
1635            Node::Output(action) => {
1636                let width = parse_expr_text(self.text(action.span))
1637                    .iter()
1638                    .rev()
1639                    .find_map(TemplateExpr::fragment_indent_width);
1640                match width {
1641                    Some(_) => None,
1642                    None => Some(self.line_indent(action.span.start)),
1643                }
1644            }
1645            Node::Comment(_) | Node::Opaque(_) => None,
1646        }
1647    }
1648
1649    /// The indentation of the line containing `byte`.
1650    pub(super) fn line_indent(&self, byte: usize) -> usize {
1651        let line_start = self
1652            .source
1653            .get(..byte)
1654            .and_then(|prefix| prefix.rfind('\n'))
1655            .map_or(0, |newline| newline + 1);
1656        self.source
1657            .get(line_start..)
1658            .map_or(0, |line| line.len() - line.trim_start_matches(' ').len())
1659    }
1660
1661    #[expect(
1662        clippy::too_many_lines,
1663        reason = "keeping this semantic operation together makes its state transitions easier to audit"
1664    )]
1665    fn eval_node(&mut self, view: NodeView<'_>) -> Contributions {
1666        let mut out = Contributions::default();
1667        match view.node {
1668            Node::Mapping(entry) => {
1669                let previous_site = self.enter_hole_site(entry.key.span);
1670                let key = self.entry_key(&entry.key);
1671                self.push_key_reads(&key);
1672                self.restore_site(previous_site);
1673                let mut value = Guarded::empty();
1674                if let Some(block) = &entry.block {
1675                    value.extend(self.eval_block_scalar(block));
1676                }
1677                if let Some(parts) = &entry.value {
1678                    let evaluated = self.eval_scalar_parts(parts);
1679                    // A sourced value hole that lowered to nothing still
1680                    // occupies the value position: without an arm the entry
1681                    // would read as an OPEN header and adopt a following
1682                    // floated splice as its own value.
1683                    if evaluated.is_empty() {
1684                        value.extend(Guarded::unconditional(AbstractFragment::Opaque(
1685                            Opaque::default(),
1686                        )));
1687                    } else {
1688                        value.extend(evaluated);
1689                    }
1690                }
1691                let (children, siblings) = self.split_structural_children(view, entry.indent);
1692                if !children.is_empty() {
1693                    let mut child = self.eval_node_list(&children);
1694                    let opened_empty = entry.value.is_none() && entry.block.is_none();
1695                    let marked_at = content_child_mark(&entry.children, entry.indent);
1696                    value.extend(child.take_floating_below(entry.indent, opened_empty, marked_at));
1697                    out.floating.append(&mut child.floating);
1698                    out.loop_control.extend(child.take_loop_control());
1699                    value.extend(child.assemble());
1700                }
1701                // A bare output hanging at or above a block-scalar entry's
1702                // indent (`key: |` followed by a column-0 `{{- include … }}`)
1703                // renders into the still-open block whenever its text is
1704                // deeper than the entry, so it contributes block text — not
1705                // structure escaping to the parent container.
1706                let siblings = if entry.block.is_some() {
1707                    let (adopted, rest): (Vec<_>, Vec<_>) = siblings
1708                        .into_iter()
1709                        .partition(|child| matches!(child.node, Node::Output(_)));
1710                    for adopted_view in adopted {
1711                        if let Node::Output(action) = adopted_view.node {
1712                            value.extend(self.eval_block_adopted_output(action.span));
1713                        }
1714                    }
1715                    rest
1716                } else {
1717                    siblings
1718                };
1719                out.merge_entry(key, value);
1720                if !siblings.is_empty() {
1721                    out.extend(self.eval_node_list(&siblings));
1722                }
1723            }
1724            Node::Sequence(item) => {
1725                let mut value = Guarded::empty();
1726                if let Some(block) = &item.block {
1727                    value.extend(self.eval_block_scalar(block));
1728                }
1729                if let Some(parts) = &item.value {
1730                    value.extend(self.eval_scalar_parts(parts));
1731                }
1732                let (children, siblings) = self.split_structural_children(view, item.indent);
1733                if !children.is_empty() {
1734                    let mut child = self.eval_node_list(&children);
1735                    // Items never accept same-indent output (the open-slot
1736                    // query pushes item frames without that allowance).
1737                    value.extend(child.take_floating_below(item.indent, false, None));
1738                    out.floating.append(&mut child.floating);
1739                    out.loop_control.extend(child.take_loop_control());
1740                    value.extend(child.assemble());
1741                }
1742                // `- |` items adopt shallow bare outputs as block text, the
1743                // same as block-scalar mapping entries above.
1744                let siblings = if item.block.is_some() {
1745                    let (adopted, rest): (Vec<_>, Vec<_>) = siblings
1746                        .into_iter()
1747                        .partition(|child| matches!(child.node, Node::Output(_)));
1748                    for adopted_view in adopted {
1749                        if let Node::Output(action) = adopted_view.node {
1750                            value.extend(self.eval_block_adopted_output(action.span));
1751                        }
1752                    }
1753                    rest
1754                } else {
1755                    siblings
1756                };
1757                out.items.push(value);
1758                if !siblings.is_empty() {
1759                    out.extend(self.eval_node_list(&siblings));
1760                }
1761            }
1762            Node::Scalar(line) => {
1763                if line
1764                    .content
1765                    .parts
1766                    .iter()
1767                    .any(|part| matches!(part, ScalarPart::Hole(_)))
1768                {
1769                    let value = self.eval_scalar_parts(&line.content);
1770                    out.values.extend(value);
1771                }
1772            }
1773            Node::Opaque(opaque) if opaque.kind == OpaqueKind::Assignment => {
1774                self.eval_assignment_span(opaque.span);
1775            }
1776            Node::Opaque(opaque) if opaque.kind == OpaqueKind::Break => {
1777                out.loop_control.breaks.push(Predicate::True);
1778            }
1779            Node::Opaque(opaque) if opaque.kind == OpaqueKind::Continue => {
1780                out.loop_control.continues.push(Predicate::True);
1781            }
1782            Node::Control(_) | Node::Output(_) | Node::Comment(_) | Node::Opaque(_) => {}
1783        }
1784        out
1785    }
1786
1787    /// Split a container's in-scope children into real children and nodes
1788    /// the layout recovery hung under the container: YAML containers hold
1789    /// strictly deeper content — except sequence items, which may sit at
1790    /// their parent key's own indent — so anything else at or above the
1791    /// container indent evaluates as a sibling at the container's level (the
1792    /// line model's pop-by-indent rule). Explicitly-indented outputs float;
1793    /// the float rules own their placement.
1794    fn split_structural_children<'n>(
1795        &self,
1796        view: NodeView<'n>,
1797        container_indent: usize,
1798    ) -> (Vec<NodeView<'n>>, Vec<NodeView<'n>>) {
1799        view.in_scope_children()
1800            .into_iter()
1801            .partition(|child| self.node_belongs_inside(child.node, container_indent))
1802    }
1803
1804    fn node_belongs_inside(&self, node: &Node, container_indent: usize) -> bool {
1805        match node {
1806            Node::Mapping(entry) => entry.indent > container_indent,
1807            Node::Sequence(item) => item.indent >= container_indent,
1808            Node::Scalar(line) => line.indent > container_indent,
1809            Node::Control(region) => region
1810                .branches
1811                .iter()
1812                .flat_map(|branch| &branch.body)
1813                .all(|child| self.node_belongs_inside(child, container_indent)),
1814            Node::Output(action) => {
1815                // Deeper lines always belong; the explicit-width probe (a
1816                // re-parse) only runs for the rare same-or-shallower case.
1817                self.line_indent(action.span.start) > container_indent
1818                    || parse_expr_text(self.text(action.span))
1819                        .iter()
1820                        .rev()
1821                        .any(|expr| expr.fragment_indent_width().is_some())
1822            }
1823            Node::Comment(_) | Node::Opaque(_) => true,
1824        }
1825    }
1826
1827    pub(super) fn entry_key(&mut self, parts: &ScalarParts) -> EntryKey {
1828        let has_hole = parts
1829            .parts
1830            .iter()
1831            .any(|part| matches!(part, ScalarPart::Hole(_)));
1832        if !has_hole {
1833            let key = syntax::unquote_yaml_scalar(self.text(parts.span).trim()).to_string();
1834            if !key.is_empty() {
1835                return EntryKey::Literal(key);
1836            }
1837        }
1838        let mut string = AbstractString::default();
1839        for part in &parts.parts {
1840            match part {
1841                ScalarPart::Text(span) => {
1842                    let text = self.text(*span);
1843                    if !text.is_empty() {
1844                        string
1845                            .parts
1846                            .push(StringPart::Text([text.to_string()].into_iter().collect()));
1847                    }
1848                }
1849                ScalarPart::Hole(span) => {
1850                    let hole = self.hole_string(*span);
1851                    string.parts.extend(hole.parts);
1852                }
1853            }
1854        }
1855        EntryKey::Dynamic(string)
1856    }
1857
1858    /// Evaluate a hole into flattened string parts (conditions from
1859    /// alternatives are dropped; used for keys, where alternatives project
1860    /// pathlessly anyway).
1861    fn hole_string(&mut self, span: Span) -> AbstractString {
1862        let arms = self.eval_hole_parts(span);
1863        AbstractString {
1864            parts: arms.into_iter().flat_map(|(_, parts)| parts).collect(),
1865            suppressed: false,
1866        }
1867    }
1868}