helm-schema-ir 0.0.6

Generate an accurate JSON schema for any helm chart
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! Per-path helper render facts shared between the value lattice and the
//! fragment domain: `OutputPath` values, local bindings, and fragment
//! summaries all carry a [`HelperOutputMeta`] per rendered `.Values` path.

use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;

use crate::{ContractProvenance, ValueKind};
use helm_schema_core::{GuardValue, Predicate};

/// A `coalesce` substituted a constant string fallback for a stringified
/// binding's Helm-empty rendering.
///
/// `spellings` are the exact raw values whose final stringification is
/// empty: always the empty string itself, plus any values a preceding
/// `"<nil>" → ""` normalization arm diverted. An equality against exactly
/// the `fallback` literal admits every spelling beside the literal's own
/// `toString` preimage.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct EmptyRescue {
    pub(crate) fallback: String,
    pub(crate) spellings: BTreeSet<GuardValue>,
}

/// One lexical divergence between a raw string input and the transformed
/// value a capture observes.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum LexicalEscape {
    /// The transform is the identity on raw strings NOT containing the
    /// token (`replace TOKEN …`, `(split TOKEN …)._0`): captures exempt
    /// any raw string holding it.
    Contains(String),
    /// `trimPrefix TOKEN` — at most one leading occurrence is stripped
    /// before the capture's check runs, so the accepted raw language is
    /// the capture language optionally prefixed by the token.
    TrimPrefix(String),
    /// `trimSuffix TOKEN` — the suffix mirror of [`Self::TrimPrefix`].
    TrimSuffix(String),
    /// `regexReplaceAll "TOKEN.*$" … ""` — everything from the first TOKEN
    /// to the end of the string is erased before the capture's check runs,
    /// so the accepted raw language is the capture language optionally
    /// followed by a TOKEN-opened tail (cilium's `@digest` strip).
    CutAtToken(String),
}

/// The facts one rendered path carries out of a helper body: the branch
/// conditions under which it renders (one set per branch), whether the
/// render site substitutes a fallback, and the body sites it was derived
/// through.
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct HelperOutputMeta {
    pub(crate) predicates: BTreeSet<BTreeSet<Predicate>>,
    /// The projected value remains the path's runtime value. This keeps
    /// structural member selection exact when guard metadata turns a raw
    /// [`crate::abstract_value::AbstractValue::ValuesPath`] into an output
    /// identity.
    pub(crate) input_identity: bool,
    pub(crate) defaulted: bool,
    /// The binding's value is a total stringification (`quote`, `toString`,
    /// `join`) of this path, so splices rendering it expose no input shape.
    pub(crate) shape_erased: bool,
    /// The stringification is Sprig `quote`/`squote`, which skip nil
    /// operands: a missing or null source renders an explicit YAML null.
    pub(crate) nil_omitted: bool,
    /// The binding's value is the exact Go `%v` rendering of this path
    /// (`toString` over the path identity): an equality on the binding
    /// projects its literal back through the `toString` preimage. A join
    /// with a raw-identity branch keeps the flag — the raw branch's
    /// type-mismatched comparisons abort Helm, so the projected preimage
    /// only widens there.
    pub(crate) stringified: bool,
    /// The binding's value is YAML serialization of this path. Serialization
    /// accepts any input kind, while a sequence placement remains structural.
    pub(crate) yaml_serialized: bool,
    /// The YAML serialization was evaluated by `tpl`: structure survives,
    /// while template-bearing string leaves are transformed before the sink.
    pub(crate) templated_yaml: bool,
    /// The binding's value is derived text of this path (an `include`'s
    /// rendered output, a `printf` result): a consuming transform applied to
    /// the local operates on that text and claims nothing about the path.
    pub(crate) derived_text: bool,
    /// The path rendered as one part of a COMPOSED scalar (literal text
    /// around the splice) in a projected helper value: a structural
    /// re-lowering must keep the partial-text discipline so provider
    /// typing and full-value lexical preimages abstain (traefik's
    /// `--…={{ $value }}` flag items through the pod-template roundtrip).
    /// Unlike [`Self::derived_text`], this never rides ordinary
    /// include-bound locals, whose splices render the picked value's exact
    /// text and stay provider-typable.
    pub(crate) partial_text: bool,
    /// The path supplies the token-opening `%s` of a complete literal
    /// `printf` result. A caller rendering that result in a plain slot must
    /// retain the formatter's selected-string preimage.
    pub(crate) plain_slot_string_format: bool,
    /// The path's value was serialized as JSON at this render boundary.
    pub(crate) json_serialized: bool,
    /// The path's runtime identity came from JSON decoding.
    pub(crate) json_decoded: bool,
    /// The value is the path's map with nil members recursively removed
    /// (airflow's `removeNilFields` scrub): the render never sees a null
    /// member, so sink typing must relax members to admit null spellings,
    /// and the identity's truthiness over-approximates the scrubbed
    /// output's (an all-nil map scrubs to empty).
    pub(crate) nil_scrubbed: bool,
    /// The path contributes only its mapping shape after Helm's map-only
    /// `fromYaml` decode. Non-mapping source shapes produce no merge layer.
    pub(crate) parsed_map: bool,
    /// The value is one layer of an ordered merge whose every layer has a
    /// path identity: rendering the binding renders the MERGE, so the
    /// path's sink typing must scope to the states its layer actually
    /// supplies the rendered members (airflow's merged worker context
    /// flowing through the candidate-selection helper).
    pub(crate) merge_layers: Option<helm_schema_core::MergeLayersUse>,
    pub(crate) provenance: Vec<ContractProvenance>,
    /// Predicate paths this row's derivation explicitly severed (index-call
    /// narrowing): guard reads of their strict ancestors are dropped.
    pub(crate) suppress_predicate_paths: BTreeSet<String>,
    /// Conditions under which this path's RAW value is the consumed operand:
    /// a sibling `if` arm reassigned the binding away (datadog's `latest` →
    /// `1.20.0` sentinel), so strict-operand captures conjoin these
    /// before rejecting raw inputs. Only the capture lanes read them —
    /// guard decoding and row lowering see the joined value choice itself.
    pub(crate) capture_exclusions: BTreeSet<Predicate>,
    /// Lexical divergences between the raw string and this value (see
    /// [`LexicalEscape`]): a `Contains` token exempts raw strings holding
    /// it (traefik's `replace "latest-" ""` sentinel stripping), while a
    /// trim affix projects the capture language through the exact
    /// stripped-affix preimage (datadog's `trimSuffix "-jmx"` tag).
    pub(crate) lexical_escapes: BTreeSet<LexicalEscape>,
    /// Literal member keys an `omit` removed from this map value on some
    /// path to the render, mapped to the sound RETAIN guards under which
    /// the key certainly survives (external-secrets' `OpenShift`
    /// `adaptSecurityContext` omit). Empty guards mean survival is
    /// undecidable: the key's sink typing abstains.
    pub(crate) omitted_keys: std::collections::BTreeMap<String, Vec<crate::Guard>>,
    /// Raw spellings a sibling branch arm diverted to the EMPTY string
    /// before any fallback selection (the `if eq $x "<nil>" { $x = "" }`
    /// normalization idiom): the diverting arm's exact header equality
    /// literals. `None` means no divert was recorded (or an undecodable
    /// one), so a downstream `coalesce` rescue seeing an empty-literal
    /// alternative must abstain.
    pub(crate) empty_fold_spellings: Option<BTreeSet<GuardValue>>,
    /// A downstream `coalesce` substituted a constant fallback while this
    /// stringified binding rendered Helm-empty (cilium's
    /// `coalesce $stringValueKPR "false"`). Consumed by equality decoding;
    /// see [`EmptyRescue`].
    pub(crate) empty_rescue: Option<EmptyRescue>,
    /// The literal fallback a `default` substituted for this path's
    /// Helm-falsy states while producing the binding's value (`$mode :=
    /// .Values.x | default "lit"`). Equality decoding adds the exact
    /// fallback arm: comparing against the literal also holds wherever the
    /// path is falsy, and comparing against anything else certainly fails
    /// there (external-secrets' deleted `renderMode` must select the
    /// default literal's branch, not the invalid-mode `fail`).
    pub(crate) default_fallback: Option<GuardValue>,
}

impl HelperOutputMeta {
    pub(crate) fn is_input_identity(&self) -> bool {
        self.input_identity && !self.defaulted && self.is_structurally_untransformed()
    }

    pub(crate) fn is_input_member_identity(&self) -> bool {
        self.input_identity
            && !self.shape_erased
            && !self.nil_omitted
            && !self.stringified
            && !self.yaml_serialized
            && !self.templated_yaml
            && !self.derived_text
            && !self.partial_text
            && !self.plain_slot_string_format
            && !self.json_serialized
            && !self.json_decoded
            && !self.nil_scrubbed
            && !self.parsed_map
            && self.merge_layers.is_none()
            && self.lexical_escapes.is_empty()
            && self.empty_fold_spellings.is_none()
            && self.empty_rescue.is_none()
    }

    pub(crate) fn is_identity_preserving_default(&self) -> bool {
        self.defaulted && self.is_structurally_untransformed()
    }

    fn is_structurally_untransformed(&self) -> bool {
        !self.shape_erased
            && !self.nil_omitted
            && !self.stringified
            && !self.yaml_serialized
            && !self.templated_yaml
            && !self.derived_text
            && !self.partial_text
            && !self.plain_slot_string_format
            && !self.json_serialized
            && !self.json_decoded
            && !self.nil_scrubbed
            && !self.parsed_map
            && self.merge_layers.is_none()
            && self.omitted_keys.is_empty()
            && self.lexical_escapes.is_empty()
            && self.empty_fold_spellings.is_none()
            && self.empty_rescue.is_none()
    }

    pub(crate) fn merge(&mut self, other: &Self) {
        self.predicates.extend(other.predicates.iter().cloned());
        self.input_identity |= other.input_identity;
        self.defaulted |= other.defaulted;
        self.shape_erased |= other.shape_erased;
        self.nil_omitted |= other.nil_omitted;
        self.stringified |= other.stringified;
        self.yaml_serialized |= other.yaml_serialized;
        self.templated_yaml |= other.templated_yaml;
        self.derived_text |= other.derived_text;
        self.partial_text |= other.partial_text;
        self.plain_slot_string_format |= other.plain_slot_string_format;
        self.json_serialized |= other.json_serialized;
        self.json_decoded |= other.json_decoded;
        // Any scrubbed derivation relaxes: the relaxation only widens
        // member typing, so it is safe when other derivations kept nulls.
        self.nil_scrubbed |= other.nil_scrubbed;
        self.parsed_map |= other.parsed_map;
        // Layer facts must stay EXACT: disagreeing merge positions (or a
        // non-layered sibling derivation) drop to the ordinary row, whose
        // typing is the stricter direction.
        self.merge_layers = merge_exact_fact(self.merge_layers.take(), other.merge_layers.clone());
        merge_provenance_sites(&mut self.provenance, &other.provenance);
        self.suppress_predicate_paths
            .extend(other.suppress_predicate_paths.iter().cloned());
        self.capture_exclusions
            .extend(other.capture_exclusions.iter().cloned());
        self.lexical_escapes
            .extend(other.lexical_escapes.iter().cloned());
        for (key, retain_guards) in &other.omitted_keys {
            // Conflicting retain guards for one key collapse to abstention:
            // typing may only bind where the key CERTAINLY survives.
            self.omitted_keys
                .entry(key.clone())
                .and_modify(|existing| {
                    if existing != retain_guards {
                        existing.clear();
                    }
                })
                .or_insert_with(|| retain_guards.clone());
        }
        // The empty-rescue facts must stay EXACT: a one-sided fact survives
        // (the fresh `or_default()` entry every meta transfer merges into
        // carries none), but disagreeing recorded facts drop to abstention
        // instead of unioning into a claim neither side made.
        self.empty_fold_spellings = merge_exact_fact(
            self.empty_fold_spellings.take(),
            other.empty_fold_spellings.clone(),
        );
        self.empty_rescue = merge_exact_fact(self.empty_rescue.take(), other.empty_rescue.clone());
        self.default_fallback =
            merge_exact_fact(self.default_fallback.take(), other.default_fallback.clone());
    }

    pub(crate) fn suppress_predicate_path(&mut self, path: impl Into<String>) {
        let path = path.into();
        if !path.is_empty() {
            self.suppress_predicate_paths.insert(path);
        }
    }

    /// Conjoin `predicates` onto every recorded branch (one fresh branch when
    /// none are recorded yet).
    pub(crate) fn conjoin_branches(&mut self, predicates: &BTreeSet<Predicate>) {
        if predicates.is_empty() {
            return;
        }
        if self.predicates.is_empty() {
            self.predicates.insert(predicates.clone());
            return;
        }
        self.predicates = std::mem::take(&mut self.predicates)
            .into_iter()
            .map(|mut branch| {
                branch.extend(predicates.iter().cloned());
                branch
            })
            .collect();
    }
}

/// Merges one optional exact fact: agreement (or one-sidedness) keeps the
/// fact, disagreement drops it.
fn merge_exact_fact<T: PartialEq>(left: Option<T>, right: Option<T>) -> Option<T> {
    match (left, right) {
        (Some(left), Some(right)) => (left == right).then_some(left),
        (left, right) => left.or(right),
    }
}

/// One rendered claim of a helper call flattened from its summary fragment:
/// the path, its render kind, encoding, and per-path meta. Call sites use
/// these for no-render demotion (assignments and conditions read the paths
/// without rendering them) and for restoring per-path branch meta when
/// transfer functions collapse the value shape.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct RenderedRow {
    pub(crate) path: String,
    pub(crate) kind: ValueKind,
    pub(crate) encoded: bool,
    pub(crate) meta: HelperOutputMeta,
}

/// Merges the meta of every rendered row into a per-source meta map (the
/// shape local bindings carry).
pub(crate) fn merge_rendered_row_meta(
    output_meta: &mut BTreeMap<String, HelperOutputMeta>,
    rows: &[RenderedRow],
) {
    for row in rows {
        output_meta
            .entry(row.path.clone())
            .or_default()
            .merge(&row.meta);
    }
}

/// Appends `extra` provenance sites onto `target`, preserving first-seen
/// order and skipping sites already present. Every provenance merge in the
/// contract pipeline uses this discipline so emitted site lists stay
/// deterministic.
pub(crate) fn merge_provenance_sites(
    target: &mut Vec<ContractProvenance>,
    extra: &[ContractProvenance],
) {
    for site in extra {
        if !target.contains(site) {
            target.push(site.clone());
        }
    }
}

/// Whether two values paths describe related data: same top-level root, or
/// one is an ancestor of the other.
pub(crate) fn values_paths_are_related(left: &str, right: &str) -> bool {
    let left_root = helm_schema_core::split_value_path(left).into_iter().next();
    let right_root = helm_schema_core::split_value_path(right).into_iter().next();
    left_root == right_root
        || helm_schema_core::values_path_is_descendant(left, right)
        || helm_schema_core::values_path_is_descendant(right, left)
}

pub(crate) fn insert_type_hint(
    hints: &mut BTreeMap<String, BTreeSet<String>>,
    path: String,
    schema_type: &str,
) {
    if path.trim().is_empty() {
        return;
    }
    hints
        .entry(path)
        .or_default()
        .insert(schema_type.to_string());
}

/// Projects a lexical capture pattern through its escapes.
///
/// Edge escapes compose: each wraps its own side of an anchored pattern
/// (`^P$` becomes `^(?:P)(?:-jmx)?$` for `trimSuffix "-jmx"`, `^(?:v)?(?:P)$`
/// for `trimPrefix "v"`, `^(?:P)(?:@.*)?$` for a `CutAtToken("@")` erasure),
/// so a set with at most one escape per position — leading affix, trailing
/// affix, cut tail — composes in ANY application order into a superset of
/// the ordered preimage (a capture-valid string that itself wears an affix
/// is transformed first, so admitting it is a bounded widening, never a
/// rejection). `Contains` tokens — and any escape mix beyond one per edge —
/// weaken to exemption alternatives: a raw string containing the token
/// diverged from the observed value before the check ran, so the capture
/// accepts it unconditionally (JSON Schema `pattern` is an unanchored
/// search, so a bare token alternative matches any string containing it).
pub(crate) fn pattern_with_lexical_escapes(
    pattern: &str,
    escapes: &BTreeSet<LexicalEscape>,
) -> String {
    if escapes.is_empty() {
        return pattern.to_string();
    }
    if let Some(composed) = composed_edge_escape_pattern(pattern, escapes) {
        return composed;
    }
    let mut alternatives: Vec<String> = escapes
        .iter()
        .map(|escape| match escape {
            LexicalEscape::Contains(token)
            | LexicalEscape::TrimPrefix(token)
            | LexicalEscape::TrimSuffix(token)
            | LexicalEscape::CutAtToken(token) => crate::escape_regex_literal(token),
        })
        .collect();
    alternatives.push(format!("(?:{pattern})"));
    alternatives.join("|")
}

/// The exact edge composition of `pattern_with_lexical_escapes`, or `None`
/// when the escape set holds a `Contains` token, more than one escape per
/// edge position, or the pattern is not anchored on both sides.
fn composed_edge_escape_pattern(
    pattern: &str,
    escapes: &BTreeSet<LexicalEscape>,
) -> Option<String> {
    let anchored = pattern
        .strip_prefix('^')
        .and_then(|p| p.strip_suffix('$'))?;
    let mut prefix = None;
    let mut suffix = None;
    let mut cut = None;
    for escape in escapes {
        let slot = match escape {
            LexicalEscape::Contains(_) => return None,
            LexicalEscape::TrimPrefix(token) => (&mut prefix, token),
            LexicalEscape::TrimSuffix(token) => (&mut suffix, token),
            LexicalEscape::CutAtToken(token) => (&mut cut, token),
        };
        if slot.0.replace(slot.1).is_some() {
            return None;
        }
    }
    let mut composed = String::from("^");
    if let Some(token) = prefix {
        let _ = write!(composed, "(?:{})?", crate::escape_regex_literal(token));
    }
    let _ = write!(composed, "(?:{anchored})");
    if let Some(token) = suffix {
        let _ = write!(composed, "(?:{})?", crate::escape_regex_literal(token));
    }
    if let Some(token) = cut {
        let _ = write!(composed, "(?:{}.*)?", crate::escape_regex_literal(token));
    }
    composed.push('$');
    Some(composed)
}