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