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