Skip to main content

presolve_compiler/
authored_semantics.rs

1//! Canonical authored semantics, normalized at the syntax/TypeScript boundary.
2//!
3//! The parser owns source syntax and the TypeScript-authority package owns
4//! resolved symbols. This module accepts the product of joining those two
5//! boundaries; it deliberately does not inspect intrinsic spelling or import
6//! paths. Legacy decorator extraction is a separate lowering concern.
7
8use std::path::PathBuf;
9
10use presolve_parser::ParsedFile;
11use serde::{Deserialize, Serialize};
12
13pub const CANONICAL_AUTHORED_SEMANTICS_SCHEMA_VERSION: u32 = 5;
14
15/// A serializable source range shared by the syntax and semantic boundaries.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17pub struct AuthoredSourceRangeV1 {
18    pub start: usize,
19    pub end: usize,
20    pub line: usize,
21    pub column: usize,
22}
23
24/// A resolved declaration identity supplied by the TypeScript authority adapter.
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
26pub struct ResolvedIntrinsicIdentityV1 {
27    pub name: String,
28    pub flags: u32,
29    pub declaration_modules: Vec<String>,
30}
31
32/// The canonical intrinsic classification of a syntax-selected use site.
33///
34/// `kind` is only produced by the resolved-identity registry. It must never be
35/// inferred from source spelling by a compiler consumer.
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum CanonicalIntrinsicKindV1 {
39    Component,
40    State,
41    Action,
42    Computed,
43    Effect,
44    Slot,
45    Context,
46    Provide,
47    Consume,
48    Form,
49    Serialize,
50    Field,
51    Validate,
52    Submit,
53    Resource,
54    Loader,
55    ServerAction,
56    Opaque,
57}
58
59/// The authority-backed basis for one syntax-selected semantic candidate.
60///
61/// Intrinsics require a resolved framework identity. TSX bindings and event
62/// references are syntax facts whose expression/type validation is supplied by
63/// TypeScript queries, but they are not framework intrinsics themselves.
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum AuthoredSemanticCandidateKindV1 {
67    ResolvedIntrinsic {
68        intrinsic_kind: CanonicalIntrinsicKindV1,
69        intrinsic_identity: ResolvedIntrinsicIdentityV1,
70    },
71    /// A non-intrinsic getter admitted by compiler-owned reactive/purity
72    /// analysis. Its evidence is explicit because no framework symbol exists.
73    DerivedComputedGetter {
74        state_dependencies: Vec<String>,
75        computed_dependencies: Vec<String>,
76    },
77    /// A module export whose value shape TypeScript proved implements
78    /// Standard Schema v1 for the owning Form Field.
79    DerivedStandardSchemaValidation {
80        module_specifier: String,
81        export_name: String,
82        declaration_modules: Vec<String>,
83        input_type: Option<String>,
84        output_type: Option<String>,
85    },
86    TsxBinding,
87    TsxEventReference,
88}
89
90/// Evidence retained for a non-intrinsic declaration admitted by compiler
91/// analysis rather than resolved framework-symbol identity.
92#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum DerivedAuthoredEvidenceV2 {
95    ComputedGetter {
96        state_dependencies: Vec<String>,
97        computed_dependencies: Vec<String>,
98    },
99    /// TypeScript-authoritative proof that a canonical Form Field value is an
100    /// array of the platform `File` type from the configured DOM library.
101    FormFieldFileArray,
102    StandardSchemaValidation {
103        module_specifier: String,
104        export_name: String,
105        declaration_modules: Vec<String>,
106        input_type: Option<String>,
107        output_type: Option<String>,
108    },
109}
110
111/// One candidate selected from the general source AST and checked by the
112/// TypeScript authority adapter.
113#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
114pub struct ResolvedAuthoredSemanticCandidateV1 {
115    /// The source declaration or use-site being described. This is data for
116    /// tooling and stable snapshots, not an identity authority.
117    pub subject: String,
118    pub source: AuthoredSourceRangeV1,
119    pub kind: AuthoredSemanticCandidateKindV1,
120}
121
122/// A normalized authored declaration which later compiler products extend.
123#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
124pub struct CanonicalAuthoredDeclarationV1 {
125    pub kind: CanonicalAuthoredDeclarationKindV1,
126    pub subject: String,
127    pub source: AuthoredSourceRangeV1,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub intrinsic_identity: Option<ResolvedIntrinsicIdentityV1>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub derived_evidence: Option<DerivedAuthoredEvidenceV2>,
132}
133
134/// The source-independent vocabulary emitted at the authored-semantics
135/// boundary. Each case records a framework meaning, not legacy syntax.
136#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum CanonicalAuthoredDeclarationKindV1 {
139    Component,
140    State,
141    Action,
142    Computed,
143    Effect,
144    Slot,
145    ContextToken,
146    ContextProvider,
147    ContextConsumer,
148    Form,
149    Serialization,
150    FormField,
151    Validation,
152    Submission,
153    Resource,
154    RouteLoader,
155    ServerAction,
156    Capability,
157    TsxBinding,
158    TsxEventReference,
159}
160
161/// The canonical output of syntax selection plus resolved intrinsic identity.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct CanonicalAuthoredSemanticModelV1 {
164    pub schema_version: u32,
165    pub source_path: PathBuf,
166    pub declarations: Vec<CanonicalAuthoredDeclarationV1>,
167}
168
169/// A boundary violation while normalizing authored semantic candidates.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum AuthoredSemanticNormalizationErrorV1 {
172    InvalidSourceRange {
173        subject: String,
174        start: usize,
175        end: usize,
176        source_length: usize,
177    },
178}
179
180impl std::fmt::Display for AuthoredSemanticNormalizationErrorV1 {
181    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        match self {
183            Self::InvalidSourceRange {
184                subject,
185                start,
186                end,
187                source_length,
188            } => write!(
189                formatter,
190                "authored semantic candidate `{subject}` has invalid source range {start}..{end} for source length {source_length}"
191            ),
192        }
193    }
194}
195
196impl std::error::Error for AuthoredSemanticNormalizationErrorV1 {}
197
198/// A boundary violation while composing independently lowered source forms.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum AuthoredSemanticCompositionErrorV1 {
201    Empty,
202    SchemaVersion { actual: u32 },
203    SourcePathMismatch { expected: PathBuf, actual: PathBuf },
204}
205
206impl std::fmt::Display for AuthoredSemanticCompositionErrorV1 {
207    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        match self {
209            Self::Empty => write!(formatter, "cannot compose zero authored semantic models"),
210            Self::SchemaVersion { actual } => write!(
211                formatter,
212                "cannot compose authored semantic schema version {actual}; expected {CANONICAL_AUTHORED_SEMANTICS_SCHEMA_VERSION}"
213            ),
214            Self::SourcePathMismatch { expected, actual } => write!(
215                formatter,
216                "cannot compose authored semantic models from {} and {}",
217                expected.display(),
218                actual.display()
219            ),
220        }
221    }
222}
223
224impl std::error::Error for AuthoredSemanticCompositionErrorV1 {}
225
226/// Normalize already-resolved syntax candidates for one parser product.
227///
228/// This is intentionally the first point where the two V2 authorities meet:
229/// `ParsedFile::syntax` supplies the source extent, while callers supply a
230/// resolved intrinsic classification from `typescript-authority`. No source
231/// text, decorator name, or module specifier is used for recognition here.
232pub fn normalize_authored_semantics_v1(
233    parsed: &ParsedFile,
234    candidates: impl IntoIterator<Item = ResolvedAuthoredSemanticCandidateV1>,
235) -> Result<CanonicalAuthoredSemanticModelV1, AuthoredSemanticNormalizationErrorV1> {
236    let source_length = parsed.syntax.source.len();
237    let mut declarations = candidates
238        .into_iter()
239        .map(|candidate| {
240            if candidate.source.start > candidate.source.end || candidate.source.end > source_length
241            {
242                return Err(AuthoredSemanticNormalizationErrorV1::InvalidSourceRange {
243                    subject: candidate.subject,
244                    start: candidate.source.start,
245                    end: candidate.source.end,
246                    source_length,
247                });
248            }
249            let (kind, mut intrinsic_identity, mut derived_evidence) =
250                declaration_kind(candidate.kind);
251            if let Some(identity) = &mut intrinsic_identity {
252                identity.declaration_modules.sort();
253                identity.declaration_modules.dedup();
254            }
255            if let Some(DerivedAuthoredEvidenceV2::ComputedGetter {
256                state_dependencies,
257                computed_dependencies,
258            }) = &mut derived_evidence
259            {
260                state_dependencies.sort();
261                state_dependencies.dedup();
262                computed_dependencies.sort();
263                computed_dependencies.dedup();
264            }
265            if let Some(DerivedAuthoredEvidenceV2::StandardSchemaValidation {
266                declaration_modules,
267                ..
268            }) = &mut derived_evidence
269            {
270                declaration_modules.sort();
271                declaration_modules.dedup();
272            }
273            Ok(CanonicalAuthoredDeclarationV1 {
274                kind,
275                subject: candidate.subject,
276                source: candidate.source,
277                intrinsic_identity,
278                derived_evidence,
279            })
280        })
281        .collect::<Result<Vec<_>, _>>()?;
282
283    declarations.sort();
284    declarations.dedup();
285    Ok(CanonicalAuthoredSemanticModelV1 {
286        schema_version: CANONICAL_AUTHORED_SEMANTICS_SCHEMA_VERSION,
287        source_path: parsed.path.clone(),
288        declarations,
289    })
290}
291
292/// Compose independently lowered source forms for one source file.
293///
294/// Each input has already crossed the source-AST and TypeScript-authority
295/// boundary. This function only verifies a common schema/path and restores the
296/// canonical deterministic ordering and deduplication rule; it never assigns
297/// framework meaning from source spelling.
298pub fn compose_authored_semantics_v1(
299    models: impl IntoIterator<Item = CanonicalAuthoredSemanticModelV1>,
300) -> Result<CanonicalAuthoredSemanticModelV1, AuthoredSemanticCompositionErrorV1> {
301    let mut models = models.into_iter();
302    let first = models
303        .next()
304        .ok_or(AuthoredSemanticCompositionErrorV1::Empty)?;
305    if first.schema_version != CANONICAL_AUTHORED_SEMANTICS_SCHEMA_VERSION {
306        return Err(AuthoredSemanticCompositionErrorV1::SchemaVersion {
307            actual: first.schema_version,
308        });
309    }
310    let source_path = first.source_path.clone();
311    let mut declarations = first.declarations;
312    for model in models {
313        if model.schema_version != CANONICAL_AUTHORED_SEMANTICS_SCHEMA_VERSION {
314            return Err(AuthoredSemanticCompositionErrorV1::SchemaVersion {
315                actual: model.schema_version,
316            });
317        }
318        if model.source_path != source_path {
319            return Err(AuthoredSemanticCompositionErrorV1::SourcePathMismatch {
320                expected: source_path,
321                actual: model.source_path,
322            });
323        }
324        declarations.extend(model.declarations);
325    }
326    declarations.sort();
327    declarations.dedup();
328    Ok(CanonicalAuthoredSemanticModelV1 {
329        schema_version: CANONICAL_AUTHORED_SEMANTICS_SCHEMA_VERSION,
330        source_path,
331        declarations,
332    })
333}
334
335fn declaration_kind(
336    kind: AuthoredSemanticCandidateKindV1,
337) -> (
338    CanonicalAuthoredDeclarationKindV1,
339    Option<ResolvedIntrinsicIdentityV1>,
340    Option<DerivedAuthoredEvidenceV2>,
341) {
342    let AuthoredSemanticCandidateKindV1::ResolvedIntrinsic {
343        intrinsic_kind,
344        intrinsic_identity,
345    } = kind
346    else {
347        return match kind {
348            AuthoredSemanticCandidateKindV1::DerivedComputedGetter {
349                state_dependencies,
350                computed_dependencies,
351            } => (
352                CanonicalAuthoredDeclarationKindV1::Computed,
353                None,
354                Some(DerivedAuthoredEvidenceV2::ComputedGetter {
355                    state_dependencies,
356                    computed_dependencies,
357                }),
358            ),
359            AuthoredSemanticCandidateKindV1::DerivedStandardSchemaValidation {
360                module_specifier,
361                export_name,
362                declaration_modules,
363                input_type,
364                output_type,
365            } => (
366                CanonicalAuthoredDeclarationKindV1::Validation,
367                None,
368                Some(DerivedAuthoredEvidenceV2::StandardSchemaValidation {
369                    module_specifier,
370                    export_name,
371                    declaration_modules,
372                    input_type,
373                    output_type,
374                }),
375            ),
376            AuthoredSemanticCandidateKindV1::TsxBinding => {
377                (CanonicalAuthoredDeclarationKindV1::TsxBinding, None, None)
378            }
379            AuthoredSemanticCandidateKindV1::TsxEventReference => (
380                CanonicalAuthoredDeclarationKindV1::TsxEventReference,
381                None,
382                None,
383            ),
384            AuthoredSemanticCandidateKindV1::ResolvedIntrinsic { .. } => unreachable!(),
385        };
386    };
387
388    let declaration_kind = match intrinsic_kind {
389        CanonicalIntrinsicKindV1::Component => CanonicalAuthoredDeclarationKindV1::Component,
390        CanonicalIntrinsicKindV1::State => CanonicalAuthoredDeclarationKindV1::State,
391        CanonicalIntrinsicKindV1::Action => CanonicalAuthoredDeclarationKindV1::Action,
392        CanonicalIntrinsicKindV1::Computed => CanonicalAuthoredDeclarationKindV1::Computed,
393        CanonicalIntrinsicKindV1::Effect => CanonicalAuthoredDeclarationKindV1::Effect,
394        CanonicalIntrinsicKindV1::Slot => CanonicalAuthoredDeclarationKindV1::Slot,
395        CanonicalIntrinsicKindV1::Context => CanonicalAuthoredDeclarationKindV1::ContextToken,
396        CanonicalIntrinsicKindV1::Provide => CanonicalAuthoredDeclarationKindV1::ContextProvider,
397        CanonicalIntrinsicKindV1::Consume => CanonicalAuthoredDeclarationKindV1::ContextConsumer,
398        CanonicalIntrinsicKindV1::Form => CanonicalAuthoredDeclarationKindV1::Form,
399        CanonicalIntrinsicKindV1::Serialize => CanonicalAuthoredDeclarationKindV1::Serialization,
400        CanonicalIntrinsicKindV1::Field => CanonicalAuthoredDeclarationKindV1::FormField,
401        CanonicalIntrinsicKindV1::Validate => CanonicalAuthoredDeclarationKindV1::Validation,
402        CanonicalIntrinsicKindV1::Submit => CanonicalAuthoredDeclarationKindV1::Submission,
403        CanonicalIntrinsicKindV1::Resource => CanonicalAuthoredDeclarationKindV1::Resource,
404        CanonicalIntrinsicKindV1::Loader => CanonicalAuthoredDeclarationKindV1::RouteLoader,
405        CanonicalIntrinsicKindV1::ServerAction => CanonicalAuthoredDeclarationKindV1::ServerAction,
406        CanonicalIntrinsicKindV1::Opaque => CanonicalAuthoredDeclarationKindV1::Capability,
407    };
408    (declaration_kind, Some(intrinsic_identity), None)
409}
410
411#[cfg(test)]
412mod tests {
413    use presolve_parser::parse_file;
414
415    use super::{
416        compose_authored_semantics_v1, normalize_authored_semantics_v1,
417        AuthoredSemanticCandidateKindV1, AuthoredSemanticCompositionErrorV1,
418        AuthoredSemanticNormalizationErrorV1, AuthoredSourceRangeV1,
419        CanonicalAuthoredDeclarationKindV1, CanonicalIntrinsicKindV1,
420        ResolvedAuthoredSemanticCandidateV1, ResolvedIntrinsicIdentityV1,
421    };
422
423    fn candidate(
424        subject: &str,
425        start: usize,
426        kind: CanonicalIntrinsicKindV1,
427    ) -> ResolvedAuthoredSemanticCandidateV1 {
428        ResolvedAuthoredSemanticCandidateV1 {
429            subject: subject.to_owned(),
430            source: AuthoredSourceRangeV1 {
431                start,
432                end: start + 5,
433                line: 1,
434                column: start + 1,
435            },
436            kind: AuthoredSemanticCandidateKindV1::ResolvedIntrinsic {
437                intrinsic_kind: kind,
438                intrinsic_identity: ResolvedIntrinsicIdentityV1 {
439                    name: "renamedFrameworkExport".to_owned(),
440                    flags: 2_097_152,
441                    declaration_modules: vec![
442                        "node_modules/@presolve/framework/index.d.ts".to_owned()
443                    ],
444                },
445            },
446        }
447    }
448
449    #[test]
450    fn normalizes_resolved_candidates_without_using_source_spelling() {
451        let parsed = parse_file("src/Card.tsx", "const Card = frameworkUse();");
452        let state = candidate("Card.count", 20, CanonicalIntrinsicKindV1::State);
453        let component = candidate("Card", 6, CanonicalIntrinsicKindV1::Component);
454
455        let model =
456            normalize_authored_semantics_v1(&parsed, [state.clone(), component.clone(), state])
457                .expect("valid resolved candidates");
458
459        assert_eq!(model.schema_version, 5);
460        assert_eq!(model.declarations.len(), 2);
461        assert_eq!(model.declarations[0].subject, "Card");
462        assert_eq!(
463            model.declarations[0].kind,
464            CanonicalAuthoredDeclarationKindV1::Component
465        );
466        assert_eq!(model.declarations[1].subject, "Card.count");
467        assert_eq!(
468            model.declarations[1].kind,
469            CanonicalAuthoredDeclarationKindV1::State
470        );
471        assert_eq!(
472            model.declarations[1]
473                .intrinsic_identity
474                .as_ref()
475                .unwrap()
476                .name,
477            "renamedFrameworkExport"
478        );
479        assert_eq!(
480            serde_json::to_value(&model).expect("serializable model"),
481            serde_json::json!({
482                "schema_version": 5,
483                "source_path": "src/Card.tsx",
484                "declarations": [
485                    {
486                        "kind": "component",
487                        "subject": "Card",
488                        "source": { "start": 6, "end": 11, "line": 1, "column": 7 },
489                        "intrinsic_identity": {
490                            "name": "renamedFrameworkExport",
491                            "flags": 2_097_152,
492                            "declaration_modules": ["node_modules/@presolve/framework/index.d.ts"]
493                        }
494                    },
495                    {
496                        "kind": "state",
497                        "subject": "Card.count",
498                        "source": { "start": 20, "end": 25, "line": 1, "column": 21 },
499                        "intrinsic_identity": {
500                            "name": "renamedFrameworkExport",
501                            "flags": 2_097_152,
502                            "declaration_modules": ["node_modules/@presolve/framework/index.d.ts"]
503                        }
504                    }
505                ]
506            })
507        );
508    }
509
510    #[test]
511    fn composes_same_source_models_and_rejects_cross_source_mixing() {
512        let parsed = parse_file("src/Card.tsx", "const Card = frameworkUse();");
513        let component = normalize_authored_semantics_v1(
514            &parsed,
515            [candidate("Card", 6, CanonicalIntrinsicKindV1::Component)],
516        )
517        .unwrap();
518        let state = normalize_authored_semantics_v1(
519            &parsed,
520            [candidate("Card.count", 20, CanonicalIntrinsicKindV1::State)],
521        )
522        .unwrap();
523        let composed = compose_authored_semantics_v1([component.clone(), state]).unwrap();
524        assert_eq!(composed.declarations.len(), 2);
525
526        let other = normalize_authored_semantics_v1(
527            &parse_file("src/Other.tsx", "const Other = frameworkUse();"),
528            [candidate("Other", 6, CanonicalIntrinsicKindV1::Component)],
529        )
530        .unwrap();
531        assert!(matches!(
532            compose_authored_semantics_v1([component, other]),
533            Err(AuthoredSemanticCompositionErrorV1::SourcePathMismatch { .. })
534        ));
535    }
536
537    #[test]
538    fn retains_tsx_binding_and_event_facts_without_an_intrinsic_identity() {
539        let parsed = parse_file(
540            "src/Card.tsx",
541            "const Card = <button onClick={save}>{count}</button>;",
542        );
543        let binding = ResolvedAuthoredSemanticCandidateV1 {
544            subject: "count".to_owned(),
545            source: AuthoredSourceRangeV1 {
546                start: 44,
547                end: 49,
548                line: 1,
549                column: 45,
550            },
551            kind: AuthoredSemanticCandidateKindV1::TsxBinding,
552        };
553        let event = ResolvedAuthoredSemanticCandidateV1 {
554            subject: "save".to_owned(),
555            source: AuthoredSourceRangeV1 {
556                start: 37,
557                end: 41,
558                line: 1,
559                column: 38,
560            },
561            kind: AuthoredSemanticCandidateKindV1::TsxEventReference,
562        };
563
564        let model = normalize_authored_semantics_v1(&parsed, [binding, event])
565            .expect("TSX syntax candidates fit the source AST");
566
567        assert_eq!(model.declarations.len(), 2);
568        assert!(model.declarations.iter().any(|declaration| {
569            declaration.kind == CanonicalAuthoredDeclarationKindV1::TsxBinding
570                && declaration.intrinsic_identity.is_none()
571        }));
572        assert!(model.declarations.iter().any(|declaration| {
573            declaration.kind == CanonicalAuthoredDeclarationKindV1::TsxEventReference
574                && declaration.intrinsic_identity.is_none()
575        }));
576    }
577
578    #[test]
579    fn rejects_candidates_outside_the_general_source_ast_extent() {
580        let parsed = parse_file("src/Card.tsx", "const Card = 1;");
581        let error = normalize_authored_semantics_v1(
582            &parsed,
583            [candidate("Card", 99, CanonicalIntrinsicKindV1::Component)],
584        )
585        .expect_err("invalid range must not enter the canonical model");
586
587        assert!(matches!(
588            error,
589            AuthoredSemanticNormalizationErrorV1::InvalidSourceRange { subject, .. }
590                if subject == "Card"
591        ));
592    }
593}