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