Skip to main content

helm_schema_core/
contract_signals.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{GuardValue, ProviderSchemaUse};
4
5/// Values-decidable guard expression that can be lowered into JSON Schema
6/// conditionals.
7#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub enum ConditionalGuard {
9    /// The value at `path` is Helm-truthy.
10    Truthy {
11        /// Values path tested for truthiness.
12        path: String,
13    },
14    /// A `with` action selected the non-empty value at `path`.
15    With {
16        /// Values path selected by the action.
17        path: String,
18    },
19    /// The value at `path` equals a literal.
20    Eq {
21        /// Values path compared with the literal.
22        path: String,
23        /// Literal required at the path.
24        value: GuardValue,
25    },
26    /// The value at `path` differs from a literal.
27    NotEq {
28        /// Values path compared with the literal.
29        path: String,
30        /// Literal excluded at the path.
31        value: GuardValue,
32    },
33    /// The value at `path` is absent.
34    Absent {
35        /// Values path whose absence selects the branch.
36        path: String,
37    },
38    /// The value at `path` has a specific JSON Schema type.
39    TypeIs {
40        /// Values path subjected to the type test.
41        path: String,
42        /// JSON Schema type name accepted by the branch.
43        schema_type: String,
44    },
45    /// The string at `path` matches a regular expression.
46    MatchesPattern {
47        /// Values path subjected to the pattern test.
48        path: String,
49        /// ECMA-compatible regular expression required by the branch.
50        pattern: String,
51    },
52    /// The path's RAW value is a JSON integer strictly greater than `bound`
53    /// — a sound SUBSET of the Sprig coercion (`gt (int64 x) bound`) it
54    /// stands in for, valid only where firing less often is safe.
55    IntGt {
56        /// Values path subjected to the integer comparison.
57        path: String,
58        /// Exclusive lower bound.
59        bound: i64,
60    },
61    /// The mirror of [`ConditionalGuard::IntGt`]: the path's RAW value is a
62    /// JSON integer strictly less than `bound`, under the same sound-subset
63    /// contract.
64    IntLt {
65        /// Values path subjected to the integer comparison.
66        path: String,
67        /// Exclusive upper bound.
68        bound: i64,
69    },
70    /// The mapping at `path` contains the literal member `key`. The key is
71    /// an OPAQUE property name (it may contain dots), so it rides beside
72    /// the segmented path instead of being appended to it.
73    HasKey {
74        /// Values path expected to hold a mapping.
75        path: String,
76        /// Literal mapping key whose presence selects the branch.
77        key: String,
78    },
79    /// SOME iterated item of the collection at `path` has `member` equal to
80    /// `value` — the document-level meaning of a range-sentinel flag
81    /// (`Range(path) ∧ Eq(path.*.member, value)`). Lowers to `contains`
82    /// over the array lane and the double-negated member quantifier over
83    /// the object lane.
84    ContainsMemberEquals {
85        /// Values path expected to hold the iterated collection.
86        path: String,
87        /// Member name compared within each collection item.
88        member: String,
89        /// Literal that at least one member must equal.
90        value: GuardValue,
91    },
92    /// SOME iterated item of the collection at `path` has a Helm-truthy
93    /// `member` — the document-level meaning of a Boolean range sentinel
94    /// (`Range(path) ∧ Truthy(path.*.member)`).
95    ContainsTruthyMember {
96        /// Values path expected to hold the iterated collection.
97        path: String,
98        /// Member whose truthiness selects the sentinel state.
99        member: String,
100    },
101    /// SOME item of the list at `path` deep-equals the scalar literal —
102    /// Sprig `has LITERAL .Values.list`. `has` returns false on a nil
103    /// haystack and aborts on non-lists, so the guard holds exactly for
104    /// arrays carrying the literal; lowers to `contains` with a `const`
105    /// item.
106    ContainsEquals {
107        /// Values path expected to hold the list.
108        path: String,
109        /// Literal that at least one list item must equal.
110        value: GuardValue,
111    },
112    /// The collection at `path` has at most one entry — the document-level
113    /// form of "every iteration of this range is the first" (an
114    /// empty-initialized dedup accumulator cannot have shadowed anything).
115    /// A sound subset: it may only scope positive-polarity evidence.
116    AtMostOneMember {
117        /// Values path expected to hold the bounded collection.
118        path: String,
119    },
120    /// The value at `path` is a mapping with at least `bound` members
121    /// (`gt (keys X | len) N`). Exact: both polarities encode.
122    MinMembers {
123        /// Values path expected to hold the mapping.
124        path: String,
125        /// Inclusive minimum number of members.
126        bound: i64,
127    },
128    /// Logical negation of a guard.
129    Not(Box<ConditionalGuard>),
130    /// Conjunction of every enclosed guard.
131    AllOf(Vec<ConditionalGuard>),
132    /// Disjunction of the enclosed guards.
133    AnyOf(Vec<ConditionalGuard>),
134}
135
136impl ConditionalGuard {
137    /// Returns every values path referenced by this guard tree.
138    #[must_use]
139    pub fn value_paths(&self) -> BTreeSet<String> {
140        let mut paths = BTreeSet::new();
141        self.collect_value_paths(&mut paths);
142        paths
143    }
144
145    /// Rewrite the values paths carried by this guard (and every nested
146    /// guard).
147    #[must_use]
148    pub fn map_value_paths<F>(self, map: &mut F) -> Self
149    where
150        F: FnMut(&str) -> String,
151    {
152        match self {
153            Self::Truthy { path } => Self::Truthy { path: map(&path) },
154            Self::With { path } => Self::With { path: map(&path) },
155            Self::Eq { path, value } => Self::Eq {
156                path: map(&path),
157                value,
158            },
159            Self::NotEq { path, value } => Self::NotEq {
160                path: map(&path),
161                value,
162            },
163            Self::Absent { path } => Self::Absent { path: map(&path) },
164            Self::TypeIs { path, schema_type } => Self::TypeIs {
165                path: map(&path),
166                schema_type,
167            },
168            Self::MatchesPattern { path, pattern } => Self::MatchesPattern {
169                path: map(&path),
170                pattern,
171            },
172            Self::IntGt { path, bound } => Self::IntGt {
173                path: map(&path),
174                bound,
175            },
176            Self::IntLt { path, bound } => Self::IntLt {
177                path: map(&path),
178                bound,
179            },
180            Self::HasKey { path, key } => Self::HasKey {
181                path: map(&path),
182                key,
183            },
184            Self::ContainsMemberEquals {
185                path,
186                member,
187                value,
188            } => Self::ContainsMemberEquals {
189                path: map(&path),
190                member,
191                value,
192            },
193            Self::ContainsTruthyMember { path, member } => Self::ContainsTruthyMember {
194                path: map(&path),
195                member,
196            },
197            Self::ContainsEquals { path, value } => Self::ContainsEquals {
198                path: map(&path),
199                value,
200            },
201            Self::AtMostOneMember { path } => Self::AtMostOneMember { path: map(&path) },
202            Self::MinMembers { path, bound } => Self::MinMembers {
203                path: map(&path),
204                bound,
205            },
206            Self::Not(inner) => Self::Not(Box::new(inner.map_value_paths(map))),
207            Self::AllOf(guards) => Self::AllOf(
208                guards
209                    .into_iter()
210                    .map(|guard| guard.map_value_paths(map))
211                    .collect(),
212            ),
213            Self::AnyOf(guards) => Self::AnyOf(
214                guards
215                    .into_iter()
216                    .map(|guard| guard.map_value_paths(map))
217                    .collect(),
218            ),
219        }
220    }
221
222    fn collect_value_paths(&self, paths: &mut BTreeSet<String>) {
223        match self {
224            Self::Truthy { path }
225            | Self::With { path }
226            | Self::Eq { path, .. }
227            | Self::NotEq { path, .. }
228            | Self::Absent { path }
229            | Self::TypeIs { path, .. }
230            | Self::MatchesPattern { path, .. }
231            | Self::IntGt { path, .. }
232            | Self::IntLt { path, .. }
233            | Self::HasKey { path, .. }
234            | Self::ContainsMemberEquals { path, .. }
235            | Self::ContainsTruthyMember { path, .. }
236            | Self::ContainsEquals { path, .. }
237            | Self::AtMostOneMember { path }
238            | Self::MinMembers { path, .. } => {
239                paths.insert(path.clone());
240            }
241            Self::Not(inner) => inner.collect_value_paths(paths),
242            Self::AllOf(guards) | Self::AnyOf(guards) => {
243                for guard in guards {
244                    guard.collect_value_paths(paths);
245                }
246            }
247        }
248    }
249}
250
251/// Semantic source of one conditional evidence branch.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
253pub enum ConditionalOverlayFlavor {
254    /// Evidence selected by ordinary values control flow.
255    Ordinary,
256    /// Evidence tied to one producer-known Kubernetes kind alternative.
257    KindBranch,
258}
259
260/// Conditionally-scoped values path whose schema can be lowered under a
261/// values-decidable guard set.
262///
263/// Multiple entries in `guards` mean conjunction: all guards in the set must
264/// hold for the overlay to apply.
265#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
266pub struct ConditionalPathOverlay {
267    /// Conjoined conditions that select this overlay.
268    pub guards: Vec<ConditionalGuard>,
269    /// Schema evidence that applies while the guards hold.
270    pub evidence: ConditionalOverlayEvidence,
271    /// Keep the unconditional/base schema for this path alongside the guarded
272    /// overlay because the contract also observed an unguarded use.
273    pub preserve_base_schema: bool,
274    /// Semantic origin of the conditional evidence.
275    pub flavor: ConditionalOverlayFlavor,
276}
277
278/// Branch-local evidence for one conditional schema overlay.
279///
280/// The target path is implicit from the enclosing [`ContractPathSchemaEvidence`]
281/// entry that owns the overlay.
282#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
283pub struct ConditionalOverlayEvidence {
284    /// Behavioral facts observed in the selected branch.
285    pub facts: ContractValuePathFacts,
286    /// Kubernetes metadata field roles reached in the branch.
287    pub metadata_field_kinds: BTreeSet<MetadataFieldKind>,
288    /// JSON Schema type names implied by branch-local consumers.
289    pub type_hints: BTreeSet<String>,
290    /// Resource-schema sinks reached in the selected branch.
291    pub provider_schema_uses: Vec<ProviderSchemaUse>,
292}
293
294impl ConditionalOverlayEvidence {
295    /// Materializes this branch-local evidence as evidence for `value_path`.
296    #[must_use]
297    pub fn as_path_evidence(&self, value_path: &str) -> ContractPathSchemaEvidence {
298        ContractPathSchemaEvidence {
299            value_path: value_path.to_string(),
300            is_referenced_value_path: true,
301            facts: self.facts,
302            guard_predicates: Vec::new(),
303            metadata_field_kinds: self.metadata_field_kinds.clone(),
304            type_hints: self.type_hints.clone(),
305            guarded_type_hints: BTreeSet::new(),
306            fallback_type_hints: BTreeSet::new(),
307            provider_schema_uses: self.provider_schema_uses.clone(),
308            requiredness: ContractRequirednessEvidence::default(),
309            conditional_overlays: Vec::new(),
310            requirement_implications: Vec::new(),
311        }
312    }
313}
314
315/// Kubernetes `metadata.*` field shape referenced by a values path.
316///
317/// The contract layer records the field category structurally from the
318/// rendered document path. JSON Schema lowering remains a generator policy.
319#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
320pub enum MetadataFieldKind {
321    /// `metadata.labels` and `metadata.annotations`.
322    StringMap,
323    /// `metadata.name`.
324    Name,
325    /// `metadata.namespace`.
326    Namespace,
327}
328
329/// All schema-lowering evidence for one values path.
330///
331/// The contract layer owns this view so downstream generation can consume one
332/// path-local static-analysis fact instead of reassembling meaning from
333/// several parallel maps.
334#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
335pub struct ContractPathSchemaEvidence {
336    /// Canonical dot-separated values path described by this evidence.
337    pub value_path: String,
338    /// Whether template analysis directly referenced this path.
339    pub is_referenced_value_path: bool,
340    /// Aggregate behavioral facts observed for the path.
341    pub facts: ContractValuePathFacts,
342    /// Unconditional guard facts attached to the path.
343    pub guard_predicates: Vec<ConditionalGuard>,
344    /// Kubernetes metadata field roles reached from the path.
345    pub metadata_field_kinds: BTreeSet<MetadataFieldKind>,
346    /// Unconditional JSON Schema type hints.
347    pub type_hints: BTreeSet<String>,
348    /// Hints observed only under branch predicates. At the path level these
349    /// may only WIDEN (add accepted alternatives to an otherwise-typed
350    /// base): `allOf` branches can narrow but never re-widen a base, so a
351    /// branch-scoped domain alternative must surface here.
352    pub guarded_type_hints: BTreeSet<String>,
353    /// Hints from literal `default`/`coalesce` fallbacks. The selection call
354    /// never consumes the raw value — every Helm-empty input takes the
355    /// fallback — so these type only the truthy arm and base lowering must
356    /// keep the whole Helm-falsy set open beside them.
357    pub fallback_type_hints: BTreeSet<String>,
358    /// Resource-schema sinks that consume the path.
359    pub provider_schema_uses: Vec<ProviderSchemaUse>,
360    /// Facts used by optional required-property inference.
361    pub requiredness: ContractRequirednessEvidence,
362    /// Branch-local evidence keyed by values-decidable guards.
363    pub conditional_overlays: Vec<ConditionalPathOverlay>,
364    /// Runtime-hard requirements that must hold wherever their outer guards
365    /// do. They may come from an explicit abort path or another producer that
366    /// proves the same rendering requirement, so lowering must not let weaker
367    /// evidence suppress them.
368    pub requirement_implications: Vec<ContractRequirementImplication>,
369}
370
371/// A chart-authored values-program wrapper convention: within `scope_path`
372/// (empty for the whole values tree), any node may be a singleton
373/// `{key: PROGRAM}` map that the chart's engine replaces with the
374/// `tpl`-rendered, YAML-reparsed program result before consumers read it.
375#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
376pub struct ValuesProgramWrapper {
377    /// Values subtree the engine rewrites; empty means the whole tree.
378    pub scope_path: String,
379    /// The wrapper's sentinel member key (`$tplYaml`).
380    pub key: String,
381    /// Whether the engine SPREADS the program result into the parent
382    /// collection instead of replacing the node (`$tplYamlSpread`): the
383    /// result's kind must match the parent's kind (a null result is a
384    /// no-op removal), and the values root itself rejects the wrapper.
385    pub spread: bool,
386}
387
388/// A chart-wide default subtree merged into an effective `.Values` subtree.
389///
390/// The target remains user-overridable; the source supplies only keys absent
391/// from the target, matching `mustMergeOverwrite SOURCE TARGET` before the
392/// result replaces a root or nested `Values` object.
393#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
394pub struct ValuesDefaultSource {
395    /// Effective values subtree receiving defaults, with an empty path denoting `.Values`.
396    pub target_path: String,
397    /// Chart values subtree supplying defaults.
398    pub source_path: String,
399}
400
401/// One guarded runtime requirement on a values path.
402#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
403pub struct ContractRequirementImplication {
404    /// Conditions outside the requirement; empty means it binds the path
405    /// unconditionally.
406    pub outer_guards: Vec<ConditionalGuard>,
407    /// The runtime value affected by the requirement.
408    pub target: ContractRequirementTarget,
409    /// Conjunction of requirements the affected value must satisfy.
410    pub requirements: Vec<FailValueRequirement>,
411}
412
413/// Runtime value within a values-path contract that must satisfy a requirement.
414#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
415pub enum ContractRequirementTarget {
416    /// The values path itself.
417    Value,
418    /// Every value produced by ranging the path.
419    ///
420    /// `allow_integer` describes the range header's own integer lane. It is
421    /// false for a two-variable range, even when the member requirement would
422    /// otherwise accept integer values.
423    Members {
424        /// Whether Helm's integer-count range form remains accepted.
425        allow_integer: bool,
426    },
427    /// Every ranged member except named object entries supplied by a deeper
428    /// values layer. Array members and newly added object entries still bind.
429    MembersExceptKeys {
430        /// Object keys whose member requirement is satisfied after values
431        /// layering even when the parent layer omits the required leaf.
432        keys: BTreeSet<String>,
433        /// Whether Helm's integer-count range form remains accepted.
434        allow_integer: bool,
435    },
436    /// Values of object entries whose keys start with the literal prefix.
437    /// Empty arrays and null remain valid because they execute no range body.
438    MembersMatchingPrefix {
439        /// Literal key prefix selecting affected object entries.
440        prefix: String,
441    },
442    /// Each ranged member whose literal sibling equals `value` must satisfy
443    /// the requirements at `target_path`, both relative to that member.
444    MembersWhereEquals {
445        /// Relative member path used as the selector.
446        guard_path: Vec<String>,
447        /// Literal required at the selector path.
448        value: GuardValue,
449        /// Relative member path constrained by the requirement.
450        target_path: Vec<String>,
451    },
452    /// Every ranged member must CONTAIN `target_path` and its value there
453    /// must satisfy the requirements — an unconditional per-member field
454    /// read by a strict consumer (`tpl $member.url` fails on a missing or
455    /// non-string field). `allow_integer` mirrors [`Self::Members`].
456    MembersAt {
457        /// Relative member path that must exist.
458        target_path: Vec<String>,
459        /// Whether Helm's integer-count range form remains accepted.
460        allow_integer: bool,
461    },
462    /// [`Self::MembersAt`] restricted to the ranged members whose own field
463    /// at `guard_path` is Helm-truthy: the chart gates the read per member,
464    /// which only the member's own slot can express (the minio chart reads
465    /// `.existingSecretKey` inside `if .existingSecret`). The gate selects
466    /// WHICH members the requirements bind, so it never implies that the
467    /// target itself is present — that stays the absence claim's own fact.
468    MembersAtWhereTruthy {
469        /// Relative member path whose truthiness selects the members.
470        guard_path: Vec<String>,
471        /// Relative member path the requirements constrain; empty when they
472        /// constrain the selected member itself.
473        target_path: Vec<String>,
474        /// Whether Helm's integer-count range form remains accepted.
475        allow_integer: bool,
476    },
477    /// Every key produced by ranging the path.
478    Keys,
479}
480
481/// The quoting style of a manually quoted YAML scalar hosting a raw splice.
482#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
483pub enum QuotedScalarStyle {
484    /// `"…"` — every `\` must begin a YAML escape and every `"` be escaped.
485    Double,
486    /// `'…'` — `''` is the only escape, so every apostrophe must be doubled.
487    Single,
488}
489
490impl QuotedScalarStyle {
491    /// Valid CONTENT of a scalar quoted in this style; raw text outside the
492    /// grammar corrupts the manually quoted token.
493    #[must_use]
494    pub fn safe_content_pattern(self) -> &'static str {
495        match self {
496            Self::Double => {
497                r#"^([^"\\]|\\["\\/0abtnvfre N_LP]|\\x[0-9A-Fa-f]{2}|\\u[0-9A-Fa-f]{4}|\\U[0-9A-Fa-f]{8})*$"#
498            }
499            Self::Single => r"^([^']|'')*$",
500        }
501    }
502}
503
504/// One requirement a `fail` branch imposes on an affected value.
505#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
506pub enum FailValueRequirement {
507    /// The value must be of this JSON Schema type.
508    SchemaType(String),
509    /// The value must be of this JSON Schema type EVEN WHEN NULL: the
510    /// consumer type-asserts before any nil check (Sprig `dig` subjects),
511    /// so an explicit null aborts while structural absence stays open
512    /// through the arm's properties anchoring.
513    SchemaTypeEvenNull(String),
514    /// The value must be of this JSON Schema type only when Helm-truthy:
515    /// every falsy spelling escapes through the consumer's own truthiness
516    /// selection (a ranged ACL member's `default ""` password reaching
517    /// `sha256sum` behind `if $password`).
518    TruthyImpliesSchemaType(String),
519    /// The value must be Helm-truthy (sealed-secrets aborts on any falsy
520    /// `privateKeyAnnotations` member, including the empty string).
521    HelmTruthy,
522    /// The value must be Helm-FALSY — the negation of a member's own
523    /// truthiness test inside a compound ranged terminal: the fail fires
524    /// only for truthy members, so falsiness is one escape alternative
525    /// (traefik's `if $config` gate around the http3-without-tls abort).
526    HelmFalsy,
527    /// The value's field at `path`, when present, must be Helm-FALSY: the
528    /// failing test fired on the field's truthiness (oauth2-proxy aborts
529    /// when a legacy `extraPaths[].backend.serviceName` is set under the
530    /// `networking.k8s.io/v1` Ingress api).
531    FieldHelmFalsy {
532        /// Relative field path constrained to Helm-falsy values.
533        path: Vec<String>,
534    },
535    /// The value must be an object whose field at `path` is present and
536    /// equals the literal: the failing test's negation held an equality on
537    /// the field (traefik's `eq $plugin.type "hostPath"` dispatch arm; Go's
538    /// `eq` aborts on a nil operand, so presence rides along).
539    FieldEquals {
540        /// Relative field path compared with the literal.
541        path: Vec<String>,
542        /// Literal required at the field path.
543        value: GuardValue,
544    },
545    /// The value must be an object whose field at `path` is present and
546    /// non-null: a ranged member's leaf renders into a provider-REQUIRED
547    /// resource field, where a missing or null source emits an explicit
548    /// null the provider rejects (promtail's extra Service `port`).
549    FieldPresentNotNull {
550        /// Relative field path that must contain a non-null value.
551        path: Vec<String>,
552    },
553    /// The value must be an object whose field at `path` is present and
554    /// Helm-truthy — the positive mirror of [`Self::FieldHelmFalsy`], used
555    /// as the ESCAPE alternative when a member-scoped branch guard selects
556    /// another render for truthy fields (promtail's `service` arm renders
557    /// its own port instead of `containerPort`).
558    FieldHelmTruthy {
559        /// Relative field path constrained to Helm-truthy values.
560        path: Vec<String>,
561    },
562    /// At least one alternative (each a conjunction of requirements) must
563    /// hold. A `fail` whose test conjoins several member conditions negates
564    /// to the DISJUNCTION of their negations — traefik's local plugins
565    /// render with a truthy `type` OR a legacy truthy `hostPath`, and
566    /// conjoining those requirements rejected both documented shapes.
567    AnyOf(Vec<Vec<FailValueRequirement>>),
568    /// The value must not equal this literal (cilium forbids ranged
569    /// `extraEnv` names colliding with its own backoff variables).
570    NotEquals(GuardValue),
571    /// The value's field at `path`, when present, must differ from the
572    /// literal — the negation of a member-field equality test. Absent and
573    /// null fields differ from every literal (Helm's `eq` compares `nil`
574    /// without aborting), so no presence requirement rides along
575    /// (traefik's HTTPS-protocol listeners must carry `certificateRefs`;
576    /// non-HTTPS listeners escape through this arm).
577    FieldNotEquals {
578        /// Relative field path compared with the literal.
579        path: Vec<String>,
580        /// Literal excluded at the field path.
581        value: GuardValue,
582    },
583    /// The value must be of this JSON Schema type IF present and non-null:
584    /// Go's `eq`/`ne` compare `nil` against anything, so a missing or null
585    /// comparison operand renders while a present value of a different
586    /// basic kind aborts.
587    ComparableKind(String),
588    /// The value must NOT be of this JSON Schema type.
589    NotSchemaType(String),
590    /// The value must be an object containing this member.
591    HasMember(String),
592    /// The value must be an object containing this member EVEN when the
593    /// chart's own defaults supply it: the consumer aborts on an absent
594    /// subject (a nil `dig` dict), and under coalesced-document semantics
595    /// the member is absent exactly when a user null-deletes it — the
596    /// state the requirement must reject. Exempt from the
597    /// default-supplied `required` relaxation that render-grade presence
598    /// claims get.
599    HasMemberEvenDefaulted(String),
600    /// The value must be a string matching this regular expression
601    /// (`regexMatch` type-asserts a string subject, so string-ness rides
602    /// along).
603    MatchesPattern {
604        /// Regular expression the string must match.
605        pattern: String,
606        /// Whether the pattern originated from a templated expression.
607        templated: bool,
608    },
609    /// The value must be a string NOT matching this regular expression —
610    /// the failing test fired on matches, and its `regexMatch` still
611    /// type-asserts a string subject (traefik's uppercase key gate).
612    NotMatchesPattern {
613        /// Regular expression the string must not match.
614        pattern: String,
615    },
616    /// The value must be a string whose length lies inside the window — a
617    /// provider key slot's `minLength`/`maxLength` projected onto a ranged
618    /// collection's keys (traefik's Gateway listener names).
619    StringLengthBounds {
620        /// Inclusive minimum length, when one is known.
621        min: Option<u64>,
622        /// Inclusive maximum length, when one is known.
623        max: Option<u64>,
624    },
625    /// The value HOSTS literal member reads: it must be an object — or one
626    /// of the kinds the chart's own type dispatch provably handles before
627    /// the reads run (nack converts the string image form with `set`).
628    MemberHost {
629        /// Non-object JSON kinds explicitly handled by chart dispatch.
630        handled_kinds: Vec<String>,
631        /// Whether this arm belongs to the exact, complete access domain.
632        /// A sound-subset arm may reject states where navigation certainly
633        /// executes, but it must not decide ownership of the path's base.
634        complete_domain: bool,
635    },
636    /// The value is iterated by `range`: collections and nil render, and
637    /// integer counts iterate when the loop body has no member structure.
638    Iterable {
639        /// Whether Helm's integer-count range form remains accepted.
640        allow_integer: bool,
641    },
642    /// A zero-based position must exist before `index` can project it.
643    /// Arrays lower exactly; strings remain conservative because Go indexes
644    /// bytes while JSON Schema `minLength` counts Unicode code points.
645    IndexableAt(usize),
646    /// Splitting the textual form must produce at least `segments` entries.
647    /// When the input was first passed through a total text conversion,
648    /// non-string inputs remain conservatively accepted.
649    SplitSegmentsAtLeast {
650        /// Literal delimiter used by the split operation.
651        separator: String,
652        /// Minimum number of produced segments.
653        segments: usize,
654        /// Whether a preceding total conversion admits non-string inputs.
655        allow_non_string: bool,
656    },
657    /// The value renders inside a manually quoted YAML scalar: every string
658    /// it contributes to the token — the value itself, or any nested string
659    /// or mapping key when Go's fmt serializes a collection
660    /// (`map[k:v]` / `[a b]`) with its strings embedded raw — must be valid
661    /// content for the quoting style. Non-string scalars format as plain
662    /// digits/words and are always safe.
663    QuotedSerializationSafe {
664        /// YAML quoting grammar that serialized content must satisfy.
665        style: QuotedScalarStyle,
666        /// The rendered text is a `tpl` result, so template-action-free
667        /// input is the identity while an actual program has unknown output.
668        templated: bool,
669    },
670    /// A value substituted by fmt's `%s` may be a string or a mapping whose
671    /// recursive textual form remains structurally safe. Other JSON kinds
672    /// emit a leading fmt diagnostic or collection indicator.
673    PrintfStringOperand,
674    /// The value's own text renders into an UNQUOTED YAML slot, so text that
675    /// closes the plain token there corrupts the document: a `: ` (or
676    /// trailing `:`) turns the slot into a nested mapping, a ` #` truncates
677    /// it as a comment, and a line break ends it. Non-string scalars format
678    /// as plain digits/words and are always safe.
679    ///
680    /// This is a STRUCTURAL claim only — the resolver-token exclusions that
681    /// keep a plain token from reparsing as a number/bool/null belong to the
682    /// provider-slot preimage, which knows the sink's declared type.
683    PlainScalarSafe {
684        /// The text OPENS the token, so leading YAML indicators break it too.
685        /// False for a splice with literal text ahead of it.
686        token_initial: bool,
687        /// The rendered text is a `tpl` render of the value, which is the
688        /// identity only on template-ACTION-free input: a value carrying
689        /// `{{` renders to something else entirely and escapes the claim.
690        templated: bool,
691    },
692}
693
694impl ContractPathSchemaEvidence {
695    /// Reports whether positive, unconditional evidence can make the path required.
696    #[must_use]
697    pub fn is_required_inference_candidate(&self) -> bool {
698        self.requiredness.is_positive_header
699            && !self.requiredness.has_default_fallback
700            && !self.requiredness.is_conditionally_optional
701            && self.facts.has_non_self_guarded_render_use()
702    }
703}
704
705/// Contract-derived facts consumed by core values-schema generation.
706///
707/// This is the typed boundary between static template interpretation and JSON
708/// Schema lowering. Optional post-passes can ask for their own projections,
709/// but core schema generation should consume this artifact rather than
710/// re-reading raw contract claims.
711#[derive(Debug, Clone, Default, PartialEq, Eq)]
712pub struct ContractSchemaSignals {
713    schema_evidence_by_value_path: BTreeMap<String, ContractPathSchemaEvidence>,
714    referenced_value_paths: BTreeSet<String>,
715    pruned_parent_value_paths: BTreeSet<String>,
716    unconditionally_omitted_value_paths: BTreeSet<String>,
717    direct_ranged_value_paths: BTreeSet<String>,
718    values_default_sources: BTreeSet<ValuesDefaultSource>,
719    values_program_wrappers: BTreeSet<ValuesProgramWrapper>,
720    /// Values paths whose nodes must not gain a wrapper alternative: a
721    /// strict string consumer reads them before the engine's values-root
722    /// rewrite, so a wrapper map there aborts rendering.
723    values_program_wrapper_exclusions: BTreeSet<String>,
724    /// Terminating validator formulas: rendering aborts whenever ALL guards
725    /// of one clause hold, so no valid values document may satisfy them
726    /// (`fail`/`required` under fully lowerable conditions). An empty clause
727    /// is an unconditional termination.
728    terminal_clauses: Vec<Vec<ConditionalGuard>>,
729}
730
731impl ContractSchemaSignals {
732    /// Builds a stable signal set from path evidence and terminal clauses.
733    #[must_use]
734    pub fn new(
735        schema_evidence_by_value_path: BTreeMap<String, ContractPathSchemaEvidence>,
736        terminal_clauses: Vec<Vec<ConditionalGuard>>,
737    ) -> Self {
738        let referenced_value_paths: BTreeSet<String> = schema_evidence_by_value_path
739            .iter()
740            .filter(|(_, evidence)| evidence.is_referenced_value_path)
741            .map(|(path, _)| path.clone())
742            .collect();
743        let pruned_parent_value_paths = schema_evidence_by_value_path
744            .iter()
745            .filter(|(_, evidence)| {
746                evidence.facts.has_referenced_descendants && !evidence.facts.used_as_fragment
747            })
748            .map(|(path, _)| path.clone())
749            .collect();
750        let unconditionally_omitted_value_paths = schema_evidence_by_value_path
751            .iter()
752            .flat_map(|(path, evidence)| {
753                let mut provider_uses = evidence.provider_schema_uses.iter().chain(
754                    evidence
755                        .conditional_overlays
756                        .iter()
757                        .flat_map(|overlay| overlay.evidence.provider_schema_uses.iter()),
758                );
759                let Some(first_use) = provider_uses.next() else {
760                    return BTreeSet::new();
761                };
762                let mut omitted_members = first_use
763                    .omitted_members
764                    .iter()
765                    .filter(|(_, retain_guards)| retain_guards.is_empty())
766                    .map(|(member, _)| member.clone())
767                    .collect::<BTreeSet<_>>();
768                for provider_use in provider_uses {
769                    omitted_members.retain(|member| {
770                        provider_use
771                            .omitted_members
772                            .get(member)
773                            .is_some_and(Vec::is_empty)
774                    });
775                }
776                omitted_members
777                    .into_iter()
778                    .map(|member| {
779                        let mut segments = crate::split_value_path(path);
780                        segments.push(member);
781                        crate::join_value_path(segments)
782                    })
783                    .filter(|member_path| referenced_value_paths.contains(member_path))
784                    .collect()
785            })
786            .collect();
787        let direct_ranged_value_paths = schema_evidence_by_value_path
788            .iter()
789            .filter(|(_, evidence)| evidence.facts.is_direct_ranged_source)
790            .map(|(path, _)| path.clone())
791            .collect();
792        Self {
793            schema_evidence_by_value_path,
794            referenced_value_paths,
795            pruned_parent_value_paths,
796            unconditionally_omitted_value_paths,
797            direct_ranged_value_paths,
798            values_default_sources: BTreeSet::new(),
799            values_program_wrappers: BTreeSet::new(),
800            values_program_wrapper_exclusions: BTreeSet::new(),
801            terminal_clauses,
802        }
803    }
804
805    /// Attaches chart subtrees that supply runtime defaults to effective values paths.
806    #[must_use]
807    pub fn with_values_default_sources(
808        mut self,
809        sources: impl IntoIterator<Item = ValuesDefaultSource>,
810    ) -> Self {
811        self.values_default_sources.extend(sources);
812        self
813    }
814
815    /// Default subtrees applied to effective values before templates consume them.
816    #[must_use]
817    pub fn values_default_sources(&self) -> &BTreeSet<ValuesDefaultSource> {
818        &self.values_default_sources
819    }
820
821    /// Projects fail-grade contracts on effective-root paths onto their
822    /// prefixed spellings for every in-place root overlay
823    /// (`mustMergeOverwrite $.Values (index $.Values "pilot")`): a member
824    /// the user writes under the prefix overwrites its effective-root twin
825    /// before any consumer reads it, so the same abort-grade requirements
826    /// bind the prefixed path (istiod's `pilot.env: "oops"` aborts exactly
827    /// like `env: "oops"`). Guards about the subject path or its
828    /// descendants move to the prefixed spelling; foreign guard paths keep
829    /// their root spellings — a bounded reading that assumes cross-path
830    /// conditions are supplied at the root, not through the same overlay.
831    #[must_use]
832    pub fn with_root_overlay_requirement_implications(
833        mut self,
834        prefixes: impl IntoIterator<Item = String>,
835    ) -> Self {
836        for prefix in prefixes {
837            if prefix.trim().is_empty() {
838                continue;
839            }
840            let twins: Vec<(String, Vec<ContractRequirementImplication>)> = self
841                .schema_evidence_by_value_path
842                .iter()
843                .filter(|(path, evidence)| {
844                    !evidence.requirement_implications.is_empty()
845                        && path.as_str() != prefix
846                        && !crate::values_path_is_descendant(path, &prefix)
847                        && !crate::values_path_is_descendant(&prefix, path)
848                })
849                .map(|(path, evidence)| {
850                    let implications = evidence
851                        .requirement_implications
852                        .iter()
853                        .map(|implication| {
854                            let mut twin = implication.clone();
855                            twin.outer_guards = twin
856                                .outer_guards
857                                .into_iter()
858                                .map(|guard| {
859                                    guard.map_value_paths(&mut |guard_path: &str| {
860                                        if guard_path == path
861                                            || crate::values_path_is_descendant(guard_path, path)
862                                        {
863                                            format!("{prefix}.{guard_path}")
864                                        } else {
865                                            guard_path.to_string()
866                                        }
867                                    })
868                                })
869                                .collect();
870                            twin
871                        })
872                        .collect();
873                    (format!("{prefix}.{path}"), implications)
874                })
875                .collect();
876            for (twin_path, implications) in twins {
877                let entry = self
878                    .schema_evidence_by_value_path
879                    .entry(twin_path.clone())
880                    .or_insert_with(|| ContractPathSchemaEvidence {
881                        value_path: twin_path,
882                        ..ContractPathSchemaEvidence::default()
883                    });
884                for implication in implications {
885                    if !entry.requirement_implications.contains(&implication) {
886                        entry.requirement_implications.push(implication);
887                    }
888                }
889            }
890        }
891        self
892    }
893
894    /// Attaches chart-authored program-wrapper conventions.
895    #[must_use]
896    pub fn with_values_program_wrappers(
897        mut self,
898        wrappers: impl IntoIterator<Item = ValuesProgramWrapper>,
899    ) -> Self {
900        self.values_program_wrappers.extend(wrappers);
901        self
902    }
903
904    /// Program-wrapper conventions the chart's engine applies to its values.
905    #[must_use]
906    pub fn values_program_wrappers(&self) -> &BTreeSet<ValuesProgramWrapper> {
907        &self.values_program_wrappers
908    }
909
910    /// Attaches paths excluded from wrapper alternatives (pre-rewrite
911    /// strict consumers).
912    #[must_use]
913    pub fn with_values_program_wrapper_exclusions(
914        mut self,
915        paths: impl IntoIterator<Item = String>,
916    ) -> Self {
917        self.values_program_wrapper_exclusions.extend(paths);
918        self
919    }
920
921    /// Values paths whose nodes must not gain a wrapper alternative.
922    #[must_use]
923    pub fn values_program_wrapper_exclusions(&self) -> &BTreeSet<String> {
924        &self.values_program_wrapper_exclusions
925    }
926
927    /// Paths the chart ranges DIRECTLY: their runtime iterable domain is
928    /// wider than any declared shape, so ancestor subtree schemas must not
929    /// shadow their own resolutions.
930    #[must_use]
931    pub fn direct_ranged_value_paths(&self) -> &BTreeSet<String> {
932        &self.direct_ranged_value_paths
933    }
934
935    /// Terminating validator formulas: no valid values document satisfies
936    /// all guards of one clause.
937    #[must_use]
938    pub fn terminal_clauses(&self) -> &[Vec<ConditionalGuard>] {
939        &self.terminal_clauses
940    }
941
942    /// Returns schema-lowering evidence indexed by canonical values path.
943    #[must_use]
944    pub fn schema_evidence_by_value_path(&self) -> &BTreeMap<String, ContractPathSchemaEvidence> {
945        &self.schema_evidence_by_value_path
946    }
947
948    /// Values paths the contract directly referenced, in stable order.
949    #[must_use]
950    pub fn referenced_value_paths(&self) -> &BTreeSet<String> {
951        &self.referenced_value_paths
952    }
953
954    /// Non-fragment parent paths whose referenced descendants own their own
955    /// schema evidence, so parent-level defaults must not restate them.
956    #[must_use]
957    pub fn pruned_parent_value_paths(&self) -> &BTreeSet<String> {
958        &self.pruned_parent_value_paths
959    }
960
961    /// Referenced members removed before every provider sink of their parent.
962    #[must_use]
963    pub fn unconditionally_omitted_value_paths(&self) -> &BTreeSet<String> {
964        &self.unconditionally_omitted_value_paths
965    }
966
967    /// Returns schema evidence for one canonical values path.
968    #[must_use]
969    pub fn evidence_for(&self, value_path: &str) -> Option<&ContractPathSchemaEvidence> {
970        self.schema_evidence_by_value_path.get(value_path)
971    }
972}
973
974/// Schema-generation facts for one input values path.
975///
976/// This bundles the contract-owned path state that schema lowering needs, so
977/// generator code does not have to reconstruct semantic facts from multiple
978/// lower-level projections.
979#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
980pub struct ContractValuePathFacts {
981    /// Whether analysis observed referenced paths below this path.
982    pub has_referenced_descendants: bool,
983    /// Descendant rows that continue through a `*` item segment. Item rows
984    /// describe a ranged collection's element shape; a literal member read
985    /// (e.g. a guard probing one key of a user-populated map) does not.
986    pub has_item_descendants: bool,
987    /// Item descendants that continue INTO element structure (`p.*.field`).
988    /// A bare `p.*` value row proves no LIST shape: `range` iterates maps
989    /// too, so declared-empty maps with only bare member-value rows stay
990    /// user-populated.
991    pub has_structured_item_descendants: bool,
992    /// Whether the path renders as a structural YAML fragment.
993    pub used_as_fragment: bool,
994    /// The path renders through a serializing or total-stringification sink
995    /// (`tpl (toYaml …)`, `quote`, `toString`, `join`): any input type
996    /// renders, so the use exposes provenance but no input shape.
997    pub used_as_serialized: bool,
998    /// The path is rendered through `toYaml`. The input kind is unrestricted,
999    /// while the resulting YAML fragment still obeys structural placement.
1000    pub used_as_yaml_serialized: bool,
1001    /// A string-consuming transform (`trunc`, `b64enc`, `fromYaml`, a
1002    /// dynamic `printf` format) bound a real runtime string contract on the
1003    /// path: rendering fails for non-string values, so this typing survives
1004    /// even when another use stringifies the path.
1005    pub has_string_contract: bool,
1006    /// Whether a runtime string contract can consume the raw value without
1007    /// first passing through that value's own truthiness guard. Such a
1008    /// contract still rejects Helm-falsy non-strings even when every placed
1009    /// render row is self-guarded.
1010    pub has_non_self_guarded_string_contract: bool,
1011    /// Some `path.*` member row carries a runtime string contract (`tpl`
1012    /// over each ranged member): integer iteration yields int members the
1013    /// contract rejects, so the integer lane closes.
1014    pub has_string_contract_items: bool,
1015    /// Whether fragment rendering lost a precise output location.
1016    pub used_as_pathless_fragment: bool,
1017    /// Whether the path may supply the chart's complete values-root fragment.
1018    pub accepted_values_root_fragment: bool,
1019    /// Whether the path may supply a dependency values-root fragment.
1020    pub accepted_dependency_values_root_fragment: bool,
1021    /// Whether this path or one of its projections supplies a range action.
1022    pub is_ranged_source: bool,
1023    /// The chart ranges this path DIRECTLY (`range .Values.x`), so the
1024    /// runtime iterable domain applies to the path's own value.
1025    pub is_direct_ranged_source: bool,
1026    /// Some direct range over this path uses TWO variables
1027    /// (`range $k, $v := …`): integers iterate single-variable ranges only
1028    /// ("can't use 2 to iterate over more than one variable").
1029    pub has_destructured_range_use: bool,
1030    /// Some direct range sees the path after JSON decoding, where numbers are
1031    /// `float64` values rather than Helm's integer iteration counts.
1032    pub has_json_decoded_range_use: bool,
1033    /// Whether the path contributes only part of a rendered scalar token.
1034    pub is_partial_scalar_value_path: bool,
1035    /// Whether any rendering sink consumes the path.
1036    pub has_render_use: bool,
1037    /// Whether any use consumes the value rather than merely testing it in a
1038    /// positive control-flow header.
1039    pub has_non_control_use: bool,
1040    /// Whether a non-control consumer observes the value outside an ordered
1041    /// merge layer. Such a consumer keeps the resolved base beside synthesized
1042    /// merge-layer arms.
1043    pub has_unlayered_non_control_use: bool,
1044    /// Whether a rendering sink consumes the path without a branch guard.
1045    pub has_unconditional_render_use: bool,
1046    /// Whether any rendering sink is guarded by this path's own truthiness.
1047    pub has_self_guarded_render_use: bool,
1048    /// Whether every rendering sink is guarded by this path's own truthiness.
1049    pub all_render_uses_self_guarded: bool,
1050    /// A render consumed this path as one layer of an ordered merge: the
1051    /// generator synthesizes the layer's typing as root arms, and the
1052    /// layer's synthetic self-truthiness guard must not drive base
1053    /// classification (a declared `{}` default stays an open map — the
1054    /// merged sink renders any user-supplied members).
1055    pub has_merge_layered_use: bool,
1056    /// A merge layer passes through Helm's map-only YAML decoder. Provider
1057    /// typing applies to mapping inputs, while non-mapping source shapes are
1058    /// discarded and therefore must remain open in the base schema.
1059    pub has_parsed_map_layered_use: bool,
1060    /// Every render use either sits behind the path's own truthy selection or
1061    /// cannot reject a Helm-falsy value at all: a `merge` operand's strict
1062    /// map contract rides its requirement implication (which keys on the call's live
1063    /// gate), and a checksum digest row hashes re-rendered text without
1064    /// consuming the raw value. Unlike `all_render_uses_self_guarded`, this
1065    /// bit feeds ONLY the base falsy escape — never overlay-branch routing or
1066    /// declared-default placement.
1067    pub all_render_uses_falsy_tolerant: bool,
1068    /// Whether a direct range guard protects a rendering sink for this path.
1069    pub has_self_range_guard_render_use: bool,
1070    /// Whether observed semantics explicitly admit null.
1071    pub is_nullable: bool,
1072}
1073
1074impl ContractValuePathFacts {
1075    /// Incorporates one rendering use into the aggregate path facts.
1076    pub fn record_render_use(
1077        &mut self,
1078        range_guarded: bool,
1079        self_guarded: Option<bool>,
1080        falsy_tolerant: Option<bool>,
1081    ) {
1082        if !self.has_render_use {
1083            self.all_render_uses_self_guarded = true;
1084            self.all_render_uses_falsy_tolerant = true;
1085        }
1086        self.has_render_use = true;
1087        self.has_self_range_guard_render_use |= range_guarded;
1088        if let Some(self_guarded) = self_guarded {
1089            self.has_self_guarded_render_use |= self_guarded;
1090            self.all_render_uses_self_guarded &= self_guarded;
1091        }
1092        if let Some(falsy_tolerant) = falsy_tolerant {
1093            self.all_render_uses_falsy_tolerant &= falsy_tolerant;
1094        }
1095    }
1096
1097    /// Merges rendering facts collected by another analysis branch.
1098    pub fn merge_render_use_facts(&mut self, other: Self) {
1099        if !other.has_render_use {
1100            return;
1101        }
1102        if !self.has_render_use {
1103            self.all_render_uses_self_guarded = true;
1104            self.all_render_uses_falsy_tolerant = true;
1105        }
1106        self.has_render_use = true;
1107        self.has_unconditional_render_use |= other.has_unconditional_render_use;
1108        self.has_self_guarded_render_use |= other.has_self_guarded_render_use;
1109        self.has_merge_layered_use |= other.has_merge_layered_use;
1110        self.has_parsed_map_layered_use |= other.has_parsed_map_layered_use;
1111        self.has_self_range_guard_render_use |= other.has_self_range_guard_render_use;
1112        self.all_render_uses_self_guarded &= other.all_render_uses_self_guarded;
1113        self.all_render_uses_falsy_tolerant &= other.all_render_uses_falsy_tolerant;
1114    }
1115
1116    #[must_use]
1117    pub(crate) fn has_non_self_guarded_render_use(self) -> bool {
1118        self.has_render_use
1119            && !self.has_self_guarded_render_use
1120            && !self.all_render_uses_self_guarded
1121    }
1122}
1123
1124/// Path-local evidence consumed by the optional `--infer-required` post-pass.
1125///
1126/// These are still static-analysis facts, not a decision that the path must be
1127/// required. The generator combines them with render-use facts and chart
1128/// defaults before mutating the JSON Schema.
1129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
1130pub struct ContractRequirednessEvidence {
1131    /// Whether the path appears in a positive control-flow header.
1132    pub is_positive_header: bool,
1133    /// Whether some branch permits the path to remain absent.
1134    pub is_conditionally_optional: bool,
1135    /// Whether a defaulting operation supplies an absent value.
1136    pub has_default_fallback: bool,
1137}