Skip to main content

luau_analyze/
lib.rs

1//! In-process Luau type checking for Rust.
2//!
3//! # Example
4//!
5//! ```no_run
6//! use luau_analyze::Checker;
7//!
8//! let mut checker = Checker::new().expect("checker should initialize");
9//! checker
10//!     .add_definitions(
11//!         r#"
12//!         declare class TodoBuilder
13//!             function content(self, content: string): TodoBuilder
14//!         end
15//!         declare Todo: { create: () -> TodoBuilder }
16//!         "#,
17//!     )
18//!     .expect("definitions should load");
19//!
20//! let result = checker.check(
21//!     r#"
22//!     --!strict
23//!     local _todo = Todo.create():content("review")
24//!     "#,
25//! );
26//! assert!(result.is_ok());
27//! ```
28
29/// Low-level FFI declarations for the Luau analysis bridge.
30mod ffi;
31
32use std::{
33    cmp::Ordering,
34    error::Error as StdError,
35    fmt,
36    marker::PhantomData,
37    ptr::{self, NonNull},
38    slice,
39    sync::Arc,
40    time::Duration,
41};
42
43/// Default module label for source checks.
44const DEFAULT_CHECK_MODULE_NAME: &str = "main";
45/// Default module label for definition loading.
46const DEFAULT_DEFINITIONS_MODULE_NAME: &str = "@definitions";
47
48/// Diagnostic severity emitted by the checker.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
50pub enum Severity {
51    /// Type-check or lint error.
52    Error,
53    /// Lint warning.
54    Warning,
55}
56
57/// A single diagnostic item from checking Luau source.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Diagnostic {
60    /// Zero-based start line.
61    pub line: u32,
62    /// Zero-based start column.
63    pub col: u32,
64    /// Zero-based end line.
65    pub end_line: u32,
66    /// Zero-based end column.
67    pub end_col: u32,
68    /// Severity level.
69    pub severity: Severity,
70    /// Human-readable diagnostic message.
71    pub message: String,
72}
73
74/// Result of a single checker run.
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct CheckResult {
77    /// Collected diagnostics sorted by location and severity.
78    pub diagnostics: Vec<Diagnostic>,
79    /// Whether the check hit one or more time limits.
80    pub timed_out: bool,
81    /// Whether cancellation was requested during checking.
82    pub cancelled: bool,
83}
84
85/// One parameter extracted from a direct functional entrypoint.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct EntrypointParam {
88    /// Parameter name in source order.
89    pub name: String,
90    /// Type annotation text as written.
91    pub annotation: String,
92    /// Whether the parameter is syntactically optional.
93    pub optional: bool,
94}
95
96/// Parsed schema for a direct `return function(...) ... end` chunk.
97#[derive(Debug, Clone, PartialEq, Eq, Default)]
98pub struct EntrypointSchema {
99    /// Ordered parameter list for the returned function literal.
100    pub params: Vec<EntrypointParam>,
101}
102
103impl CheckResult {
104    /// Returns `true` when the result contains no errors.
105    pub fn is_ok(&self) -> bool {
106        !self
107            .diagnostics
108            .iter()
109            .any(|diagnostic| diagnostic.severity == Severity::Error)
110    }
111
112    /// Returns all error diagnostics.
113    pub fn errors(&self) -> Vec<&Diagnostic> {
114        self.diagnostics_with_severity(Severity::Error)
115    }
116
117    /// Returns all warning diagnostics.
118    pub fn warnings(&self) -> Vec<&Diagnostic> {
119        self.diagnostics_with_severity(Severity::Warning)
120    }
121
122    /// Returns all diagnostics matching the requested severity.
123    fn diagnostics_with_severity(&self, severity: Severity) -> Vec<&Diagnostic> {
124        self.diagnostics
125            .iter()
126            .filter(|diagnostic| diagnostic.severity == severity)
127            .collect()
128    }
129
130}
131
132/// Stable checker policy values exposed by this crate.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct CheckerPolicy {
135    /// Whether strict mode is always enforced.
136    pub strict_mode: bool,
137    /// Active solver policy string.
138    pub solver: &'static str,
139    /// Whether batch queue support is exposed by this crate.
140    pub exposes_batch_queue: bool,
141}
142
143/// Returns the current fixed checker policy.
144pub const fn checker_policy() -> CheckerPolicy {
145    CheckerPolicy {
146        strict_mode: true,
147        solver: "new",
148        exposes_batch_queue: false,
149    }
150}
151
152/// Errors returned by checker construction and definition loading.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum Error {
155    /// Checker creation failed in the native layer.
156    CreateCheckerFailed,
157    /// Cancellation token creation failed in the native layer.
158    CreateCancellationTokenFailed,
159    /// Definitions failed to parse or type-check.
160    Definitions(String),
161    /// Entrypoint schema extraction failed.
162    EntrypointSchema(String),
163    /// UTF-8 input is too large for the C ABI length type.
164    InputTooLarge {
165        /// Logical input category such as `"source"` or `"definitions"`.
166        kind: &'static str,
167        /// Original input length in bytes.
168        len: usize,
169    },
170}
171
172impl fmt::Display for Error {
173    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
174        match self {
175            Self::CreateCheckerFailed => formatter.write_str("failed to create Luau checker"),
176            Self::CreateCancellationTokenFailed => {
177                formatter.write_str("failed to create Luau cancellation token")
178            }
179            Self::Definitions(message) => {
180                write!(formatter, "failed to load Luau definitions: {message}")
181            }
182            Self::EntrypointSchema(message) => {
183                write!(
184                    formatter,
185                    "failed to extract Luau entrypoint schema: {message}"
186                )
187            }
188            Self::InputTooLarge { kind, len } => {
189                write!(
190                    formatter,
191                    "{kind} input is too large for checker FFI boundary ({len} bytes)"
192                )
193            }
194        }
195    }
196}
197
198impl StdError for Error {}
199
200/// Default checker configuration used by `Checker`.
201#[derive(Debug, Clone)]
202pub struct CheckerOptions {
203    /// Optional timeout applied to checks that do not override it.
204    pub default_timeout: Option<Duration>,
205    /// Default module label used for source checks.
206    pub default_module_name: String,
207    /// Default module label used for definition loading.
208    pub default_definitions_module_name: String,
209}
210
211impl Default for CheckerOptions {
212    fn default() -> Self {
213        Self {
214            default_timeout: None,
215            default_module_name: DEFAULT_CHECK_MODULE_NAME.to_owned(),
216            default_definitions_module_name: DEFAULT_DEFINITIONS_MODULE_NAME.to_owned(),
217        }
218    }
219}
220
221/// Per-call options for `Checker::check_with_options`.
222#[derive(Debug, Clone, Copy, Default)]
223pub struct CheckOptions<'a> {
224    /// Optional timeout override for this call.
225    pub timeout: Option<Duration>,
226    /// Optional module label override for this call.
227    pub module_name: Option<&'a str>,
228    /// Optional cancellation token for this call.
229    pub cancellation_token: Option<&'a CancellationToken>,
230}
231
232/// A reusable cancellation token that can be signaled from another thread.
233///
234/// `CancellationToken` is `Send` and `Sync` because the underlying Luau implementation
235/// uses atomic operations to manage its signaled state safely across thread boundaries.
236#[derive(Clone, Debug)]
237pub struct CancellationToken {
238    /// Shared token internals.
239    inner: Arc<CancellationTokenInner>,
240}
241
242/// Shared cancellation token internals.
243#[derive(Debug)]
244struct CancellationTokenInner {
245    /// Raw C cancellation token handle.
246    raw: NonNull<ffi::LuauCancellationToken>,
247}
248
249// The underlying C cancellation token uses atomic state and is thread-safe for signal/reset.
250unsafe impl Send for CancellationTokenInner {}
251// The underlying C cancellation token uses atomic state and is thread-safe for signal/reset.
252unsafe impl Sync for CancellationTokenInner {}
253
254impl Drop for CancellationTokenInner {
255    fn drop(&mut self) {
256        // SAFETY: `raw` originates from `luau_cancellation_token_new` and is valid until drop.
257        unsafe { ffi::luau_cancellation_token_free(self.raw.as_ptr()) };
258    }
259}
260
261impl CancellationToken {
262    /// Creates a new cancellation token.
263    pub fn new() -> Result<Self, Error> {
264        // SAFETY: Calling into shim constructor. Null indicates failure.
265        let raw = NonNull::new(unsafe { ffi::luau_cancellation_token_new() })
266            .ok_or(Error::CreateCancellationTokenFailed)?;
267        Ok(Self {
268            inner: Arc::new(CancellationTokenInner { raw }),
269        })
270    }
271
272    /// Requests cancellation on this token.
273    pub fn cancel(&self) {
274        // SAFETY: `raw` is valid while `inner` is alive.
275        unsafe { ffi::luau_cancellation_token_cancel(self.inner.raw.as_ptr()) };
276    }
277
278    /// Clears cancellation state on this token.
279    pub fn reset(&self) {
280        // SAFETY: `raw` is valid while `inner` is alive.
281        unsafe { ffi::luau_cancellation_token_reset(self.inner.raw.as_ptr()) };
282    }
283
284    /// Returns the raw C token pointer.
285    fn raw(&self) -> *mut ffi::LuauCancellationToken {
286        self.inner.raw.as_ptr()
287    }
288}
289
290/// Reusable checker instance with persistent global definitions.
291///
292/// `Checker` is `Send` but not `Sync`. The underlying Luau Analysis structures
293/// are safely movable between threads, but all operations that mutate or read
294/// from the checker require exclusive `&mut self` access, meaning it cannot
295/// be concurrently accessed from multiple threads.
296pub struct Checker {
297    /// Opaque pointer to the native checker instance.
298    inner: NonNull<ffi::LuauChecker>,
299    /// Default checker behavior options.
300    options: CheckerOptions,
301}
302
303// The underlying checker is single-threaded (`&mut self` methods), but ownership can move.
304unsafe impl Send for Checker {}
305
306impl Checker {
307    /// Creates a checker with default options.
308    pub fn new() -> Result<Self, Error> {
309        Self::with_options(CheckerOptions::default())
310    }
311
312    /// Creates a checker with explicit defaults.
313    pub fn with_options(options: CheckerOptions) -> Result<Self, Error> {
314        // SAFETY: Calling into shim constructor. Null indicates failure.
315        let inner =
316            NonNull::new(unsafe { ffi::luau_checker_new() }).ok_or(Error::CreateCheckerFailed)?;
317        Ok(Self { inner, options })
318    }
319
320    /// Returns immutable access to default checker options.
321    pub fn options(&self) -> &CheckerOptions {
322        &self.options
323    }
324
325    /// Loads Luau definition source using default module label.
326    pub fn add_definitions(&mut self, defs: &str) -> Result<(), Error> {
327        let module_name = self.options.default_definitions_module_name.clone();
328        self.add_definitions_with_name(defs, &module_name)
329    }
330
331    /// Loads Luau definition source with an explicit module label.
332    pub fn add_definitions_with_name(
333        &mut self,
334        defs: &str,
335        module_name: &str,
336    ) -> Result<(), Error> {
337        let defs = FfiStr::new(defs, "definitions")?;
338        let module_name = FfiStr::new(module_name, "definition module name")?;
339
340        // SAFETY: Pointers are valid for call duration and checker handle is live.
341        let raw = RawStringGuard::new(unsafe {
342            ffi::luau_checker_add_definitions(
343                self.inner.as_ptr(),
344                defs.ptr(),
345                defs.len(),
346                module_name.ptr(),
347                module_name.len(),
348            )
349        });
350
351        match raw.message() {
352            Some(message) => Err(Error::Definitions(message)),
353            None => Ok(()),
354        }
355    }
356
357    /// Type-checks a Luau source module with default options.
358    pub fn check(&mut self, source: &str) -> Result<CheckResult, Error> {
359        self.check_with_options(source, CheckOptions::default())
360    }
361
362    /// Type-checks a Luau source module with explicit per-call options.
363    pub fn check_with_options(
364        &mut self,
365        source: &str,
366        options: CheckOptions<'_>,
367    ) -> Result<CheckResult, Error> {
368        let source = FfiStr::new(source, "source")?;
369
370        let module_name = options
371            .module_name
372            .unwrap_or(self.options.default_module_name.as_str());
373        let module_name = FfiStr::new(module_name, "module name")?;
374
375        let timeout = options.timeout.or(self.options.default_timeout);
376        let raw_options = ffi::LuauCheckOptions {
377            module_name: module_name.ptr(),
378            module_name_len: module_name.len(),
379            has_timeout: u32::from(timeout.is_some()),
380            timeout_seconds: timeout.map_or(0.0, |duration| duration.as_secs_f64()),
381            cancellation_token: options
382                .cancellation_token
383                .map_or(ptr::null_mut(), CancellationToken::raw),
384        };
385
386        // SAFETY: Input pointers and checker handle are valid for call duration.
387        let raw = unsafe {
388            ffi::luau_checker_check(
389                self.inner.as_ptr(),
390                source.ptr(),
391                source.len(),
392                &raw_options,
393            )
394        };
395        let raw = RawCheckResultGuard::new(raw);
396
397        let mut diagnostics = collect_diagnostics(raw.as_ref());
398
399        diagnostics.sort_by(diagnostic_sort_key);
400        Ok(CheckResult {
401            diagnostics,
402            timed_out: raw.as_ref().timed_out != 0,
403            cancelled: raw.as_ref().cancelled != 0,
404        })
405    }
406}
407
408/// Extracts parameter names, annotation text, and optionality from a direct
409/// `return function(...) ... end` chunk.
410pub fn extract_entrypoint_schema(source: &str) -> Result<EntrypointSchema, Error> {
411    let source = FfiStr::new(source, "source")?;
412
413    // SAFETY: Input pointer is valid for the call duration.
414    let raw = unsafe { ffi::luau_extract_entrypoint_schema(source.ptr(), source.len()) };
415    let raw = RawEntrypointSchemaGuard::new(raw);
416
417    if raw.as_ref().error_len != 0 {
418        return Err(Error::EntrypointSchema(string_from_raw(
419            raw.as_ref().error,
420            raw.as_ref().error_len,
421        )));
422    }
423
424    Ok(EntrypointSchema {
425        params: collect_entrypoint_params(raw.as_ref()),
426    })
427}
428
429impl Drop for Checker {
430    fn drop(&mut self) {
431        // SAFETY: `self.inner` originates from `luau_checker_new` and is valid until drop.
432        unsafe { ffi::luau_checker_free(self.inner.as_ptr()) };
433    }
434}
435
436/// Borrowed UTF-8 input prepared for a C ABI call.
437#[derive(Clone, Copy)]
438struct FfiStr<'a> {
439    /// Pointer to the UTF-8 bytes, or null for empty strings.
440    ptr: *const u8,
441    /// Length of the UTF-8 payload in bytes.
442    len: u32,
443    /// Ties the raw pointer to the borrowed Rust string lifetime.
444    _marker: PhantomData<&'a str>,
445}
446
447impl<'a> FfiStr<'a> {
448    /// Converts a Rust string to a pointer-length pair accepted by the C ABI.
449    fn new(value: &'a str, kind: &'static str) -> Result<Self, Error> {
450        let len = u32::try_from(value.len()).map_err(|_| Error::InputTooLarge {
451            kind,
452            len: value.len(),
453        })?;
454
455        Ok(Self {
456            ptr: if len == 0 {
457                ptr::null()
458            } else {
459                value.as_ptr()
460            },
461            len,
462            _marker: PhantomData,
463        })
464    }
465
466    /// Returns the UTF-8 pointer for the C ABI.
467    fn ptr(self) -> *const u8 {
468        self.ptr
469    }
470
471    /// Returns the UTF-8 byte length for the C ABI.
472    fn len(self) -> u32 {
473        self.len
474    }
475}
476
477/// RAII guard that releases a raw check result on scope exit.
478struct RawCheckResultGuard {
479    /// Raw check result allocated by the shim.
480    raw: ffi::LuauCheckResult,
481}
482
483impl RawCheckResultGuard {
484    /// Creates a guard for a raw check result.
485    fn new(raw: ffi::LuauCheckResult) -> Self {
486        Self { raw }
487    }
488
489    /// Returns a shared reference to the raw check result.
490    fn as_ref(&self) -> &ffi::LuauCheckResult {
491        &self.raw
492    }
493}
494
495impl Drop for RawCheckResultGuard {
496    fn drop(&mut self) {
497        // SAFETY: `raw` came from shim and must be released exactly once.
498        unsafe { ffi::luau_check_result_free(self.raw) };
499    }
500}
501
502/// RAII guard that releases a raw string result on scope exit.
503struct RawStringGuard {
504    /// Raw string result allocated by the shim.
505    raw: ffi::LuauString,
506}
507
508impl RawStringGuard {
509    /// Creates a guard for a raw string result.
510    fn new(raw: ffi::LuauString) -> Self {
511        Self { raw }
512    }
513
514    /// Reads the string payload when the shim returned one.
515    fn message(&self) -> Option<String> {
516        if self.raw.len == 0 {
517            None
518        } else {
519            Some(string_from_raw(self.raw.data, self.raw.len))
520        }
521    }
522}
523
524impl Drop for RawStringGuard {
525    fn drop(&mut self) {
526        // SAFETY: `raw` came from shim and must be released exactly once.
527        unsafe { ffi::luau_string_free(self.raw) };
528    }
529}
530
531/// RAII guard that releases a raw entrypoint schema result on scope exit.
532struct RawEntrypointSchemaGuard {
533    /// Raw entrypoint schema result allocated by the shim.
534    raw: ffi::LuauEntrypointSchemaResult,
535}
536
537impl RawEntrypointSchemaGuard {
538    /// Creates a guard for a raw entrypoint schema result.
539    fn new(raw: ffi::LuauEntrypointSchemaResult) -> Self {
540        Self { raw }
541    }
542
543    /// Returns a shared reference to the raw result.
544    fn as_ref(&self) -> &ffi::LuauEntrypointSchemaResult {
545        &self.raw
546    }
547}
548
549impl Drop for RawEntrypointSchemaGuard {
550    fn drop(&mut self) {
551        // SAFETY: `raw` came from shim and must be released exactly once.
552        unsafe { ffi::luau_entrypoint_schema_result_free(self.raw) };
553    }
554}
555
556/// Converts raw UTF-8 bytes from C into a Rust `String`.
557fn string_from_raw(ptr: *const u8, len: u32) -> String {
558    if ptr.is_null() || len == 0 {
559        return String::new();
560    }
561
562    // SAFETY: `ptr` points to `len` bytes provided by the shim for this call scope.
563    let bytes = unsafe { slice::from_raw_parts(ptr, len as usize) };
564    String::from_utf8_lossy(bytes).into_owned()
565}
566
567impl Severity {
568    /// Converts the shim severity code into the public enum.
569    fn from_ffi(code: u32) -> Self {
570        match code {
571            0 => Self::Error,
572            _ => Self::Warning,
573        }
574    }
575}
576
577/// Converts diagnostic rows owned by the shim into Rust values.
578fn collect_diagnostics(raw: &ffi::LuauCheckResult) -> Vec<Diagnostic> {
579    // SAFETY: `raw.diagnostics` points to `diagnostic_count` entries owned by `raw`.
580    unsafe { raw_slice(raw.diagnostics, raw.diagnostic_count) }
581        .iter()
582        .map(|diagnostic| Diagnostic {
583            line: diagnostic.line,
584            col: diagnostic.col,
585            end_line: diagnostic.end_line,
586            end_col: diagnostic.end_col,
587            severity: Severity::from_ffi(diagnostic.severity),
588            message: string_from_raw(diagnostic.message, diagnostic.message_len),
589        })
590        .collect()
591}
592
593/// Converts entrypoint parameter rows owned by the shim into Rust values.
594fn collect_entrypoint_params(raw: &ffi::LuauEntrypointSchemaResult) -> Vec<EntrypointParam> {
595    // SAFETY: `raw.params` points to `param_count` entries owned by `raw`.
596    unsafe { raw_slice(raw.params, raw.param_count) }
597        .iter()
598        .map(|param| EntrypointParam {
599            name: string_from_raw(param.name, param.name_len),
600            annotation: string_from_raw(param.annotation, param.annotation_len),
601            optional: param.optional != 0,
602        })
603        .collect()
604}
605
606/// Forms a borrowed slice from a non-owning C pointer and element count.
607unsafe fn raw_slice<'a, T>(ptr: *const T, len: u32) -> &'a [T] {
608    if len == 0 {
609        &[]
610    } else {
611        debug_assert!(!ptr.is_null(), "non-empty shim slice must not be null");
612        // SAFETY: The caller guarantees `ptr` is valid for `len` elements.
613        unsafe { slice::from_raw_parts(ptr, len as usize) }
614    }
615}
616
617/// Sorts diagnostics by location, then severity, then message.
618fn diagnostic_sort_key(left: &Diagnostic, right: &Diagnostic) -> Ordering {
619    left.line
620        .cmp(&right.line)
621        .then(left.col.cmp(&right.col))
622        .then(left.severity.cmp(&right.severity))
623        .then(left.message.cmp(&right.message))
624}
625
626/// Unit tests for public result helpers and policy defaults.
627#[cfg(test)]
628mod tests {
629    use super::{
630        CheckResult, CheckerOptions, Diagnostic, Severity, checker_policy,
631        extract_entrypoint_schema,
632    };
633
634    /// Verifies `CheckResult::is_ok` is true for warning-only results.
635    #[test]
636    fn check_result_ok_with_warnings() {
637        let result = CheckResult {
638            diagnostics: vec![Diagnostic {
639                line: 0,
640                col: 0,
641                end_line: 0,
642                end_col: 1,
643                severity: Severity::Warning,
644                message: "unused local".to_owned(),
645            }],
646            timed_out: false,
647            cancelled: false,
648        };
649
650        assert!(result.is_ok());
651        assert_eq!(1, result.warnings().len());
652        assert_eq!(0, result.errors().len());
653    }
654
655    /// Verifies `CheckResult::is_ok` is false when at least one error exists.
656    #[test]
657    fn check_result_not_ok_with_error() {
658        let result = CheckResult {
659            diagnostics: vec![Diagnostic {
660                line: 1,
661                col: 1,
662                end_line: 1,
663                end_col: 5,
664                severity: Severity::Error,
665                message: "type mismatch".to_owned(),
666            }],
667            timed_out: false,
668            cancelled: false,
669        };
670
671        assert!(!result.is_ok());
672        assert_eq!(0, result.warnings().len());
673        assert_eq!(1, result.errors().len());
674    }
675
676    /// Verifies policy constants match project decisions.
677    #[test]
678    fn policy_is_strict_new_solver_and_queue_free() {
679        let policy = checker_policy();
680        assert!(policy.strict_mode);
681        assert_eq!("new", policy.solver);
682        assert!(!policy.exposes_batch_queue);
683    }
684
685    /// Verifies checker options defaults use stable module labels.
686    #[test]
687    fn checker_options_defaults_are_stable() {
688        let options = CheckerOptions::default();
689        assert_eq!("main", options.default_module_name);
690        assert_eq!("@definitions", options.default_definitions_module_name);
691        assert!(options.default_timeout.is_none());
692    }
693
694    /// Verifies schema extraction reads direct function parameters in order.
695    #[test]
696    fn extract_entrypoint_schema_reads_params() {
697        let schema = extract_entrypoint_schema(
698            r#"
699return function(target: Node, count: number?, payload: JsonValue)
700    return nil
701end
702"#,
703        )
704        .expect("schema");
705        assert_eq!(3, schema.params.len());
706        assert_eq!("target", schema.params[0].name);
707        assert_eq!("Node", schema.params[0].annotation);
708        assert!(!schema.params[0].optional);
709        assert_eq!("count", schema.params[1].name);
710        assert_eq!("number?", schema.params[1].annotation);
711        assert!(schema.params[1].optional);
712        assert_eq!("payload", schema.params[2].name);
713        assert_eq!("JsonValue", schema.params[2].annotation);
714        assert!(!schema.params[2].optional);
715    }
716
717    /// Verifies schema extraction rejects indirect entrypoints.
718    #[test]
719    fn extract_entrypoint_schema_rejects_indirect_return() {
720        let error = extract_entrypoint_schema(
721            r#"
722local main = function(target: Node)
723    return nil
724end
725return main
726"#,
727        )
728        .expect_err("schema should fail");
729        assert!(
730            error
731                .to_string()
732                .contains("script must use a direct `return function(...) ... end` entrypoint"),
733            "{error}"
734        );
735    }
736}