From 044c8047f65d77b684328585960d5f3255aa7cd3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:39:41 -0800 Subject: [PATCH] Scripts: an error in code an earlier #targetengine run compiled names that run's file and line A function an earlier run of a named engine defined was reported without a line, column or file when it threw in a later run. The realm now gives each source a tag that stays the same from run to run (Realm::error_tag, Realm::script_tag), and no two sources share one once the tags run out. Each named engine keeps the source maps of its runs' scripts, included files and $.evalFile files, within 8 MiB, dropping the oldest first, and an error in kept code is reported at that file, line and column. The report's name and the console line keep the calling run's name. For an earlier script given as source, file is that run's name. Docs: docs/mcp.md (Script engines), script.run params text, docs/target-app-parity.md. --- crates/engine/src/cmd/scripts.rs | 2 +- crates/engine/src/script/include.rs | 7 + crates/engine/src/script/mod.rs | 173 ++++++++++++++++++---- crates/engine/src/tests/script_engines.rs | 112 +++++++++++++- crates/js/src/host.rs | 2 +- crates/js/src/host_tests.rs | 31 ++++ crates/js/src/realm.rs | 37 ++++- docs/mcp.md | 9 +- docs/target-app-parity.md | 1 + 9 files changed, 335 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 8567a7ec21..8c86f0dfe3 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; an error in code an earlier run compiled is at that run's file, line and column, within 8 MiB of source maps kept for each engine, the oldest going first; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script, or in code an earlier run of its engine compiled), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index ce2e1d6783..4ec68dbbde 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -88,6 +88,13 @@ impl SourceMap { let name = self.files.get(seg.file).cloned().unwrap_or_else(|| Arc::from("")); (seg.file, name, seg.line.saturating_add(line - seg.out_line), column) } + + /// The memory the map holds, in bytes: its lists and its file names, each name counted in full + /// even when another map shares it. + pub(crate) fn bytes(&self) -> usize { + let names: usize = self.files.iter().map(|f| f.len() + 2 * size_of::()).sum(); + self.files.capacity() * size_of::>() + self.segments.capacity() * size_of::() + names + } } /// How a preprocessor failure ends the run. diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index c3b3084e21..0821a11e71 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -221,12 +221,69 @@ fn current(link: &Link) -> std::result::Result, HostError> { } /// A named engine: its interpreter, with the globals its runs left (`None` while a run has it), -/// the link its host functions use, and its heap after its last run. +/// the link its host functions use, its heap after its last run, and the source maps of the code +/// its earlier runs compiled. struct Engine { name: String, realm: Option, link: Link, heap: usize, + maps: Rc>, +} + +/// A named engine taken for a run: its realm (`None` for an engine made now), its link and its +/// kept source maps. +type TakenEngine = (Option, Link, Rc>); + +/// The most bytes of source maps ([`include::SourceMap::bytes`]) a named engine keeps from its +/// earlier runs. +pub const KEPT_MAP_BYTES: usize = 8 << 20; + +/// A source map an engine kept from an earlier run: the realm's tag for the source +/// ([`Realm::error_tag`]), the map, and for the run's own script, that run's name, which stands for +/// a script given as text. +struct KeptMap { + tag: u32, + map: include::SourceMap, + script: Option>, + bytes: usize, +} + +/// The source maps a named engine kept from its earlier runs, by tag, oldest first, in at most +/// [`KEPT_MAP_BYTES`]: the oldest go first to make room, and a map larger than that is not kept. +#[derive(Default)] +struct KeptMaps { + maps: std::collections::VecDeque, + bytes: usize, +} + +impl KeptMaps { + fn keep(&mut self, tag: u32, map: include::SourceMap, script: Option>) { + let bytes = map.bytes() + script.as_ref().map_or(0, |s| s.len()); + let limit = kept_map_bytes(); + if bytes > limit || self.maps.back().is_some_and(|m| m.tag >= tag) { + return; + } + while self.bytes + bytes > limit { + let Some(old) = self.maps.pop_front() else { break }; + self.bytes -= old.bytes; + } + self.bytes += bytes; + self.maps.push_back(KeptMap { tag, map, script, bytes }); + } + + fn get(&self, tag: u32) -> Option<&KeptMap> { + self.maps.binary_search_by_key(&tag, |m| m.tag).ok().and_then(|i| self.maps.get(i)) + } +} + +/// The bytes of source maps a named engine keeps. +fn kept_map_bytes() -> usize { + #[cfg(test)] + if let Some(n) = TEST_KEPT_MAP_BYTES.with(Cell::get) { + return n; + } + KEPT_MAP_BYTES } /// The named engines of one thread's sessions, by their key, each in the order they were made. @@ -665,6 +722,14 @@ struct Host { /// The file, line and column of each error `$.evalFile` raised for a file it could not run, in /// order: the error's mark ([`HostError::Marked`]) is its index. eval_failures: RefCell, u32, u32)>>, + /// The realm's tag for the script ([`Realm::script_tag`]) once the run has started it, and + /// [`vectorcraft_js::UNKNOWN_SOURCE`] before. + script_tag: Cell, + /// The run's name as its report gives it, which [`Host::name`] shortens for console lines. + report_name: Arc, + /// The source maps the named engine of the run kept from its earlier runs; `None` for a run + /// without one. + kept_maps: Option>>, /// When the run started, where the platform has a clock. started: Option, /// The time since [`Host::started`] at which a `$.sleep` ends. @@ -740,6 +805,23 @@ pub(crate) fn slowest(times: &HashMap<&'static str, (u64, u128, u128)>) -> Vec kept.keep(t, map, None), + None => break, + } + } + } + /// Adds a line to the run's console and, during a slice, to the session's. fn line(&self, entry: console::Entry, cut: bool) { let line = console::ConsoleLine { run: self.run, name: self.name.clone(), entry }; @@ -1358,34 +1440,55 @@ fn preprocessor_error(f: include::Failure) -> (RunError, Option>) { (RunError { kind, message, line: f.line, column: f.column }, file) } -/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]) with host error mark `mark` -/// ([`Realm::error_mark`]), at the line and column of the file it is in, with that file's name -/// unless it is the script's own. -fn located(host: &Host, tag: u32, mark: Option, e: &RunError) -> (RunError, Option>) { - let map = |m: &include::SourceMap, own: bool| { +/// `e`, the error `realm` raised in the run of `host`, at the line and column of the file it is in, +/// with that file's name unless it is the run's own script. +fn located(host: &Host, realm: &Realm, e: &RunError) -> (RunError, Option>) { + let map = |m: &include::SourceMap| { let (index, file, line, column) = m.locate(e.line, e.column); let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() }; - (RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file)) + (RunError { kind: e.kind.clone(), message, line, column }, index, file) }; // An error `$.evalFile` raised for a file it could not run, and no script caught, is at that // file's place. - if let Some((file, line, column)) = mark.and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) { + if let Some((file, line, column)) = realm.error_mark().and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) { let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() }; return (RunError { kind: e.kind.clone(), message, line, column }, Some(file)); } if e.line == 0 { return (e.clone(), None); } - // An error in code from another source, such as a function an earlier run of a named engine - // defined, has no place in this run's files, so it is given without one. + // An error in code from a source whose map is not at hand, such as code that `eval` compiled, + // is given without a place. let unplaced = || (RunError { kind: e.kind.clone(), message: e.message.clone(), line: 0, column: 0 }, None); - match tag { - 0 => map(&host.script_map.borrow(), true), + match realm.error_source() { + 0 => { + let (e, index, file) = map(&host.script_map.borrow()); + (e, (index > 0).then_some(file)) + } n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) { - Some(m) => map(m, false), + Some(m) => { + let (e, _, file) = map(m); + (e, Some(file)) + } None => unplaced(), }, - _ => unplaced(), + // Code an earlier run of the named engine compiled, such as a function it defined, is at + // its place in that run's files: the run's script, a file it included, or a file + // `$.evalFile` gave. A script given as text is named by that run's name. + _ => { + let kept = host.kept_maps.as_ref().map(|k| k.borrow()); + match kept.as_ref().and_then(|k| k.get(realm.error_tag())) { + Some(kept) => { + let (e, index, file) = map(&kept.map); + let file = match &kept.script { + Some(name) if index == 0 && file.is_empty() => name.clone(), + _ => file, + }; + (e, Some(file)) + } + None => unplaced(), + } + } } } @@ -1438,17 +1541,17 @@ impl Session { } } - /// The realm of the named engine `name` for a run, with the link its host functions use: the - /// realm its last run left, or `None` for an engine made now. → the kind and text of the - /// failure when the engine cannot be had. - fn take_engine(&mut self, name: &str) -> std::result::Result<(Option, Link), (include::FailKind, String)> { + /// The realm of the named engine `name` for a run, with the link its host functions use and the + /// source maps it kept: the realm its last run left, or `None` for an engine made now. → the + /// kind and text of the failure when the engine cannot be had. + fn take_engine(&mut self, name: &str) -> std::result::Result { if cfg!(target_arch = "wasm32") { return Err((include::FailKind::Error, NO_ENGINES_ON_WEB.to_string())); } with_engines(&self.script_engines, |engines| { if let Some(e) = engines.iter_mut().find(|e| e.name == name) { let realm = e.realm.take().ok_or((include::FailKind::Error, ENGINE_BUSY.to_string()))?; - return Ok((Some(realm), e.link.clone())); + return Ok((Some(realm), e.link.clone(), e.maps.clone())); } if engines.len() >= MAX_ENGINES { return Err(( @@ -1457,15 +1560,20 @@ impl Session { )); } let link: Link = Rc::new(RefCell::new(None)); - engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0 }); - Ok((None, link)) + let maps = Rc::new(RefCell::new(KeptMaps::default())); + engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0, maps: maps.clone() }); + Ok((None, link, maps)) }) } /// The end of a run in engine `engine` (`None` for a realm that is not kept): the link no longer - /// holds the run, and the engine keeps `realm`, or is dropped without one. + /// holds the run, and the engine keeps `realm`, with the source maps of the code the run + /// compiled, or is dropped without one. fn return_engine(&self, engine: Option<&str>, link: &Link, realm: Option) { let host = link.borrow_mut().take(); + if let (Some(h), Some(_)) = (&host, &realm) { + h.keep_maps(); + } drop(host); let Some(name) = engine else { return }; let mut realm = realm; @@ -1668,9 +1776,9 @@ impl Session { }; let heap_bytes = if kept.is_some() { engine_heap() } else { HEAP_BYTES }; let limits = Limits { fuel: request.fuel, heap_bytes, ..Limits::default() }; - let (engine, realm, link) = match kept { - None => (None, None, Rc::new(RefCell::new(None))), - Some((engine, (realm, link))) => (Some(engine), realm, link), + let (engine, realm, link, kept_maps) = match kept { + None => (None, None, Rc::new(RefCell::new(None)), None), + Some((engine, (realm, link, maps))) => (Some(engine), realm, link, Some(maps)), }; // A realm made here gets the script extensions, `$` and the dialogs; a kept one has them // from its first run, and they act for the run that calls them. @@ -1716,6 +1824,9 @@ impl Session { script_map: RefCell::default(), eval_maps: RefCell::default(), eval_failures: RefCell::default(), + script_tag: Cell::new(vectorcraft_js::UNKNOWN_SOURCE), + report_name: Arc::from(name.as_str()), + kept_maps, started: clock(), wake_at: Cell::new(None), can_wait: realm.wait_flag(), @@ -1752,8 +1863,12 @@ impl Session { .and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) }) .and_then(|()| if fresh { dialogs::install(&mut realm, &link) } else { Ok(()) }) .and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() }) - .and_then(|()| realm.start(&expanded.source)) - .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) + .and_then(|()| { + let r = realm.start(&expanded.source); + host.script_tag.set(realm.script_tag()); + r + }) + .map_err(|e| located(&host, &realm, &e)) } }; if let Err((e, file)) = started { @@ -1832,7 +1947,7 @@ impl Session { fn script_failed(&mut self, job: Job, e: &RunError) -> Result { job.host.dialogs.stopped(); let fuel_left = job.realm.fuel_left(); - let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), e); + let (e, file) = located(&job.host, &job.realm, e); let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left)); close_run(self, &job.host, &mut report, ending(&e.kind)); self.end_job(job, e.kind == RunErrorKind::Internal); @@ -2127,6 +2242,8 @@ thread_local! { pub(crate) static TEST_GROWTH: Cell> = const { Cell::new(None) }; /// Tests: the heap of a named engine in place of [`ENGINE_HEAP_BYTES`]. pub(crate) static TEST_ENGINE_HEAP: Cell> = const { Cell::new(None) }; + /// Tests: the bytes of source maps a named engine keeps, in place of [`KEPT_MAP_BYTES`]. + pub(crate) static TEST_KEPT_MAP_BYTES: Cell> = const { Cell::new(None) }; /// Tests: the `JSINCLUDE` folders in place of the environment's. pub(crate) static TEST_JSINCLUDE: RefCell>> = const { RefCell::new(None) }; /// Tests: the time since a run started, in place of its clock's. diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs index 5506cb98d1..3171657b32 100644 --- a/crates/engine/src/tests/script_engines.rs +++ b/crates/engine/src/tests/script_engines.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, engines_on_this_thread, releases_kept_for}; +use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, TEST_KEPT_MAP_BYTES, engines_on_this_thread, releases_kept_for}; use crate::*; fn run(s: &mut Session, source: &str) -> Result { @@ -119,11 +119,115 @@ fn only_the_script_itself_names_its_engine() { assert_eq!(report["line"], 2, "{report}"); assert_eq!(names(&mut s), ["e"]); let _ = std::fs::remove_dir_all(base); - // An error in a function an earlier run defined has no place in the run that calls it. + // An error in a function an earlier run defined is at that run's line; a script given as text + // is named by its run's name. run(&mut s, "#targetengine e\n\n\n\nfunction bad() { throw new Error('kept'); }").unwrap(); - assert!(run(&mut s, "#targetengine e\nbad();").is_err()); + let e = run(&mut s, "#targetengine e\nbad();").unwrap_err().to_string(); let report = s.script_report().unwrap().clone(); - assert!(report["line"].as_u64().is_none_or(|l| l == 0), "{report}"); + assert_eq!((report["name"].as_str(), report["file"].as_str(), report["line"].as_u64()), (Some("t.js"), Some("t.js"), Some(5)), "{report}"); + let column = report["column"].as_u64().unwrap(); + assert_eq!(error_lines(&report), [format!("t.js: t.js:5:{column}: Error: kept")]); + assert!(e.contains(&format!("line 5, column {column} of t.js")), "{e}"); +} + +/// The console error lines of a run's report. +fn error_lines(report: &Value) -> Vec { + let lines = report["console"]["lines"].as_array().unwrap(); + lines.iter().filter_map(Value::as_str).filter(|l| l.contains("Error: ")).map(str::to_string).collect() +} + +/// The file, line and column of the last run's error. +fn place(s: &Session) -> (Option, u64, u64) { + let r = s.script_report().unwrap(); + (r["file"].as_str().map(str::to_string), r["line"].as_u64().unwrap(), r["column"].as_u64().unwrap()) +} + +/// An error in code an earlier run of a named engine compiled is at the place of that code: in that +/// run's script, a file it included or a file `$.evalFile` gave, while the report and the console +/// line keep the calling run's name. +#[test] +fn an_error_in_code_an_earlier_run_kept_is_at_that_runs_file_and_line() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-places-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join("inc.jsxinc"), "\nfunction fromInclude() { throw new Error('inc'); }").unwrap(); + std::fs::write(base.join("ev.jsxinc"), "\n\n\nfunction fromEval() { throw new Error('ev'); }").unwrap(); + let def = base.join("def.jsx"); + let text = "#targetengine e\n#include \"inc.jsxinc\"\n$.evalFile('ev.jsxinc');\nfunction fromMain() {\n throw new Error('main');\n}\n\ + function spin() {\n while (true) {}\n}\nfunction badEval() {\n eval('var = 1');\n}"; + std::fs::write(&def, text).unwrap(); + let mut s = Session::new(); + s.execute("script.run", &json!({"path": def.to_string_lossy()})).unwrap(); + let call = |s: &mut Session, code: &str| { + let e = s.execute("script.run", &json!({"source": format!("#targetengine e\n\n{code}"), "name": "caller.js"})); + assert!(e.is_err(), "{code}"); + }; + let shown = def.to_string_lossy().to_string(); + let ends = |p: &(Option, u64, u64), file: &str, line: u64| p.0.as_deref().is_some_and(|f| f.ends_with(file)) && p.1 == line && p.2 > 0; + call(&mut s, "fromMain();"); + let p = place(&s); + assert!(ends(&p, "def.jsx", 5), "{p:?}"); + let report = s.script_report().unwrap().clone(); + assert_eq!(report["name"], "caller.js"); + assert_eq!(error_lines(&report), [format!("caller.js: {}:5:{}: Error: main", p.0.as_deref().unwrap(), p.2)]); + call(&mut s, "fromInclude();"); + assert!(ends(&place(&s), "inc.jsxinc", 2), "{:?}", place(&s)); + call(&mut s, "fromEval();"); + assert!(ends(&place(&s), "ev.jsxinc", 4), "{:?}", place(&s)); + call(&mut s, "badEval();"); + assert!(ends(&place(&s), "def.jsx", 11), "a syntax error eval raises is at its call: {:?}", place(&s)); + let e = s.execute("script.run", &json!({"source": "#targetengine e\nspin();", "name": "caller.js", "maxFuel": 100_000})); + assert!(e.is_err()); + assert_eq!(s.script_report().unwrap()["kind"], "limit"); + assert!(ends(&place(&s), "def.jsx", 8), "the fuel ran out in spin: {:?}", place(&s)); + // A caught error thrown again is where that throw is, in the calling run. + call(&mut s, "try { fromMain(); } catch (x) {\n throw x;\n}"); + assert_eq!(place(&s), (None, 4, 3)); + // A run that fails before it starts compiles nothing, and the kept places stay. + assert!(run(&mut s, "#targetengine e\n#include \"missing.jsxinc\"").is_err()); + call(&mut s, "fromMain();"); + assert!(ends(&place(&s), "def.jsx", 5), "{:?}", place(&s)); + assert!(place(&s).0.is_some_and(|f| f == shown)); + // A dropped engine takes its places with it: the new one has its own. + s.execute("script.engine.drop", &json!({"name": "e"})).unwrap(); + run(&mut s, "#targetengine e\nfunction fromMain() { throw new Error('new'); }").unwrap(); + call(&mut s, "fromMain();"); + assert_eq!(place(&s).0.as_deref(), Some("t.js")); + assert_eq!(place(&s).1, 2); + // The name stands for the earlier script as that run's report gave it, not as console lines + // show it. + s.execute("script.run", &json!({"source": "#targetengine e\nfunction named() { throw 1; }", "name": "a\u{1}b.js"})).unwrap(); + call(&mut s, "named();"); + assert_eq!(place(&s).0.as_deref(), Some("a\u{1}b.js")); + let _ = std::fs::remove_dir_all(base); +} + +/// A named engine keeps the places of its earlier runs' code within its budget, the oldest going +/// first, and the code still runs once its place is gone. +#[test] +fn an_engine_keeps_the_newest_places_within_its_budget() { + let mut s = Session::new(); + run(&mut s, "#targetengine e\nfunction a() { throw new Error('a'); }").unwrap(); + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(300))); + run(&mut s, "#targetengine e\n\nfunction b() { throw new Error('b'); }").unwrap(); + for _ in 0..20 { + run(&mut s, "#targetengine e\nvar x = 1;").unwrap(); + } + run(&mut s, "#targetengine e\n\n\nfunction c() { throw new Error('c'); }").unwrap(); + // Each run's own map takes the room of the one before, so c is called first. + assert!(run(&mut s, "#targetengine e\nc();").is_err()); + let c_place = place(&s); + let a = run(&mut s, "#targetengine e\na();").map(drop); + let a_place = place(&s); + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(1))); + run(&mut s, "#targetengine e\n\n\n\nfunction d() { throw new Error('d'); }").unwrap(); + assert!(run(&mut s, "#targetengine e\nd();").is_err()); + let d_place = place(&s); + TEST_KEPT_MAP_BYTES.with(|b| b.set(None)); + assert!(a.is_err()); + assert_eq!((a_place.0, a_place.1, a_place.2), (None, 0, 0), "a's place went to make room"); + assert_eq!((c_place.0.as_deref(), c_place.1), (Some("t.js"), 4)); + assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept"); } /// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 9c14bedca1..fbb5e4fc90 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -395,7 +395,7 @@ impl Interp { Err(e) => return Err(self.host_error(e)?), }; let writes = strict_writes().unwrap_or_else(|| self.writes_mode()); - self.evaluated = self.evaluated.saturating_add(1); + self.evaluated = crate::realm::next_tag(self.evaluated); let tag = std::mem::replace(&mut self.source_tag, self.evaluated); // UTF-8 takes at most 3 bytes for each UTF-16 code unit, so the fuel for a third of the // bytes is taken before the source is encoded, and the rest after. A source within the diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 124fe395f3..de252c6c64 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -638,6 +638,37 @@ fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unkn assert_eq!(r.error_source(), 0, "a syntax error is in the script"); } +#[test] +fn error_tags_stay_the_same_from_run_to_run_and_are_never_shared() { + let mut r = realm(10_000_000); + let source: HostFunction = Rc::new(|_| Ok(Data::String("function inEval() { throw new Error('in eval'); }".into()))); + let load = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) }; + r.define_host_object("host", vec![("load".into(), load)]).unwrap(); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE, "no script yet"); + r.run("function late() { throw new Error('late'); }\nhost.load(); host.load();").unwrap(); + let first = r.script_tag(); + assert_ne!(first, UNKNOWN_SOURCE); + assert!(r.run("\nlate()").is_err()); + let second = r.script_tag(); + assert!(second > first + 2, "the evaluator's two sources took the tags after the first script"); + assert_eq!((r.error_source(), r.error_tag()), (UNKNOWN_SOURCE, first), "late keeps its script's tag"); + assert!(r.run("inEval()").is_err()); + assert_eq!(r.error_tag(), first + 2, "the second source the evaluator gave in the first run"); + assert!(r.run("\nthrow 1").is_err()); + assert_eq!((r.error_source(), r.error_tag()), (0, r.script_tag())); + // Once the tags are used up, a source gets no tag rather than one an earlier source has. + r.set_last_tag(UNKNOWN_SOURCE - 3); + r.run("function nearEnd() { throw 1; }\nhost.load(); host.load(); host.load();").unwrap(); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE - 2); + assert!(r.run("nearEnd()").is_err()); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE, "a script after the last tag"); + assert_eq!(r.error_tag(), UNKNOWN_SOURCE - 2); + assert!(r.run("inEval()").is_err()); + assert_eq!(r.error_tag(), UNKNOWN_SOURCE, "the third source came after the last tag"); + assert!(r.run("\nthrow 1").is_err()); + assert_eq!((r.error_source(), r.error_tag()), (UNKNOWN_SOURCE, UNKNOWN_SOURCE)); +} + #[test] fn an_evaluators_source_takes_fuel_before_it_is_parsed() { // The evaluator gives 131,072 spaces and a number: far more code units than the fuel left. diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index b1651d1291..077f3b19b6 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -63,6 +63,12 @@ pub enum LimitKind { /// evaluator's: code that `eval` or `Function` compiled, or an error with no place. pub const UNKNOWN_SOURCE: u32 = u32::MAX; +/// The source tag after `tag`. Once the tags are used up, every later source gets +/// [`UNKNOWN_SOURCE`], so no two sources share a tag. +pub(crate) fn next_tag(tag: u32) -> u32 { + if tag >= UNKNOWN_SOURCE - 1 { UNKNOWN_SOURCE } else { tag + 1 } +} + /// The fuel [`Realm::advance`] lets a slice spend, past the slice it was asked for, in a nested run /// it cannot pause. pub const MAX_UNBROKEN_FUEL: u64 = 200_000_000; @@ -151,7 +157,9 @@ pub struct Realm { unbroken_fuel: u64, /// The source tag of the script the last [`Realm::run`] or [`Realm::start`] compiled. Each /// script gets a new one, and the sources an evaluator gives during its run get the ones after - /// it, so code that an earlier script defined keeps a tag of its own. + /// it, so code that an earlier script defined keeps a tag of its own. [`UNKNOWN_SOURCE`] + /// before the first script, for a script the realm did not compile, and once the tags are used + /// up. script_tag: u32, } @@ -170,7 +178,7 @@ impl Realm { /// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it /// counted together. pub fn with_options(options: Options, limits: Limits) -> Self { - Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: 0 } + Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: UNKNOWN_SOURCE } } /// Sets the fuel [`Realm::advance`] lets a nested run spend past its slice (by default @@ -226,6 +234,7 @@ impl Realm { /// Compiles `source` and readies the realm for a run. fn compile(&mut self, source: &str) -> Result, RunError> { + self.script_tag = UNKNOWN_SOURCE; let interp = &mut self.interp; if let Some(message) = &interp.install_error { return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 }); @@ -244,7 +253,7 @@ impl Realm { interp.finally_sites.clear(); interp.error_mark = None; let strict = interp.options.strict; - interp.evaluated = interp.evaluated.saturating_add(1).min(UNKNOWN_SOURCE - 1); + interp.evaluated = next_tag(interp.evaluated); self.script_tag = interp.evaluated; interp.source_tag = self.script_tag; let code = interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)); @@ -397,6 +406,28 @@ impl Realm { } } + /// The source tag of the code the last error's `line` and `column` are in, which stays the same + /// from run to run: each script given to [`Realm::run`] or [`Realm::start`] and each source a + /// [`crate::HostItem::Evaluator`] gives gets the next tag, so a function an earlier script + /// defined has its script's tag. [`UNKNOWN_SOURCE`] for code that `eval` or `Function` + /// compiled, an error with no place, and sources given once the tags are used up. + pub fn error_tag(&self) -> u32 { + self.interp.error_tag + } + + /// The source tag ([`Realm::error_tag`]) of the script the last [`Realm::run`] or + /// [`Realm::start`] compiled; the `n`th source an evaluator gave during that run has this tag + /// plus `n`. [`UNKNOWN_SOURCE`] when that call compiled nothing. + pub fn script_tag(&self) -> u32 { + self.script_tag + } + + /// Tests: makes `tag` the last source tag given out. + #[cfg(test)] + pub(crate) fn set_last_tag(&mut self, tag: u32) { + self.interp.evaluated = tag; + } + /// The mark of the host error ([`crate::HostError::Marked`]) the last run ended with, when no /// `catch` clause took that error's exception on its way out; `None` otherwise. pub fn error_mark(&self) -> Option { diff --git a/docs/mcp.md b/docs/mcp.md index 7bf5ae9956..1a50f9c26f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1873,8 +1873,13 @@ no engines, and the engines it left are dropped when their own thread next uses and the other eight are kept. Each named engine has a heap of 64 MiB, which holds what it keeps between runs; a run that goes past it ends with the heap limit, and the engine stays usable. - The documents go back after an uncaught error, a limit or `script.stop` as they do for any run, and the engine keeps - the globals the run set before it ended. An internal error drops the engine. An error in a function an earlier run - defined is reported without a line and column. + the globals the run set before it ended. An internal error drops the engine. +- An error in code an earlier run compiled, such as a function it defined, is reported at that code's line and column + in the earlier run's script, a file it included or a file its `$.evalFile` ran, and the report's `file` names that + file. For an earlier script given as `source`, `file` is that run's name. The report's `name` and the console line's + name stay the calling run's, as in `caller.js: /path/lib.jsx:12:5: Error: kept`. Each engine keeps these places + for 8 MiB of source maps, the oldest going first; an error in code whose place is gone, or in code that `eval` or + `Function` compiled, is reported without a line and column, and the code still runs. - `vc`, `$`, `alert`, `confirm` and `prompt` act for the run that calls them, including a copy an earlier run kept in a global. `vc.args` is the calling run's `args`, through a `vc` object an earlier run kept too. An engine holds no document: `vc.document` is a copy, read when it is called. What a run sets on `$` (`$.locale`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b1ceaf70ea..c51d398bee 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -756,6 +756,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: an error a `finally` block passes on is still reported where it was thrown when the block's allocations collect garbage under a small heap limit, and a `finally` block that `return`, `break` or `continue` leaves drops the saved location of its error as it leaves, so a collection no longer counts that code as live. VectorCraft's own rule. `crates/js/src/host_tests.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). - Scripts: `#targetengine name` (and `//@targetengine name`) runs the script in a named engine that lasts for the session, and a later script that names it sees the globals and functions earlier ones left ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)); `$.engineName` gives the name ([the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: a script without the line and `#targetengine main` get a new engine each run, the line comes once in the script itself before its code, a session keeps 8 named engines with 64 MiB of heap each, an error or a stop keeps the globals the run set while the documents go back, kept host functions act for the run that calls them, `script.engines` lists the engines and `script.engine.drop` drops one, and the web build has none. `crates/engine/src/tests/script_engines.rs`. + - Scripts: a runtime error is shown at the line that was running ([the help page](https://extendscript.docsforadobe.dev/extendscript-toolkit/debugging-in-the-toolkit/)). VectorCraft's own rules: an error in code an earlier run of a named engine compiled, such as a function it defined, is reported at that code's file, line and column (the earlier run's script, a file it included or a file its `$.evalFile` ran), while the report and its console line keep the calling run's name; a script given as text is named by its run's name, and each engine keeps these places for 8 MiB of source maps, the oldest going first. `crates/engine/src/tests/script_engines.rs`. - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.