1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
//! Explicit input bundle for one processed Compose project.
use std::collections::BTreeMap;
use boxferry_engine::{
DiagnosticField, DiagnosticValue, NativeFinding, NativeFindingLabel, NativeFindingLabelKind, Severity,
};
use boxferry_model::{Identifier, ModelError, SourceId};
use compose_lens::{
diagnostic::{Diagnostic as ComposeDiagnostic, LabelKind, Severity as ComposeSeverity},
merge::MergedProject,
profiles::ProfileSelection,
source::SourceId as ComposeSourceId,
};
/// Compose processing layer that emitted a retained native finding.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ComposeFindingStage {
/// YAML syntax or single-document loading.
Load,
/// Explicit variable interpolation.
Interpolation,
/// Ordered multi-document merge.
Merge,
/// Explicit service profile selection.
ProfileSelection,
/// Native merged-project typing.
ProjectModel,
/// Provider/runtime compatibility validation.
Validation,
}
impl ComposeFindingStage {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Load => "load",
Self::Interpolation => "interpolation",
Self::Merge => "merge",
Self::ProfileSelection => "profile-selection",
Self::ProjectModel => "project-model",
Self::Validation => "validation",
}
}
/// Converts one native diagnostic into the shared protected finding envelope.
#[must_use]
pub fn native_finding(self, diagnostic: &ComposeDiagnostic) -> NativeFinding {
let severity = match diagnostic.severity() {
ComposeSeverity::Error => Severity::Error,
ComposeSeverity::Warning => Severity::Warning,
ComposeSeverity::Note => Severity::Note,
};
let mut finding = NativeFinding::new(
"compose",
"compose-lens",
diagnostic.code().as_str(),
self.as_str(),
severity,
diagnostic.message(),
);
if let Some(variable) = interpolation_variable(diagnostic.code().as_str(), diagnostic.message()) {
finding = finding.with_field(DiagnosticField::new("variable", DiagnosticValue::plain(variable)));
}
for label in diagnostic.labels() {
finding = finding.with_label(NativeFindingLabel::new(
match label.kind() {
LabelKind::Primary => NativeFindingLabelKind::Primary,
LabelKind::Secondary => NativeFindingLabelKind::Secondary,
},
label.span().source_id().get(),
label.span().start(),
label.span().end(),
label.message(),
));
}
for note in diagnostic.notes() {
finding = finding.with_note(note);
}
finding
}
}
/// A merged Compose project and the caller-owned context needed to import it safely.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ComposeSource {
project: MergedProject,
fallback_application_name: Identifier,
source_ids: BTreeMap<ComposeSourceId, SourceId>,
profile_selection: Option<ProfileSelection>,
native_findings: Vec<NativeFinding>,
deferred_options: crate::QuadletDeferredOptions,
interpolation_applied: bool,
}
impl ComposeSource {
/// Records that the native merge consumed an interpolation overlay, including an empty one.
/// Callers must set this whenever merging with an overlay: escaped dollars may have been
/// decoded even when no substituted provider value carries sensitivity taint.
#[must_use]
pub const fn with_interpolation_applied(mut self) -> Self {
self.interpolation_applied = true;
self
}
/// Returns the explicit native processing-stage evidence supplied by the caller.
#[must_use]
pub const fn interpolation_applied(&self) -> bool {
self.interpolation_applied
}
/// Attaches explicit per-service deferred execution plans to uninterpolated input.
///
/// Callers must merge without an interpolation overlay. Explicitly recorded overlays are
/// rejected; this option never authorizes a second evaluation stage.
#[must_use]
pub fn with_deferred_options(mut self, options: crate::QuadletDeferredOptions) -> Self {
self.deferred_options = options;
self
}
/// Returns the explicit deferral policy.
#[must_use]
pub const fn deferred_options(&self) -> &crate::QuadletDeferredOptions {
&self.deferred_options
}
/// Creates a source without guessing profiles or source filenames from ambient state.
///
/// Every Compose source initially receives the stable neutral identity
/// `compose-source-<numeric-id>`. Call [`Self::with_source_id`] to replace it with a caller-owned
/// path, URI, or other display identity. The project's explicit top-level `name` wins when
/// present; the fallback is used when Compose project naming was supplied externally or
/// omitted. If the merge consumed any interpolation overlay, callers must additionally use
/// [`Self::with_interpolation_applied`], even when the overlay was empty or only decoded escapes.
/// Processing stage is explicit caller context and is never inferred from provenance or taint.
///
/// # Errors
///
/// Returns [`ModelError`] if a generated fallback source identity violates the neutral-model
/// invariant. This cannot occur for the current `compose-source-<u32>` spelling, but keeping
/// construction fallible avoids a hidden panic if that policy changes.
pub fn new(project: MergedProject, fallback_application_name: Identifier) -> Result<Self, ModelError> {
let source_ids = project
.source_ids()
.iter()
.copied()
.map(|source_id| {
SourceId::new(format!("compose-source-{}", source_id.get())).map(|neutral| (source_id, neutral))
})
.collect::<Result<_, _>>()?;
Ok(Self {
project,
fallback_application_name,
source_ids,
profile_selection: None,
native_findings: Vec::new(),
deferred_options: crate::QuadletDeferredOptions::new(),
interpolation_applied: false,
})
}
/// Assigns a caller-owned neutral identity to one Compose source document.
///
/// Unknown Compose source IDs are retained in the map but cannot contribute provenance unless
/// they also occur in the merged project.
#[must_use]
pub fn with_source_id(mut self, compose: ComposeSourceId, neutral: SourceId) -> Self {
self.source_ids.insert(compose, neutral);
self
}
/// Attaches the explicit selection produced by `ComposeLens` project processing.
#[must_use]
pub fn with_profile_selection(mut self, selection: ProfileSelection) -> Self {
self.profile_selection = Some(selection);
self
}
/// Retains native Compose diagnostics without terminal rendering or source contents.
///
/// Diagnostics should be attached at the stage where the caller obtained them. The adapter
/// keeps native codes as provenance and maps them to BoxFerry-owned rules during import.
#[must_use]
pub fn with_native_diagnostics<'a>(
mut self,
stage: ComposeFindingStage,
diagnostics: impl IntoIterator<Item = &'a ComposeDiagnostic>,
) -> Self {
self.native_findings.extend(
diagnostics
.into_iter()
.map(|diagnostic| stage.native_finding(diagnostic)),
);
self
}
/// Returns the merged native project.
#[must_use]
pub const fn project(&self) -> &MergedProject {
&self.project
}
/// Returns the caller-selected fallback application name.
#[must_use]
pub const fn fallback_application_name(&self) -> &Identifier {
&self.fallback_application_name
}
/// Resolves a Compose source identity into its neutral-model identity.
#[must_use]
pub fn source_id(&self, compose: ComposeSourceId) -> Option<&SourceId> {
self.source_ids.get(&compose)
}
/// Returns the explicit profile selection, when one was supplied.
#[must_use]
pub const fn profile_selection(&self) -> Option<&ProfileSelection> {
self.profile_selection.as_ref()
}
/// Returns retained native Compose findings in processing order.
#[must_use]
pub fn native_findings(&self) -> &[NativeFinding] {
&self.native_findings
}
}
pub(crate) fn native_project_finding(diagnostic: &ComposeDiagnostic) -> NativeFinding {
ComposeFindingStage::ProjectModel.native_finding(diagnostic)
}
fn interpolation_variable<'a>(code: &str, message: &'a str) -> Option<&'a str> {
if !matches!(
code,
"compose.interpolation.unset-variable" | "compose.interpolation.required-variable"
) {
return None;
}
let (_, remainder) = message.split_once('`')?;
let (variable, _) = remainder.split_once('`')?;
let mut bytes = variable.bytes();
let first = bytes.next()?;
((first == b'_' || first.is_ascii_alphabetic()) && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()))
.then_some(variable)
}