Skip to main content

runmat_static_analysis/
frontend.rs

1use runmat_hir::{
2    CallKind, CallSyntax, EnvironmentEffect as HirEnvironmentEffect, FunctionHandleTarget,
3    HirCallableRef, HirDiagnostic, HirDiagnosticSeverity, HirError, LoweringContext,
4    LoweringResult, Span,
5};
6use runmat_mir::{analysis::AnalysisStore, MirAssembly, MirStmtKind};
7use runmat_parser::{CompatMode, ParserOptions};
8use runmat_vm::CompileError;
9use serde::{Deserialize, Serialize};
10
11use crate::lints::shape::lint_shapes_from_mir;
12
13pub const DIAGNOSTIC_UNRESOLVED_FUNCTION: &str = "RM-RES0001";
14pub const DIAGNOSTIC_RUNTIME_DEPENDENT_RESOLUTION: &str = "RM-RES0002";
15pub const DIAGNOSTIC_RUNTIME_METHOD_DISPATCH: &str = "RM-RES0003";
16pub const DIAGNOSTIC_SOURCE_CATALOG: &str = "RM-CAT0001";
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ResolutionState {
21    Resolved,
22    Unresolved,
23    RuntimeDependent,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ResolutionEvidence {
28    pub name: String,
29    pub span: Span,
30    pub state: ResolutionState,
31    pub reason: String,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub definition: Option<runmat_config::project::ProjectSymbolDefinition>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum AnalysisCompleteness {
39    Complete,
40    Partial,
41    RuntimeDependent,
42    Unavailable,
43    NotApplicable,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct AnalysisDomains {
48    pub syntax: AnalysisCompleteness,
49    pub name_resolution: AnalysisCompleteness,
50    pub definite_assignment: AnalysisCompleteness,
51    pub effects: AnalysisCompleteness,
52    pub types: AnalysisCompleteness,
53    pub shapes: AnalysisCompleteness,
54    pub async_safety: AnalysisCompleteness,
55}
56
57impl AnalysisDomains {
58    fn unavailable_after_syntax() -> Self {
59        Self {
60            syntax: AnalysisCompleteness::Complete,
61            name_resolution: AnalysisCompleteness::Unavailable,
62            definite_assignment: AnalysisCompleteness::Unavailable,
63            effects: AnalysisCompleteness::Unavailable,
64            types: AnalysisCompleteness::Unavailable,
65            shapes: AnalysisCompleteness::Unavailable,
66            async_safety: AnalysisCompleteness::Unavailable,
67        }
68    }
69
70    fn unavailable_after_hir() -> Self {
71        Self {
72            syntax: AnalysisCompleteness::Complete,
73            name_resolution: AnalysisCompleteness::Partial,
74            definite_assignment: AnalysisCompleteness::Unavailable,
75            effects: AnalysisCompleteness::Unavailable,
76            types: AnalysisCompleteness::Unavailable,
77            shapes: AnalysisCompleteness::Unavailable,
78            async_safety: AnalysisCompleteness::Unavailable,
79        }
80    }
81
82    fn completed_frontend() -> Self {
83        Self {
84            syntax: AnalysisCompleteness::Complete,
85            name_resolution: AnalysisCompleteness::Complete,
86            definite_assignment: AnalysisCompleteness::Complete,
87            effects: AnalysisCompleteness::Complete,
88            // The facts are useful today, but the comprehensive builtin and
89            // interprocedural propagation audit is tracked separately.
90            types: AnalysisCompleteness::Partial,
91            shapes: AnalysisCompleteness::Partial,
92            async_safety: AnalysisCompleteness::Complete,
93        }
94    }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct ParseFailure {
99    pub message: String,
100    pub position: usize,
101    pub found_token: Option<String>,
102    pub expected: Option<String>,
103}
104
105#[derive(Debug, Clone)]
106pub struct FrontendAnalysis {
107    pub lowering: Option<LoweringResult>,
108    pub mir: Option<MirAssembly>,
109    pub facts: Option<AnalysisStore>,
110    pub diagnostics: Vec<HirDiagnostic>,
111    pub parse_failure: Option<ParseFailure>,
112    pub lowering_failure: Option<HirError>,
113    pub compile_failure: Option<CompileError>,
114    pub bytecode: Option<runmat_vm::Bytecode>,
115    pub resolution: Vec<ResolutionEvidence>,
116    pub domains: AnalysisDomains,
117}
118
119impl FrontendAnalysis {
120    pub fn has_errors(&self) -> bool {
121        self.diagnostics
122            .iter()
123            .any(|diagnostic| diagnostic.severity == HirDiagnosticSeverity::Error)
124    }
125
126    pub fn warning_count(&self) -> usize {
127        self.diagnostics
128            .iter()
129            .filter(|diagnostic| diagnostic.severity == HirDiagnosticSeverity::Warning)
130            .count()
131    }
132}
133
134/// Run the canonical source-local frontend. Project and host resolution inputs
135/// are supplied through `LoweringContext`; CLI, LSP, and runtime adapters are
136/// responsible for constructing that context from the same source catalog.
137pub fn analyze_source(
138    source: &str,
139    compat: CompatMode,
140    lowering_context: &LoweringContext<'_>,
141) -> FrontendAnalysis {
142    analyze_source_with_catalog(source, compat, lowering_context, None)
143}
144
145pub fn analyze_source_with_catalog(
146    source: &str,
147    compat: CompatMode,
148    lowering_context: &LoweringContext<'_>,
149    source_catalog: Option<&runmat_config::project::DiscoveredSourceSymbols>,
150) -> FrontendAnalysis {
151    let ast = match runmat_parser::parse_with_options(source, ParserOptions::new(compat)) {
152        Ok(ast) => ast,
153        Err(error) => {
154            let failure = ParseFailure {
155                message: error.message,
156                position: error.position,
157                found_token: error.found_token,
158                expected: error.expected,
159            };
160            let message = parse_failure_message(&failure);
161            let span = source_position_span(source, failure.position);
162            return FrontendAnalysis {
163                lowering: None,
164                mir: None,
165                facts: None,
166                diagnostics: vec![HirDiagnostic::new(
167                    "RunMat:ParseError",
168                    HirDiagnosticSeverity::Error,
169                    message,
170                    span,
171                )
172                .with_primary_label("syntax error occurs here")
173                .with_category("syntax")],
174                parse_failure: Some(failure),
175                lowering_failure: None,
176                compile_failure: None,
177                bytecode: None,
178                resolution: Vec::new(),
179                domains: AnalysisDomains::unavailable_after_syntax(),
180            };
181        }
182    };
183
184    analyze_program_with_catalog(&ast, lowering_context, source_catalog)
185}
186
187/// Run the canonical frontend from a parsed program. Runtime compilation uses
188/// this entry point after it has appended companion sources to the primary AST;
189/// CLI and LSP use [`analyze_source_with_catalog`] so parsing still happens in
190/// this crate. Every consumer therefore shares lowering, MIR analysis,
191/// resolution auditing, linting, and bytecode compilation.
192pub fn analyze_program_with_catalog(
193    ast: &runmat_parser::Program,
194    lowering_context: &LoweringContext<'_>,
195    source_catalog: Option<&runmat_config::project::DiscoveredSourceSymbols>,
196) -> FrontendAnalysis {
197    let lowering = match runmat_hir::lower(ast, lowering_context) {
198        Ok(lowering) => lowering,
199        Err(error) => {
200            let span = error.span.unwrap_or(Span { start: 0, end: 0 });
201            let code = error
202                .identifier
203                .clone()
204                .unwrap_or_else(|| "RunMat:LoweringError".to_string());
205            return FrontendAnalysis {
206                lowering: None,
207                mir: None,
208                facts: None,
209                diagnostics: vec![HirDiagnostic::new(
210                    code,
211                    HirDiagnosticSeverity::Error,
212                    error.message.clone(),
213                    span,
214                )
215                .with_primary_label("semantic error occurs here")
216                .with_category("semantic")],
217                parse_failure: None,
218                lowering_failure: Some(error),
219                compile_failure: None,
220                bytecode: None,
221                resolution: Vec::new(),
222                domains: AnalysisDomains::unavailable_after_hir(),
223            };
224        }
225    };
226
227    let mir = match runmat_mir::lowering::lower_assembly(&lowering.assembly) {
228        Ok(mir) => mir,
229        Err(error) => {
230            let span = error.span.unwrap_or(Span { start: 0, end: 0 });
231            let code = error
232                .identifier
233                .clone()
234                .unwrap_or_else(|| "RunMat:MirLoweringError".to_string());
235            return FrontendAnalysis {
236                lowering: Some(lowering),
237                mir: None,
238                facts: None,
239                diagnostics: vec![HirDiagnostic::new(
240                    code,
241                    HirDiagnosticSeverity::Error,
242                    error.message.clone(),
243                    span,
244                )
245                .with_primary_label("MIR lowering failed here")
246                .with_category("mir-lowering")],
247                parse_failure: None,
248                lowering_failure: Some(error),
249                compile_failure: None,
250                bytecode: None,
251                resolution: Vec::new(),
252                domains: AnalysisDomains::unavailable_after_hir(),
253            };
254        }
255    };
256
257    let facts = runmat_mir::analysis::analyze_assembly(&mir);
258    let mut diagnostics = facts
259        .diagnostics
260        .iter()
261        // Environment effects are facts. They become diagnostics below only
262        // when they make a concrete subsequent call impossible to resolve.
263        .filter(|diagnostic| diagnostic.code != "RM-MIR0009")
264        .cloned()
265        .collect::<Vec<_>>();
266    diagnostics.extend(lint_shapes_from_mir(&mir, &facts));
267
268    let environment_effects = environment_effects(&mir);
269    let resolution = call_resolution_diagnostics(&lowering, &environment_effects, source_catalog);
270    let runtime_dependent = resolution.runtime_dependent;
271    diagnostics.extend(resolution.diagnostics);
272
273    let compiled = compile_lowering(&lowering, &mir);
274    let compile_failure = compiled.as_ref().err().cloned();
275    if let Some(error) = &compile_failure {
276        diagnostics.push(
277            HirDiagnostic::new(
278                error
279                    .identifier
280                    .clone()
281                    .unwrap_or_else(|| "RunMat:CompileError".to_string()),
282                HirDiagnosticSeverity::Error,
283                error.message.clone(),
284                error.span.unwrap_or(Span { start: 0, end: 0 }),
285            )
286            .with_primary_label("compilation failed here")
287            .with_category("compile"),
288        );
289    }
290
291    diagnostics.sort_by_key(|diagnostic| {
292        (
293            diagnostic.primary.span.start,
294            diagnostic.primary.span.end,
295            diagnostic.code.clone(),
296        )
297    });
298    diagnostics.dedup_by(|left, right| {
299        left.code == right.code
300            && left.primary.span == right.primary.span
301            && left.message == right.message
302    });
303
304    let mut domains = AnalysisDomains::completed_frontend();
305    if runtime_dependent {
306        domains.name_resolution = AnalysisCompleteness::RuntimeDependent;
307    }
308    FrontendAnalysis {
309        lowering: Some(lowering),
310        mir: Some(mir),
311        facts: Some(facts),
312        diagnostics,
313        parse_failure: None,
314        lowering_failure: None,
315        compile_failure,
316        bytecode: compiled.ok(),
317        resolution: resolution.evidence,
318        domains,
319    }
320}
321
322fn compile_lowering(
323    lowering: &LoweringResult,
324    mir: &MirAssembly,
325) -> Result<runmat_vm::Bytecode, CompileError> {
326    let Some(entrypoint) = lowering.assembly.entrypoints.first() else {
327        let bound_functions =
328            runmat_vm::compile_semantic_function_registry(&lowering.assembly, mir)?;
329        let function_registry = runmat_vm::FunctionRegistry::new(bound_functions.clone());
330        let mut bytecode = runmat_vm::Bytecode::empty();
331        bytecode.bound_functions = bound_functions;
332        bytecode.function_registry = function_registry;
333        return Ok(bytecode);
334    };
335    runmat_vm::compile(&lowering.assembly, mir, entrypoint.id)
336}
337
338fn parse_failure_message(failure: &ParseFailure) -> String {
339    let mut message = failure.message.clone();
340    if let Some(expected) = &failure.expected {
341        message.push_str(&format!("; expected {expected}"));
342    }
343    if let Some(found) = &failure.found_token {
344        message.push_str(&format!("; found `{found}`"));
345    }
346    message
347}
348
349fn source_position_span(source: &str, position: usize) -> Span {
350    let start = position.min(source.len());
351    let end = source[start..]
352        .chars()
353        .next()
354        .map(|character| start + character.len_utf8())
355        .unwrap_or(start);
356    Span { start, end }
357}
358
359#[derive(Debug, Clone)]
360struct EnvironmentEffect {
361    span: Span,
362    effect: HirEnvironmentEffect,
363}
364
365fn environment_effects(mir: &MirAssembly) -> Vec<EnvironmentEffect> {
366    let mut effects = Vec::new();
367    for body in mir.bodies.values() {
368        for block in &body.blocks {
369            for statement in &block.statements {
370                if let MirStmtKind::EnvironmentEffect(effect) = &statement.kind {
371                    effects.push(EnvironmentEffect {
372                        span: statement.span,
373                        effect: effect.clone(),
374                    });
375                }
376            }
377        }
378    }
379    effects.sort_by_key(|effect| effect.span.start);
380    effects
381}
382
383struct ResolutionDiagnostics {
384    diagnostics: Vec<HirDiagnostic>,
385    evidence: Vec<ResolutionEvidence>,
386    runtime_dependent: bool,
387}
388
389fn call_resolution_diagnostics(
390    lowering: &LoweringResult,
391    environment_effects: &[EnvironmentEffect],
392    source_catalog: Option<&runmat_config::project::DiscoveredSourceSymbols>,
393) -> ResolutionDiagnostics {
394    let mut diagnostics = Vec::new();
395    let mut evidence = Vec::new();
396    let mut runtime_dependent = false;
397    for call in &lowering.hir_index.calls {
398        let name = call
399            .name
400            .display_name()
401            .unwrap_or_else(|| "<dynamic call>".to_string());
402        if !matches!(call.callee, HirCallableRef::Unresolved(_)) {
403            if let Some(definition) = catalog_definition(source_catalog, &name) {
404                evidence.push(ResolutionEvidence {
405                    name,
406                    span: call.span,
407                    state: ResolutionState::Resolved,
408                    reason: "matched a statically indexed project source".to_string(),
409                    definition: Some(definition.clone()),
410                });
411            }
412            continue;
413        }
414        if !matches!(call.kind, CallKind::Dynamic)
415            || !matches!(call.callee, HirCallableRef::Unresolved(_))
416        {
417            continue;
418        }
419        if matches!(call.syntax, CallSyntax::Method | CallSyntax::DottedInvoke) {
420            runtime_dependent = true;
421            evidence.push(ResolutionEvidence {
422                name,
423                span: call.span,
424                state: ResolutionState::RuntimeDependent,
425                reason: "method dispatch depends on the receiver's runtime class".to_string(),
426                definition: None,
427            });
428            diagnostics.push(
429                HirDiagnostic::new(
430                    DIAGNOSTIC_RUNTIME_METHOD_DISPATCH,
431                    HirDiagnosticSeverity::Warning,
432                    "cannot determine which function is called here",
433                    call.span,
434                )
435                .with_primary_label("the call target is selected at runtime")
436                .with_note(
437                    "method dispatch depends on the runtime class of the receiver expression",
438                )
439                .with_category("call-resolution"),
440            );
441            continue;
442        }
443        let causal_effect = environment_effects
444            .iter()
445            .rev()
446            .find(|effect| effect.span.start < call.span.start);
447        if let Some(effect) = causal_effect {
448            runtime_dependent = true;
449            evidence.push(ResolutionEvidence {
450                name: name.clone(),
451                span: call.span,
452                state: ResolutionState::RuntimeDependent,
453                reason: "a preceding statement changes the runtime function lookup environment"
454                    .to_string(),
455                definition: None,
456            });
457            let effect_label = match effect.effect {
458                HirEnvironmentEffect::PathMutation => {
459                    "this changes where subsequent functions are loaded from"
460                }
461                HirEnvironmentEffect::WorkingDirectoryMutation => {
462                    "this changes the base directory used for subsequent lookup"
463                }
464                HirEnvironmentEffect::FunctionCacheInvalidation => {
465                    "this invalidates cached function lookup"
466                }
467                HirEnvironmentEffect::DynamicLookupInvalidation => {
468                    "this changes subsequent dynamic lookup behavior"
469                }
470            };
471            diagnostics.push(
472                HirDiagnostic::new(
473                    DIAGNOSTIC_RUNTIME_DEPENDENT_RESOLUTION,
474                    HirDiagnosticSeverity::Warning,
475                    format!("cannot resolve `{name}` after a runtime environment change"),
476                    call.span,
477                )
478                .with_primary_label(format!(
479                    "RunMat cannot determine which `{name}.m` will be used"
480                ))
481                .with_secondary(effect.span, effect_label)
482                .with_note(
483                    "all statically known source roots were checked before classifying this call",
484                )
485                .with_category("call-resolution"),
486            );
487        } else {
488            evidence.push(ResolutionEvidence {
489                name: name.clone(),
490                span: call.span,
491                state: ResolutionState::Unresolved,
492                reason: "no matching builtin, local, imported, or indexed project source was found"
493                    .to_string(),
494                definition: None,
495            });
496            diagnostics.push(
497                HirDiagnostic::new(
498                    DIAGNOSTIC_UNRESOLVED_FUNCTION,
499                    HirDiagnosticSeverity::Warning,
500                    format!("cannot find function `{name}`"),
501                    call.span,
502                )
503                .with_primary_label("not defined in this file or project")
504                .with_note(
505                    "RunMat checked built-ins, local functions, imports, and every configured source root",
506                )
507                .with_help(format!(
508                    "place `{name}.m` beside this source or in a source root configured by `runmat.toml`"
509                ))
510                .with_category("call-resolution"),
511            );
512        }
513    }
514    for handle in &lowering.hir_index.function_handles {
515        let FunctionHandleTarget::DynamicName(_) = &handle.target else {
516            continue;
517        };
518        let name = handle
519            .name
520            .display_name()
521            .unwrap_or_else(|| "<dynamic function handle>".to_string());
522        if let Some(effect) = environment_effects
523            .iter()
524            .rev()
525            .find(|effect| effect.span.start < handle.span.start)
526        {
527            runtime_dependent = true;
528            evidence.push(ResolutionEvidence {
529                name: name.clone(),
530                span: handle.span,
531                state: ResolutionState::RuntimeDependent,
532                reason: "a preceding statement changes the runtime function lookup environment"
533                    .to_string(),
534                definition: None,
535            });
536            diagnostics.push(
537                HirDiagnostic::new(
538                    DIAGNOSTIC_RUNTIME_DEPENDENT_RESOLUTION,
539                    HirDiagnosticSeverity::Warning,
540                    format!("cannot resolve function handle `@{name}` after a runtime environment change"),
541                    handle.span,
542                )
543                .with_primary_label(format!(
544                    "RunMat cannot determine which `{name}.m` this handle will reference"
545                ))
546                .with_secondary(
547                    effect.span,
548                    "this changes the function lookup environment used by the handle",
549                )
550                .with_category("call-resolution"),
551            );
552        } else {
553            evidence.push(ResolutionEvidence {
554                name: name.clone(),
555                span: handle.span,
556                state: ResolutionState::Unresolved,
557                reason: "no matching builtin, local, imported, or indexed project source was found"
558                    .to_string(),
559                definition: None,
560            });
561            diagnostics.push(
562                HirDiagnostic::new(
563                    DIAGNOSTIC_UNRESOLVED_FUNCTION,
564                    HirDiagnosticSeverity::Warning,
565                    format!("cannot find function referenced by `@{name}`"),
566                    handle.span,
567                )
568                .with_primary_label("the named function is not defined in this file or project")
569                .with_note(
570                    "RunMat checked built-ins, local functions, imports, and every configured source root",
571                )
572                .with_help(format!(
573                    "place `{name}.m` beside this source or in a source root configured by `runmat.toml`"
574                ))
575                .with_category("call-resolution"),
576            );
577        }
578    }
579    ResolutionDiagnostics {
580        diagnostics,
581        evidence,
582        runtime_dependent,
583    }
584}
585
586fn catalog_definition<'a>(
587    catalog: Option<&'a runmat_config::project::DiscoveredSourceSymbols>,
588    name: &str,
589) -> Option<&'a runmat_config::project::ProjectSymbolDefinition> {
590    catalog?
591        .definitions
592        .iter()
593        .find(|definition| definition.name == name)
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use std::collections::HashSet;
600    use std::path::PathBuf;
601
602    fn analyze(source: &str) -> FrontendAnalysis {
603        analyze_source(source, CompatMode::default(), &LoweringContext::empty())
604    }
605
606    #[test]
607    fn reports_parse_failures_as_structured_errors() {
608        let analysis = analyze("x = ;");
609        assert!(analysis.has_errors());
610        assert_eq!(analysis.diagnostics[0].code, "RunMat:ParseError");
611        assert_eq!(
612            analysis.domains.name_resolution,
613            AnalysisCompleteness::Unavailable
614        );
615    }
616
617    #[test]
618    fn reports_unresolved_bare_calls_without_rejecting_compilation() {
619        let analysis = analyze("x = definitely_missing(42);");
620        assert!(analysis.compile_failure.is_none());
621        assert!(analysis
622            .diagnostics
623            .iter()
624            .any(|diagnostic| diagnostic.code == DIAGNOSTIC_UNRESOLVED_FUNCTION));
625    }
626
627    #[test]
628    fn resolves_known_project_functions() {
629        let symbols = HashSet::from(["helper".to_string()]);
630        let context = LoweringContext::empty().with_known_project_symbols(&symbols);
631        let analysis = analyze_source("x = helper(42);", CompatMode::default(), &context);
632        assert!(!analysis
633            .diagnostics
634            .iter()
635            .any(|diagnostic| diagnostic.code == DIAGNOSTIC_UNRESOLVED_FUNCTION));
636    }
637
638    #[test]
639    fn preserves_the_project_definition_that_justified_resolution() {
640        let symbols = HashSet::from(["helper".to_string()]);
641        let definition = runmat_config::project::ProjectSymbolDefinition {
642            name: "helper".to_string(),
643            qualified_name: "helper".to_string(),
644            source_path: PathBuf::from("src/helper.m"),
645            package_name: "demo".to_string(),
646            is_private: false,
647        };
648        let catalog = runmat_config::project::DiscoveredSourceSymbols {
649            manifest_path: Some(PathBuf::from("runmat.toml")),
650            project_root: PathBuf::from("."),
651            symbols: symbols.clone(),
652            definitions: vec![definition.clone()],
653        };
654        let context = LoweringContext::empty().with_known_project_symbols(&symbols);
655        let analysis = analyze_source_with_catalog(
656            "x = helper(42);",
657            CompatMode::default(),
658            &context,
659            Some(&catalog),
660        );
661        assert_eq!(
662            analysis.resolution,
663            vec![ResolutionEvidence {
664                name: "helper".to_string(),
665                span: analysis.resolution[0].span,
666                state: ResolutionState::Resolved,
667                reason: "matched a statically indexed project source".to_string(),
668                definition: Some(definition),
669            }]
670        );
671    }
672
673    #[test]
674    fn includes_mir_and_shape_diagnostics() {
675        let analysis = analyze("a = ones(2,3); b = ones(4,2); c = a * b;");
676        assert!(analysis
677            .diagnostics
678            .iter()
679            .any(|diagnostic| diagnostic.code == "lint.shape.matmul"));
680    }
681
682    #[test]
683    fn distinguishes_runtime_path_mutation_from_an_unresolved_static_call() {
684        let analysis = analyze("addpath('plugins'); value = selected_at_runtime(1);");
685        assert!(analysis
686            .diagnostics
687            .iter()
688            .any(|diagnostic| diagnostic.code == DIAGNOSTIC_RUNTIME_DEPENDENT_RESOLUTION));
689        assert_eq!(
690            analysis.domains.name_resolution,
691            AnalysisCompleteness::RuntimeDependent
692        );
693    }
694
695    #[test]
696    fn a_later_path_mutation_does_not_explain_an_earlier_missing_call() {
697        let analysis = analyze("value = still_missing(1); addpath('plugins');");
698        assert!(analysis
699            .diagnostics
700            .iter()
701            .any(|diagnostic| diagnostic.code == DIAGNOSTIC_UNRESOLVED_FUNCTION));
702        assert!(!analysis
703            .diagnostics
704            .iter()
705            .any(|diagnostic| diagnostic.code == DIAGNOSTIC_RUNTIME_DEPENDENT_RESOLUTION));
706    }
707
708    #[test]
709    fn unresolved_method_dispatch_is_reported_as_runtime_dependent() {
710        let analysis = analyze("receiver = struct(); value = receiver.compute(1);");
711        assert!(analysis
712            .diagnostics
713            .iter()
714            .any(|diagnostic| diagnostic.code == DIAGNOSTIC_RUNTIME_METHOD_DISPATCH));
715    }
716
717    #[test]
718    fn builtins_do_not_produce_missing_function_diagnostics() {
719        let analysis = analyze("value = sin(1);");
720        assert!(!analysis
721            .diagnostics
722            .iter()
723            .any(|diagnostic| diagnostic.category.as_deref() == Some("call-resolution")));
724    }
725
726    #[test]
727    fn unresolved_function_handles_use_the_same_resolution_policy() {
728        let missing = analyze("handle = @definitely_missing;");
729        assert!(missing
730            .diagnostics
731            .iter()
732            .any(|diagnostic| diagnostic.code == DIAGNOSTIC_UNRESOLVED_FUNCTION));
733
734        let symbols = HashSet::from(["project.helper".to_string()]);
735        let context = LoweringContext::empty().with_known_project_symbols(&symbols);
736        let resolved = analyze_source("handle = @project.helper;", CompatMode::default(), &context);
737        assert!(!resolved
738            .diagnostics
739            .iter()
740            .any(|diagnostic| diagnostic.code == DIAGNOSTIC_UNRESOLVED_FUNCTION));
741    }
742}