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