Skip to main content

presolve_compiler/
form_diagnostics.rs

1/// A Phase I diagnostic code reserved before form syntax is lowered.
2///
3/// I0 owns only the stable range and its roadmap-defined meanings. The
4/// canonical I18 projector remains responsible for creating diagnostics from
5/// immutable form products.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub struct FormDiagnosticReservation {
8    pub code: &'static str,
9    pub meaning: &'static str,
10}
11
12/// The next contiguous public compiler diagnostic range after Phase H.
13pub const FORM_DIAGNOSTIC_RESERVATIONS: [FormDiagnosticReservation; 12] = [
14    FormDiagnosticReservation {
15        code: "PSC1084",
16        meaning: "Duplicate form",
17    },
18    FormDiagnosticReservation {
19        code: "PSC1085",
20        meaning: "Duplicate field",
21    },
22    FormDiagnosticReservation {
23        code: "PSC1086",
24        meaning: "Missing submit",
25    },
26    FormDiagnosticReservation {
27        code: "PSC1087",
28        meaning: "Invalid validator",
29    },
30    FormDiagnosticReservation {
31        code: "PSC1088",
32        meaning: "Cyclic validation dependency",
33    },
34    FormDiagnosticReservation {
35        code: "PSC1089",
36        meaning: "Invalid serialization",
37    },
38    FormDiagnosticReservation {
39        code: "PSC1090",
40        meaning: "Invalid reset",
41    },
42    FormDiagnosticReservation {
43        code: "PSC1091",
44        meaning: "Duplicate binding",
45    },
46    FormDiagnosticReservation {
47        code: "PSC1092",
48        meaning: "Nested forms",
49    },
50    FormDiagnosticReservation {
51        code: "PSC1093",
52        meaning: "Invalid ownership",
53    },
54    FormDiagnosticReservation {
55        code: "PSC1094",
56        meaning: "Invalid submit signature",
57    },
58    FormDiagnosticReservation {
59        code: "PSC1095",
60        meaning: "Invalid field scope",
61    },
62];
63
64/// Project I18 Forms diagnostics exclusively from retained compiler products.
65///
66/// Candidate-only findings retain their candidate semantic identity in the
67/// shared diagnostic subject slot; valid semantic subjects retain their exact
68/// Form, Field, Rule, or binding identity. This projector never reparses
69/// source or observes runtime state.
70///
71/// # Panics
72///
73/// Panics if a retained validation cycle references a missing retained rule
74/// candidate, which violates the I6 graph invariant.
75#[must_use]
76#[allow(clippy::too_many_lines)]
77pub fn collect_form_diagnostics(
78    model: &crate::ApplicationSemanticModel,
79) -> Vec<crate::ComponentDiagnostic> {
80    use crate::{
81        FormDeclarationViolation, FormFieldBindingViolation, FormFieldDeclarationViolation,
82        SubmissionDeclarationViolation, SubmissionHostViolation,
83    };
84
85    let mut diagnostics = Vec::new();
86    for candidate in model
87        .components
88        .iter()
89        .flat_map(|component| &component.form_declaration_candidates)
90    {
91        if candidate
92            .violations()
93            .contains(&FormDeclarationViolation::DuplicateName)
94        {
95            diagnostics.push(form_diagnostic(
96                "PSC1084",
97                candidate.provenance.clone(),
98                candidate.id.as_semantic_id().clone(),
99            ));
100        } else if !candidate.violations().is_empty() {
101            diagnostics.push(form_diagnostic(
102                "PSC1093",
103                candidate.provenance.clone(),
104                candidate.id.as_semantic_id().clone(),
105            ));
106        }
107    }
108    for candidate in model
109        .components
110        .iter()
111        .flat_map(|component| &component.form_field_declaration_candidates)
112    {
113        let code = if candidate
114            .violations
115            .contains(&FormFieldDeclarationViolation::DuplicateName)
116        {
117            Some("PSC1085")
118        } else if candidate.violations.iter().any(|violation| {
119            matches!(
120                violation,
121                FormFieldDeclarationViolation::InvalidFormDesignator
122                    | FormFieldDeclarationViolation::UnresolvedForm
123                    | FormFieldDeclarationViolation::InvalidForm
124                    | FormFieldDeclarationViolation::CrossComponentForm
125                    | FormFieldDeclarationViolation::InvalidOwner
126            )
127        }) {
128            Some("PSC1093")
129        } else if candidate.violations.is_empty() {
130            None
131        } else {
132            Some("PSC1095")
133        };
134        if let Some(code) = code {
135            diagnostics.push(form_diagnostic(
136                code,
137                candidate.provenance.clone(),
138                candidate.id.as_semantic_id().clone(),
139            ));
140        }
141    }
142    for candidate in &model.validation_rule_candidates {
143        if !candidate.violations.is_empty() {
144            diagnostics.push(form_diagnostic(
145                "PSC1087",
146                candidate.decorator_provenance.clone(),
147                candidate.id.as_semantic_id().clone(),
148            ));
149        }
150    }
151    for cycle in &model.validation_graph.cycles {
152        for candidate in &cycle.candidates {
153            let provenance = model
154                .validation_rule_candidates
155                .iter()
156                .find(|item| item.id == *candidate)
157                .map(|item| item.decorator_provenance.clone())
158                .expect("validation cycle candidate retained");
159            diagnostics.push(form_diagnostic(
160                "PSC1088",
161                provenance,
162                candidate.as_semantic_id().clone(),
163            ));
164        }
165    }
166    for candidate in &model.submissions.candidates {
167        let code = if candidate.violations.iter().any(|violation| {
168            matches!(
169                violation,
170                SubmissionDeclarationViolation::StaticMethod
171                    | SubmissionDeclarationViolation::AsyncMethod
172                    | SubmissionDeclarationViolation::ParameterizedMethod
173                    | SubmissionDeclarationViolation::InvalidReturnType
174            )
175        }) {
176            Some("PSC1094")
177        } else if candidate.violations.is_empty() {
178            None
179        } else {
180            Some("PSC1093")
181        };
182        if let Some(code) = code {
183            diagnostics.push(form_diagnostic(
184                code,
185                candidate.provenance.clone(),
186                candidate.id.as_semantic_id().clone(),
187            ));
188        }
189    }
190    for declaration in &model.serialization.declarations {
191        if let Some(form) = &declaration.form {
192            if !declaration.invoked
193                || declaration.argument_count != 1
194                || !matches!(
195                    declaration.format.as_deref(),
196                    Some("json" | "form-data" | "url-encoded")
197                )
198            {
199                diagnostics.push(form_diagnostic(
200                    "PSC1089",
201                    declaration.provenance.clone(),
202                    form.as_semantic_id().clone(),
203                ));
204            }
205        }
206    }
207    for candidate in &model.form_field_binding_candidates {
208        if candidate.violations.iter().any(|violation| {
209            matches!(
210                violation,
211                FormFieldBindingViolation::DuplicateFieldControl
212                    | FormFieldBindingViolation::DuplicateBindingAttribute
213                    | FormFieldBindingViolation::CompetingValueBinding
214                    | FormFieldBindingViolation::CompetingCheckedBinding
215            )
216        }) {
217            diagnostics.push(form_diagnostic(
218                "PSC1091",
219                candidate.provenance.clone(),
220                candidate.id.as_semantic_id().clone(),
221            ));
222        }
223    }
224    for candidate in &model.submission_host_candidates {
225        let code = if candidate.violations.iter().any(|violation| {
226            matches!(
227                violation,
228                SubmissionHostViolation::NestedHost
229                    | SubmissionHostViolation::InvalidHostElement
230                    | SubmissionHostViolation::DuplicateHostAttribute
231                    | SubmissionHostViolation::InvalidHostExpression
232            )
233        }) {
234            Some("PSC1092")
235        } else if candidate
236            .violations
237            .contains(&SubmissionHostViolation::MissingSubmissionPlan)
238        {
239            Some("PSC1086")
240        } else if candidate.violations.iter().any(|violation| {
241            matches!(
242                violation,
243                SubmissionHostViolation::CrossComponentForm
244                    | SubmissionHostViolation::ContainedControlForDifferentForm
245            )
246        }) {
247            Some("PSC1093")
248        } else {
249            None
250        };
251        if let Some(code) = code {
252            diagnostics.push(form_diagnostic(
253                code,
254                candidate.provenance.clone(),
255                candidate.id.as_semantic_id().clone(),
256            ));
257        }
258    }
259    for form in model.forms.values() {
260        if !model
261            .reset
262            .plans
263            .contains_key(&crate::ResetPlanId::for_form(&form.id))
264        {
265            diagnostics.push(form_diagnostic(
266                "PSC1090",
267                form.provenance.clone(),
268                form.id.as_semantic_id().clone(),
269            ));
270        }
271    }
272    diagnostics.sort_by(|left, right| {
273        (
274            left.code.as_str(),
275            left.provenance
276                .as_ref()
277                .map(|item| (&item.path, item.span.start)),
278            left.component_id.as_ref(),
279        )
280            .cmp(&(
281                right.code.as_str(),
282                right
283                    .provenance
284                    .as_ref()
285                    .map(|item| (&item.path, item.span.start)),
286                right.component_id.as_ref(),
287            ))
288    });
289    diagnostics
290        .dedup_by(|left, right| left.code == right.code && left.component_id == right.component_id);
291    diagnostics
292}
293
294fn form_diagnostic(
295    code: &str,
296    provenance: crate::SourceProvenance,
297    subject: crate::SemanticId,
298) -> crate::ComponentDiagnostic {
299    let message = FORM_DIAGNOSTIC_RESERVATIONS
300        .iter()
301        .find(|reservation| reservation.code == code)
302        .expect("I18 reserved code")
303        .meaning;
304    crate::ComponentDiagnostic {
305        code: code.to_string(),
306        severity: crate::ComponentDiagnosticSeverity::Error,
307        message: format!("{message}."),
308        provenance: Some(provenance),
309        effect_id: None,
310        statement_id: None,
311        context_declaration_candidate_id: None,
312        context_id: None,
313        provider_id: None,
314        consumer_id: None,
315        slot_id: None,
316        invocation_id: None,
317        component_instance_id: None,
318        slot_binding_id: None,
319        structural_region_id: None,
320        component_id: Some(subject),
321        provider_instance_id: None,
322        consumer_instance_id: None,
323        secondary_labels: Vec::new(),
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use std::collections::BTreeSet;
330
331    use crate::{
332        COMPONENT_DIAGNOSTIC_CONTRACTS, RESUME_MANIFEST_SCHEMA_VERSION,
333        RUNTIME_COMPONENT_ARTIFACT_SCHEMA_VERSION, RUNTIME_CONTEXT_ARTIFACT_SCHEMA_VERSION,
334        SEMANTIC_GRAPH_SCHEMA_VERSION, TEMPLATE_MANIFEST_SCHEMA_VERSION,
335    };
336
337    use super::FORM_DIAGNOSTIC_RESERVATIONS;
338
339    #[test]
340    fn projects_retained_form_candidates_without_runtime_evidence() {
341        let model = crate::build_application_semantic_model(&presolve_parser::parse_file(
342            "src/Duplicate.tsx",
343            r#"@component("duplicate") class Duplicate { @form() profile!: Form; @form() profile!: Form; render() { return <main />; } }"#,
344        ));
345        let diagnostics = super::collect_form_diagnostics(&model);
346        assert!(diagnostics
347            .iter()
348            .all(|diagnostic| diagnostic.code == "PSC1084"));
349        assert_eq!(diagnostics.len(), 2);
350        assert!(diagnostics
351            .iter()
352            .all(|diagnostic| diagnostic.component_id.is_some()));
353    }
354
355    #[test]
356    fn phase_i_entry_reserves_the_next_diagnostic_range_after_the_frozen_h_baseline() {
357        let phase_h_codes = COMPONENT_DIAGNOSTIC_CONTRACTS
358            .iter()
359            .map(|contract| contract.code)
360            .collect::<BTreeSet<_>>();
361        let form_codes = FORM_DIAGNOSTIC_RESERVATIONS
362            .iter()
363            .map(|reservation| reservation.code)
364            .collect::<Vec<_>>();
365
366        assert_eq!(phase_h_codes.last(), Some(&"PSC1083"));
367        assert_eq!(form_codes.first(), Some(&"PSC1084"));
368        assert_eq!(form_codes.last(), Some(&"PSC1095"));
369        assert_eq!(
370            form_codes.len(),
371            form_codes.iter().collect::<BTreeSet<_>>().len()
372        );
373
374        for (offset, reservation) in FORM_DIAGNOSTIC_RESERVATIONS.iter().enumerate() {
375            assert_eq!(reservation.code, format!("PSC{}", 1084 + offset));
376            assert!(!reservation.meaning.is_empty());
377        }
378    }
379
380    #[test]
381    fn phase_i_i17_updates_the_forms_inspection_schema_versions() {
382        assert_eq!(RUNTIME_COMPONENT_ARTIFACT_SCHEMA_VERSION, 4);
383        assert_eq!(RESUME_MANIFEST_SCHEMA_VERSION, 6);
384        assert_eq!(TEMPLATE_MANIFEST_SCHEMA_VERSION, 5);
385        assert_eq!(RUNTIME_CONTEXT_ARTIFACT_SCHEMA_VERSION, 2);
386        assert_eq!(SEMANTIC_GRAPH_SCHEMA_VERSION, 6);
387    }
388}