Skip to main content

helm_schema_ir/contract/
graph.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::contract::FinalizedContract;
4use crate::contract_normalization::{
5    canonicalize_contract_uses, drop_default_guard_subsumed_duplicates,
6    drop_self_truthy_subsumed_duplicates, normalize_contract_uses,
7};
8use crate::{ContractUse, Guard, ValueKind, YamlPath};
9
10/// Opaque guarded contract graph for one template interpretation.
11///
12/// Accumulation, path rebasing, and normalization live behind this
13/// contract-layer artifact instead of a raw vector owned by callers.
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct ContractIr {
16    uses: Vec<ContractUse>,
17    dependency_uses: Vec<ContractUse>,
18    type_hints: BTreeMap<String, BTreeSet<String>>,
19    /// Input-type hints observed only under branch predicates: they hold
20    /// where those branches render, so they type conditional overlays but
21    /// never the unconditional base.
22    guarded_type_hints: BTreeMap<String, BTreeSet<String>>,
23    /// Input-type hints from literal `default`/`coalesce` fallbacks: they
24    /// type only the truthy arm of the path, so lowering must keep the
25    /// whole Helm-falsy set open beside them.
26    fallback_type_hints: BTreeMap<String, BTreeSet<String>>,
27    /// Fallback hints observed under branch predicates: fallback-grade
28    /// intent that may type conditional overlays, but never a branch whose
29    /// renders all totally format.
30    guarded_fallback_type_hints: BTreeMap<String, BTreeSet<String>>,
31    /// Paths consumed through total stringifications (`quote`, `toString`,
32    /// `join`, `printf`) anywhere in the interpretation: the chart tolerates
33    /// any input type at them even when no placed row exists.
34    shape_erased_value_paths: BTreeSet<String>,
35    /// Paths carrying a real runtime string contract (`trunc`, `b64enc`,
36    /// `fromYaml`, a dynamic `printf` format) anywhere.
37    string_contract_value_paths: BTreeSet<String>,
38    /// The chart's per-path range facts (direct iteration, JSON-decoded
39    /// values, key/value destructuring).
40    range_modes: crate::range_modes::RangeModes,
41    /// Chart value subtrees supplying defaults to effective values subtrees.
42    values_default_sources: BTreeSet<crate::ValuesDefaultSource>,
43    /// Values subtrees merged in place over the values root; root contracts
44    /// project back onto the prefixed spellings.
45    values_root_overlay_prefixes: BTreeSet<String>,
46    values_program_wrappers: BTreeSet<helm_schema_core::ValuesProgramWrapper>,
47    /// Values paths whose nodes must NOT gain a wrapper alternative: a
48    /// strict string consumer reads them BEFORE the engine's values-root
49    /// rewrite, so a wrapper map there aborts rendering (nats'
50    /// `nameOverride` through `fullname | trunc`).
51    values_program_wrapper_exclusions: BTreeSet<String>,
52    /// `fail` captures: no valid values document may satisfy one of these
53    /// conjunctions.
54    fail_conditions: Vec<crate::eval_effect::FailCapture>,
55    dependency_values_root_fragments: BTreeSet<String>,
56}
57
58impl ContractIr {
59    /// Build a contract graph from already-structured contract claims.
60    ///
61    /// This is the contract-layer constructor for tests and expert callers
62    /// that already have semantic claims. Schema signals are still derived
63    /// through [`ContractIr::finalize`], so semantic finalization stays on
64    /// the contract graph rather than a serialized document.
65    #[must_use]
66    pub fn from_contract_uses(uses: Vec<ContractUse>) -> Self {
67        Self {
68            uses,
69            ..Self::default()
70        }
71    }
72
73    pub(crate) fn push(&mut self, contract_use: ContractUse) {
74        self.uses.push(contract_use);
75    }
76
77    pub(crate) fn push_dependency_use(&mut self, contract_use: ContractUse) {
78        self.dependency_uses.push(contract_use);
79    }
80
81    /// Add a pathless scalar claim for a value path.
82    ///
83    /// Pathless claims make a value path visible to downstream schema
84    /// generation without asserting any rendered Kubernetes field shape.
85    pub fn push_pathless_scalar(&mut self, source_expr: impl Into<String>) {
86        self.push(ContractUse::new(
87            source_expr.into(),
88            YamlPath(Vec::new()),
89            ValueKind::Scalar,
90            Vec::new(),
91            None,
92        ));
93    }
94
95    /// Records a pathless fragment accepted at a dependency values root.
96    pub fn push_pathless_dependency_fragment(&mut self, source_expr: impl Into<String>) {
97        self.dependency_values_root_fragments
98            .insert(source_expr.into());
99    }
100
101    /// Move all claims from another contract graph into this graph.
102    pub fn append(&mut self, mut other: Self) {
103        self.uses.append(&mut other.uses);
104        self.dependency_uses.append(&mut other.dependency_uses);
105        self.dependency_values_root_fragments
106            .append(&mut other.dependency_values_root_fragments);
107        for (path, schema_types) in other.type_hints {
108            self.type_hints
109                .entry(path)
110                .or_default()
111                .extend(schema_types);
112        }
113        for (path, schema_types) in other.guarded_type_hints {
114            self.guarded_type_hints
115                .entry(path)
116                .or_default()
117                .extend(schema_types);
118        }
119        for (path, schema_types) in other.fallback_type_hints {
120            self.fallback_type_hints
121                .entry(path)
122                .or_default()
123                .extend(schema_types);
124        }
125        for (path, schema_types) in other.guarded_fallback_type_hints {
126            self.guarded_fallback_type_hints
127                .entry(path)
128                .or_default()
129                .extend(schema_types);
130        }
131        self.shape_erased_value_paths
132            .append(&mut other.shape_erased_value_paths);
133        self.string_contract_value_paths
134            .append(&mut other.string_contract_value_paths);
135        self.range_modes.merge(&other.range_modes);
136        self.values_default_sources
137            .append(&mut other.values_default_sources);
138        self.values_root_overlay_prefixes
139            .append(&mut other.values_root_overlay_prefixes);
140        self.values_program_wrappers
141            .append(&mut other.values_program_wrappers);
142        self.values_program_wrapper_exclusions
143            .append(&mut other.values_program_wrapper_exclusions);
144        for condition in std::mem::take(&mut other.fail_conditions) {
145            if !self.fail_conditions.contains(&condition) {
146                self.fail_conditions.push(condition);
147            }
148        }
149    }
150
151    /// Append guards to every claim in the graph without rewriting any paths.
152    ///
153    /// This is used for chart-structural activation predicates that apply to
154    /// an already-scoped batch of claims, such as dependency `condition:` /
155    /// `tags:` liveness from `Chart.yaml`.
156    /// Record that rendering FAILS whenever `condition` holds — an
157    /// unconditionally reached `include` whose helper only an inactive
158    /// optional dependency defines aborts with "no template". The predicate
159    /// lowers through the standard terminal-clause machinery.
160    pub fn add_terminal_fail_condition(&mut self, condition: helm_schema_core::Predicate) {
161        let capture = crate::eval_effect::FailCapture {
162            conjunction: vec![condition],
163            ranged: crate::range_modes::RangeModes::default(),
164            kind: crate::eval_effect::CaptureKind::Fail,
165        };
166        if !self.fail_conditions.contains(&capture) {
167            self.fail_conditions.push(capture);
168        }
169    }
170
171    /// Conjoins activation guards onto every use and terminating failure.
172    pub fn append_guards_to_all_uses(&mut self, guards: &[Guard]) {
173        for contract_use in self.uses.iter_mut().chain(&mut self.dependency_uses) {
174            contract_use.condition = contract_use
175                .condition
176                .conjoined_with_guards(guards.iter().cloned());
177        }
178        // Fail captures are claims too: a `fail` inside a dependency gated
179        // off by `condition:` / `tags:` cannot abort rendering, so its
180        // conjunction must carry the activation predicate like every row.
181        for capture in &mut self.fail_conditions {
182            capture.conjunction.splice(
183                0..0,
184                guards
185                    .iter()
186                    .cloned()
187                    .map(helm_schema_core::Predicate::from),
188            );
189        }
190        // A conditionally active chart cannot contribute unconditional
191        // effective defaults. Conditional default overlays are not yet part
192        // of the schema-signal vocabulary, so abstain instead of leaking them.
193        if !guards.is_empty() {
194            self.values_default_sources.clear();
195            self.values_root_overlay_prefixes.clear();
196            // A path-wide runtime string contract is unconditional only
197            // within its own chart's rendering: under activation guards the
198            // consumer may never run, so the fact must not type the base.
199            self.string_contract_value_paths.clear();
200        }
201    }
202
203    /// Mark rendered claims as textual output rather than structured YAML
204    /// placements. Runtime operand contracts and terminal effects remain
205    /// unchanged.
206    pub fn mark_rendered_output_textual(&mut self) {
207        for contract_use in self.uses.iter_mut().chain(&mut self.dependency_uses) {
208            contract_use.kind = ValueKind::Serialized;
209        }
210    }
211
212    /// Rewrite all referenced values paths while preserving rendered YAML paths.
213    ///
214    /// This is used at chart boundaries where a dependency's `.Values.foo`
215    /// contract becomes `.Values.subchart.foo`, while rendered manifest paths
216    /// such as `metadata.name` stay unchanged.
217    pub fn map_value_paths<F>(&mut self, mut map: F)
218    where
219        F: FnMut(&str) -> String,
220    {
221        for contract_use in self.uses.iter_mut().chain(&mut self.dependency_uses) {
222            contract_use.map_value_paths(&mut map);
223        }
224        self.dependency_values_root_fragments =
225            std::mem::take(&mut self.dependency_values_root_fragments)
226                .into_iter()
227                .map(|path| map(&path))
228                .collect();
229        let mut type_hints: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
230        for (path, schema_types) in std::mem::take(&mut self.type_hints) {
231            type_hints
232                .entry(map(&path))
233                .or_default()
234                .extend(schema_types);
235        }
236        self.type_hints = type_hints;
237        let mut guarded_type_hints: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
238        for (path, schema_types) in std::mem::take(&mut self.guarded_type_hints) {
239            guarded_type_hints
240                .entry(map(&path))
241                .or_default()
242                .extend(schema_types);
243        }
244        self.guarded_type_hints = guarded_type_hints;
245        let mut fallback_type_hints: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
246        for (path, schema_types) in std::mem::take(&mut self.fallback_type_hints) {
247            fallback_type_hints
248                .entry(map(&path))
249                .or_default()
250                .extend(schema_types);
251        }
252        self.fallback_type_hints = fallback_type_hints;
253        let mut guarded_fallback_type_hints: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
254        for (path, schema_types) in std::mem::take(&mut self.guarded_fallback_type_hints) {
255            guarded_fallback_type_hints
256                .entry(map(&path))
257                .or_default()
258                .extend(schema_types);
259        }
260        self.guarded_fallback_type_hints = guarded_fallback_type_hints;
261        self.shape_erased_value_paths = std::mem::take(&mut self.shape_erased_value_paths)
262            .into_iter()
263            .map(|path| map(&path))
264            .collect();
265        self.string_contract_value_paths = std::mem::take(&mut self.string_contract_value_paths)
266            .into_iter()
267            .map(|path| map(&path))
268            .collect();
269        self.range_modes.map_value_paths(&mut map);
270        self.values_default_sources = std::mem::take(&mut self.values_default_sources)
271            .into_iter()
272            .map(|source| crate::ValuesDefaultSource {
273                target_path: map(&source.target_path),
274                source_path: map(&source.source_path),
275            })
276            .collect();
277        self.values_root_overlay_prefixes = std::mem::take(&mut self.values_root_overlay_prefixes)
278            .into_iter()
279            .map(|path| map(&path))
280            .collect();
281        self.values_program_wrappers = std::mem::take(&mut self.values_program_wrappers)
282            .into_iter()
283            .map(|wrapper| helm_schema_core::ValuesProgramWrapper {
284                scope_path: map(&wrapper.scope_path),
285                key: wrapper.key,
286                spread: wrapper.spread,
287            })
288            .collect();
289        self.values_program_wrapper_exclusions =
290            std::mem::take(&mut self.values_program_wrapper_exclusions)
291                .into_iter()
292                .map(|path| map(&path))
293                .collect();
294        self.fail_conditions = std::mem::take(&mut self.fail_conditions)
295            .into_iter()
296            .map(|mut capture| {
297                capture.conjunction = capture
298                    .conjunction
299                    .into_iter()
300                    .map(|predicate| predicate.map_value_paths(&mut map))
301                    .collect();
302                capture.ranged.map_value_paths(&mut map);
303                capture.kind.map_value_paths(&mut map);
304                capture
305            })
306            .collect();
307    }
308
309    /// Add declared input-type hints for values paths without projecting them
310    /// as inspection rows.
311    pub fn add_type_hint(&mut self, path: impl Into<String>, schema_type: impl Into<String>) {
312        let path = path.into();
313        let schema_type = schema_type.into();
314        if path.trim().is_empty() || schema_type.trim().is_empty() {
315            return;
316        }
317        self.type_hints.entry(path).or_default().insert(schema_type);
318    }
319
320    /// Extend the graph with already-grouped path type hints.
321    pub(crate) fn extend_type_hints(
322        &mut self,
323        type_hints: impl IntoIterator<Item = (String, BTreeSet<String>)>,
324    ) {
325        for (path, schema_types) in type_hints {
326            if path.trim().is_empty() {
327                continue;
328            }
329            let schema_types = schema_types
330                .into_iter()
331                .filter(|schema_type| !schema_type.trim().is_empty())
332                .collect::<BTreeSet<_>>();
333            if schema_types.is_empty() {
334                continue;
335            }
336            self.type_hints
337                .entry(path)
338                .or_default()
339                .extend(schema_types);
340        }
341    }
342
343    pub(crate) fn extend_fallback_type_hints(
344        &mut self,
345        type_hints: impl IntoIterator<Item = (String, BTreeSet<String>)>,
346    ) {
347        for (path, schema_types) in type_hints {
348            if path.trim().is_empty() {
349                continue;
350            }
351            let schema_types = schema_types
352                .into_iter()
353                .filter(|schema_type| !schema_type.trim().is_empty())
354                .collect::<BTreeSet<_>>();
355            if schema_types.is_empty() {
356                continue;
357            }
358            self.fallback_type_hints
359                .entry(path)
360                .or_default()
361                .extend(schema_types);
362        }
363    }
364
365    pub(crate) fn extend_guarded_fallback_type_hints(
366        &mut self,
367        type_hints: impl IntoIterator<Item = (String, BTreeSet<String>)>,
368    ) {
369        for (path, schema_types) in type_hints {
370            if path.trim().is_empty() {
371                continue;
372            }
373            let schema_types = schema_types
374                .into_iter()
375                .filter(|schema_type| !schema_type.trim().is_empty())
376                .collect::<BTreeSet<_>>();
377            if schema_types.is_empty() {
378                continue;
379            }
380            self.guarded_fallback_type_hints
381                .entry(path)
382                .or_default()
383                .extend(schema_types);
384        }
385    }
386
387    pub(crate) fn extend_guarded_type_hints(
388        &mut self,
389        type_hints: impl IntoIterator<Item = (String, BTreeSet<String>)>,
390    ) {
391        for (path, schema_types) in type_hints {
392            if path.trim().is_empty() {
393                continue;
394            }
395            let schema_types = schema_types
396                .into_iter()
397                .filter(|schema_type| !schema_type.trim().is_empty())
398                .collect::<BTreeSet<_>>();
399            if schema_types.is_empty() {
400                continue;
401            }
402            self.guarded_type_hints
403                .entry(path)
404                .or_default()
405                .extend(schema_types);
406        }
407    }
408
409    pub(crate) fn extend_shape_erased_value_paths(
410        &mut self,
411        paths: impl IntoIterator<Item = String>,
412    ) {
413        self.shape_erased_value_paths
414            .extend(paths.into_iter().filter(|path| !path.trim().is_empty()));
415    }
416
417    pub(crate) fn extend_string_contract_value_paths(
418        &mut self,
419        paths: impl IntoIterator<Item = String>,
420    ) {
421        self.string_contract_value_paths
422            .extend(paths.into_iter().filter(|path| !path.trim().is_empty()));
423    }
424
425    pub(crate) fn merge_range_modes(&mut self, range_modes: &crate::range_modes::RangeModes) {
426        self.range_modes.merge(range_modes);
427    }
428
429    pub(crate) fn extend_values_default_sources(
430        &mut self,
431        sources: impl IntoIterator<Item = crate::ValuesDefaultSource>,
432    ) {
433        self.values_default_sources.extend(sources);
434    }
435
436    pub(crate) fn extend_values_root_overlay_prefixes(
437        &mut self,
438        prefixes: impl IntoIterator<Item = String>,
439    ) {
440        self.values_root_overlay_prefixes.extend(prefixes);
441    }
442
443    pub(crate) fn extend_values_program_wrappers(
444        &mut self,
445        wrappers: impl IntoIterator<Item = helm_schema_core::ValuesProgramWrapper>,
446    ) {
447        self.values_program_wrappers.extend(wrappers);
448    }
449
450    pub(crate) fn extend_values_program_wrapper_exclusions(
451        &mut self,
452        paths: impl IntoIterator<Item = String>,
453    ) {
454        self.values_program_wrapper_exclusions.extend(paths);
455    }
456
457    /// Drop evidence recorded AT a program-wrapper sentinel key: within a
458    /// wrapper-engine chart a `$tplYaml`-keyed member is the engine's own
459    /// dispatch convention — probed by the recursive walker, replaced
460    /// before ordinary consumers read the tree — never an ordinary chart
461    /// value, so reads and fail predicates over such paths must not mint
462    /// values properties. The wrapper alternatives model those nodes.
463    pub(crate) fn scrub_program_wrapper_sentinel_evidence(&mut self) {
464        let keys: std::collections::BTreeSet<String> = self
465            .values_program_wrappers
466            .iter()
467            .map(|wrapper| wrapper.key.clone())
468            .collect();
469        if keys.is_empty() {
470            return;
471        }
472        let touches = |path: &str| {
473            helm_schema_core::split_value_path(path)
474                .iter()
475                .any(|segment| keys.contains(segment))
476        };
477        self.uses
478            .retain(|contract_use| !touches(&contract_use.source_expr));
479        self.dependency_uses
480            .retain(|contract_use| !touches(&contract_use.source_expr));
481        self.fail_conditions.retain(|capture| {
482            let mut paths: Vec<String> = capture
483                .conjunction
484                .iter()
485                .flat_map(helm_schema_core::Predicate::value_paths)
486                .collect();
487            let mut kind = capture.kind.clone();
488            kind.map_value_paths(&mut |path: &str| {
489                paths.push(path.to_string());
490                path.to_string()
491            });
492            !paths.iter().any(|path| touches(path))
493        });
494    }
495
496    pub(crate) fn extend_fail_conditions(
497        &mut self,
498        conditions: impl IntoIterator<Item = crate::eval_effect::FailCapture>,
499    ) {
500        for conjunction in conditions {
501            if !self.fail_conditions.contains(&conjunction) {
502                self.fail_conditions.push(conjunction);
503            }
504        }
505    }
506
507    /// Finalize the contract once and derive downstream artifacts from that
508    /// one normalized contract representation.
509    #[must_use]
510    #[tracing::instrument(skip_all)]
511    pub fn finalize(mut self) -> FinalizedContract {
512        self.scrub_program_wrapper_sentinel_evidence();
513        let Self {
514            mut uses,
515            mut dependency_uses,
516            type_hints,
517            guarded_type_hints,
518            fallback_type_hints,
519            guarded_fallback_type_hints,
520            shape_erased_value_paths,
521            string_contract_value_paths,
522            range_modes,
523            values_default_sources,
524            values_root_overlay_prefixes,
525            values_program_wrappers,
526            values_program_wrapper_exclusions,
527            mut fail_conditions,
528            dependency_values_root_fragments,
529        } = self;
530        for source_expr in &dependency_values_root_fragments {
531            dependency_uses.push(ContractUse::new(
532                source_expr.clone(),
533                YamlPath(Vec::new()),
534                ValueKind::Fragment,
535                Vec::new(),
536                None,
537            ));
538        }
539        normalize_contract_uses(&mut uses);
540        drop_self_truthy_subsumed_duplicates(&mut dependency_uses);
541        canonicalize_contract_uses(&mut dependency_uses);
542        uses.append(&mut dependency_uses);
543        drop_default_guard_subsumed_duplicates(&mut uses);
544        drop_self_truthy_subsumed_duplicates(&mut uses);
545        canonicalize_contract_uses(&mut uses);
546        fail_conditions.sort();
547        fail_conditions.dedup();
548        FinalizedContract::new(
549            uses,
550            &type_hints,
551            &guarded_type_hints,
552            &fallback_type_hints,
553            &guarded_fallback_type_hints,
554            &shape_erased_value_paths,
555            &string_contract_value_paths,
556            &range_modes,
557            values_default_sources,
558            values_root_overlay_prefixes,
559            values_program_wrappers,
560            values_program_wrapper_exclusions,
561            &fail_conditions,
562            &dependency_values_root_fragments,
563        )
564    }
565}