Merge pull request #7144 from dotCooCoo/fix/script-line-numbers

Scripts: $.line and $.stack give the line of the file the code is in
This commit is contained in:
BFlat
2026-11-14 00:51:19 +07:00
committed by GitHub
11 changed files with 182 additions and 8 deletions
+35
View File
@@ -823,6 +823,34 @@ impl Host {
}
}
/// The file and line that line `line` of the source with tag `tag` came from, as `$.line` and
/// `$.stack` give them: the run's script, a file it included or a file `$.evalFile` gave, in
/// this run or in an earlier run of its named engine, with the run's name standing for a script
/// given as text. Code that `eval` or `Function` compiled has no map and keeps its own line,
/// named `[eval]`; code whose map is gone, such as one the named engine let go to stay within
/// [`KEPT_MAP_BYTES`], is at line 0 of `[unknown]`.
fn source_place(&self, tag: u32, line: u32) -> (String, u32) {
if tag == vectorcraft_js::UNKNOWN_SOURCE {
return ("[eval]".to_string(), line);
}
let named = |map: &include::SourceMap, script: Option<&Arc<str>>| {
let (index, file, line, _) = map.locate(line, 1);
match script {
Some(name) if index == 0 && file.is_empty() => (name.to_string(), line),
_ => (file.to_string(), line),
}
};
let script = self.script_tag.get();
let placed = match tag.checked_sub(script).filter(|_| script != vectorcraft_js::UNKNOWN_SOURCE) {
Some(0) => self.script_map.try_borrow().ok().map(|m| named(&m, Some(&self.report_name))),
Some(n) => self.eval_maps.try_borrow().ok().and_then(|maps| maps.get(n as usize - 1).map(|m| named(m, None))),
None => {
self.kept_maps.as_ref().and_then(|k| k.try_borrow().ok()).and_then(|k| k.get(tag).map(|kept| named(&kept.map, kept.script.as_ref())))
}
};
placed.unwrap_or_else(|| ("[unknown]".to_string(), 0))
}
/// `$.fileName` read by the code at `site`: the file that code is in, by the source map of the
/// source it came from (this run's script, a file `$.evalFile` gave, or code an earlier run of
/// the named engine compiled), so a function keeps the file it was defined in. Code from a file
@@ -1891,6 +1919,13 @@ impl Session {
Ok(expanded) => {
*host.script_map.borrow_mut() = expanded.map;
realm.set_strict_writes(expanded.strict == Some(true));
if fresh {
let l = link.clone();
realm.set_source_locator(Rc::new(move |tag, line| match current(&l) {
Ok(h) => h.source_place(tag, line),
Err(_) => ("[unknown]".to_string(), 0),
}));
}
(if fresh { realm.add_script_extensions() } else { Ok(()) })
.and_then(|()| if fresh { realm.define_host_object("vc", vc_object(&link)) } else { Ok(()) })
.and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) })
+2 -2
View File
@@ -63,7 +63,7 @@ fn each_member_has_its_value() {
("$.memCache", json!(0)),
("$.os", json!(os)),
("$.screens.length", json!(0)),
("$.stack", json!("[global]:1\n")),
("$.stack", json!("[global]@t.js:1\n")),
("$.strict", json!(false)),
("$.version", json!(version)),
("typeof $.getenv + typeof $.setenv + typeof $.sleep", json!("functionfunctionfunction")),
@@ -165,7 +165,7 @@ fn getenv_reads_only_its_names_and_setenv_changes_only_the_run() {
fn error_line_and_stack_follow_the_running_code() {
let mut s = Session::new();
let source = "try { null.x; } catch (e) {}\nfunction where() {\n return [$.line, $.stack, $.error instanceof TypeError];\n}\nvc.log(where());";
assert_eq!(logged(&mut s, source)[0], json!([3, "[global]:5\nwhere:3\n", true]));
assert_eq!(logged(&mut s, source)[0], json!([3, "[global]@t.js:5\nwhere@t.js:3\n", true]));
assert_eq!(value(&mut s, "$.error"), json!(null), "each run starts without one");
}
+38
View File
@@ -276,6 +276,44 @@ fn an_engine_keeps_the_newest_places_within_its_budget() {
assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept");
}
/// `$.line` and `$.stack` in code an earlier run of a named engine compiled give the line of that
/// run's file: its script, or a file it included; a script given as text is named by that run's
/// name. Code `eval` and `Function` compiled keeps its own line, in `[eval]`, and code whose place
/// the engine did not keep is at line 0 of `[unknown]`.
#[test]
fn the_line_and_the_stack_of_code_an_earlier_run_kept_are_at_that_runs_files() {
use super::script_include::short_stack;
let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-line-stack-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
std::fs::write(base.join("inc.jsxinc"), "\n\nfunction fromInclude() {\n return [$.line, $.stack];\n}").unwrap();
let def = base.join("def.jsx");
std::fs::write(&def, "#targetengine e\nvar pad = 1;\n#include \"inc.jsxinc\"\nfunction fromMain() {\n return fromInclude();\n}").unwrap();
let mut s = Session::new();
s.execute("script.run", &json!({"path": def.to_string_lossy()})).unwrap();
run(&mut s, "#targetengine e\n\nfunction fromText() {\n return $.stack;\n}").unwrap();
let call = |s: &mut Session, code: &str| {
let r = s.execute("script.run", &json!({"source": format!("#targetengine e\n\n{code}"), "name": "caller.js"}));
r.unwrap_or_else(|e| panic!("{code}: {e}"))["log"][0].clone()
};
let r = call(&mut s, "vc.log(fromMain());");
assert_eq!(r[0], 4, "{r}");
assert_eq!(short_stack(&r[1]), "[global]@caller.js:3\nfromMain@def.jsx:5\nfromInclude@inc.jsxinc:4\n");
assert_eq!(short_stack(&call(&mut s, "vc.log(fromText());")), "[global]@caller.js:3\nfromText@t.js:4\n");
assert_eq!(call(&mut s, "vc.log(eval('\\n\\n$.line'));"), 3);
assert_eq!(call(&mut s, "vc.log(eval('\\n$.stack'));"), "[global]@caller.js:3\n[global]@[eval]:2\n");
let r = call(&mut s, "vc.log(new Function('\\nreturn $.stack;')());");
assert_eq!(r, "[global]@caller.js:3\n[anonymous]@[eval]:4\n", "the line of the function source `Function` compiles");
// A map larger than the budget is not kept.
TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(1)));
let lost = run(&mut s, "#targetengine e\n\n\nfunction lost() {\n return [$.line, $.stack];\n}");
let r = lost.map(|_| call(&mut s, "vc.log(lost());"));
TEST_KEPT_MAP_BYTES.with(|b| b.set(None));
let r = r.unwrap();
assert_eq!(r, json!([0, "[global]@caller.js:3\nlost@[unknown]:0\n"]));
let _ = std::fs::remove_dir_all(base);
}
/// A run of a named engine whose script does not compile defines no code, so it keeps no place
/// and pushes out none of the places kept before. An included file without a preprocessor line is
/// first parsed with the script it joins, so its syntax error ends the run as it starts.
+43
View File
@@ -496,6 +496,49 @@ fn script_included_error_has_local_file_and_line() {
let _ = std::fs::remove_dir_all(base);
}
/// `$.stack`'s text with each frame's file cut to its name: `function@name:line` a line.
pub(crate) fn short_stack(stack: &Value) -> String {
let stack = stack.as_str().unwrap_or_else(|| panic!("a stack: {stack}"));
stack
.lines()
.map(|frame| match (frame.split_once('@'), frame.rsplit_once(':')) {
(Some((name, _)), Some((place, line))) => {
let file = place.split_once('@').map_or(place, |(_, f)| f).rsplit(['/', '\\']).next().unwrap_or_default();
format!("{name}@{file}:{line}\n")
}
_ => format!("{frame}\n"),
})
.collect()
}
/// `$.line` and `$.stack` give the line of the file the running code is in: the script, a file it
/// included (and a file that one included) or a file `$.evalFile` gave and the files it included,
/// whatever lines come before it in the run's expanded source.
#[test]
fn script_line_and_stack_are_at_the_line_of_each_file() {
let base = folder("line-stack");
write(
&base.join("main/lib.jsxinc"),
"\nfunction libWhere() {\n return [$.line, $.stack];\n}\nvar libLine = $.line;\n#include \"deep.jsxinc\"\n",
);
write(&base.join("main/deep.jsxinc"), "\n\nvar deepLine = $.line;\nfunction deepStack() {\n return $.stack;\n}");
write(&base.join("main/sub/ev.jsx"), "#include \"inc.jsxinc\"\nfunction evWhere() {\n return incWhere();\n}\nevWhere();");
write(&base.join("main/sub/inc.jsxinc"), "\n\nfunction incWhere() {\n return [$.line, $.stack];\n}");
let text = "var a = 1;\nvar b = 2;\n#include \"lib.jsxinc\"\nvar mainLine = $.line;\nvc.log([libLine, deepLine, mainLine]);\n\
vc.log(libWhere());\nvc.log(deepStack());\nvc.log($.evalFile('sub/ev.jsx'));";
let log = run(&base, text).unwrap()["log"].clone();
assert_eq!(log[0], json!([5, 3, 4]), "{log}");
assert_eq!(log[1][0], 3, "{log}");
assert_eq!(short_stack(&log[1][1]), "[global]@main.jsx:6\nlibWhere@lib.jsxinc:3\n");
assert_eq!(short_stack(&log[2]), "[global]@main.jsx:7\ndeepStack@deep.jsxinc:5\n");
assert_eq!(log[3][0], 4, "{log}");
assert_eq!(short_stack(&log[3][1]), "[global]@main.jsx:8\n[global]@ev.jsx:5\nevWhere@ev.jsx:3\nincWhere@inc.jsxinc:4\n");
// The file a frame names is the one `$.fileName` gives while that file runs.
let log = run(&base, "vc.log($.stack.slice(9, -3) === $.fileName);").unwrap()["log"].clone();
assert_eq!(log, json!([true]));
let _ = std::fs::remove_dir_all(base);
}
/// Pumps the run in the app until it ends or waits at a dialog (`None`).
fn pump(s: &mut Session) -> Option<Result<Value>> {
for _ in 0..10_000 {
+21 -4
View File
@@ -388,18 +388,33 @@ fn last_error(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<V
}
fn line(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> {
let line = interp.script_frames().last().map_or(0, |(code, pc, _)| code.line_column(pc.saturating_sub(1)).0);
let line = interp.script_frames().last().map_or(0, |(code, pc, _)| place(interp.locator.as_ref(), code, pc).1);
Ok(Value::Number(f64::from(line)))
}
/// Where the instruction before `pc` in `code` came from: the file the realm's locator names
/// (`None` without one) and the line there, or the line in the compiled source without a locator.
fn place(locator: Option<&crate::realm::SourceLocator>, code: &Code, pc: usize) -> (Option<String>, u32) {
let line = code.line_column(pc.saturating_sub(1)).0;
match locator {
Some(locate) => {
let (file, line) = locate(code.source.tag, line);
(Some(file), line)
}
None => (None, line),
}
}
/// The most frames `$.stack` lists, and the most characters of a function's name it shows.
const STACK_FRAMES: usize = 64;
const STACK_NAME_UNITS: usize = 64;
/// The script's frames, outermost first, one a line: the function's name and the line it is at.
/// The script's frames, outermost first, one a line: the function's name and the line it is at,
/// as `name:line`, or as `name@file:line` with the file the realm's locator names.
fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> {
let frames: Vec<(Rc<Code>, usize, bool)> = interp.script_frames().map(|(c, pc, f)| (c.clone(), pc, f)).collect();
let skip = frames.len().saturating_sub(STACK_FRAMES);
let locator = interp.locator.clone();
let mut text = String::new();
if skip > 0 {
text.push_str("…\n");
@@ -407,8 +422,10 @@ fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value>
for (code, pc, function) in frames.iter().skip(skip) {
interp.use_fuel()?;
let name = if *function { function_name(code) } else { "[global]".to_string() };
let line = code.line_column(pc.saturating_sub(1)).0;
text.push_str(&format!("{name}:{line}\n"));
match place(locator.as_ref(), code, *pc) {
(Some(file), line) => text.push_str(&format!("{name}@{file}:{line}\n")),
(None, line) => text.push_str(&format!("{name}:{line}\n")),
}
}
let mut out = Builder::new();
out.push_str(interp, &text)?;
+26
View File
@@ -219,6 +219,32 @@ fn script_items_read_the_global_object_the_last_error_the_line_and_the_stack() {
assert!(thrown(&mut r, "'use strict'; h.line = 1").starts_with("TypeError"), "the items cannot be assigned");
}
#[test]
fn the_line_and_the_stack_give_the_place_the_source_locator_maps_each_frame_to() {
let mut r = realm();
let items = [("line", ScriptItem::Line), ("stack", ScriptItem::Stack)].into_iter().map(|(n, i)| (n.to_string(), HostItem::Script(i))).collect();
r.define_host_object("h", items).unwrap();
let asked: Rc<RefCell<Vec<(u32, u32)>>> = Rc::default();
let seen = asked.clone();
r.set_source_locator(Rc::new(move |tag, line| {
seen.borrow_mut().push((tag, line));
let file = if tag == UNKNOWN_SOURCE { "[eval]".to_string() } else { format!("f{tag}") };
(file, line + 100)
}));
let source = "function inner() {\n return h.stack;\n}\n\ninner()";
let stack = text(&mut r, source);
let tag = r.script_tag();
assert_eq!(stack, format!("[global]@f{tag}:105\ninner@f{tag}:102\n"));
assert_eq!(*asked.borrow(), [(tag, 5), (tag, 2)], "each frame's tag and its line in the compiled source");
assert_eq!(r.run("\n\n h.line").unwrap(), JsValue::Number(103.0));
let tag = r.script_tag();
let stack = text(&mut r, "\neval('\\n\\nh.stack')");
assert_eq!(stack, format!("[global]@f{}:102\n[global]@[eval]:103\n", tag + 1), "code eval compiles has no tag of its own");
let deep = "function d(n) { return n ? d(n - 1) : h.stack; } var s = d(100).split('\\n'); s.length + ',' + s[0] + ',' + s[1]";
let tag = r.script_tag() + 1;
assert_eq!(text(&mut r, deep), format!("66,…,d@f{tag}:101"), "the frames past 64 are still left out");
}
#[test]
fn a_host_property_reads_its_getter_and_gives_assignments_to_its_setter() {
let mut r = Realm::new();
+3
View File
@@ -232,6 +232,8 @@ pub(crate) struct Interp {
pub(crate) evaluated: u32,
/// The tag of the source the last run's error was raised in.
pub(crate) error_tag: u32,
/// Where `$.line` and `$.stack` find the file and line of a compiled line.
pub(crate) locator: Option<crate::realm::SourceLocator>,
/// The mark of the host error ([`crate::HostError::Marked`]) whose exception or limit is
/// propagating.
pub(crate) thrown_mark: Option<u32>,
@@ -469,6 +471,7 @@ impl Interp {
source_tag: crate::realm::UNKNOWN_SOURCE,
evaluated: 0,
error_tag: crate::realm::UNKNOWN_SOURCE,
locator: None,
thrown_mark: None,
finally_marks: Vec::new(),
finally_sites: Vec::new(),
+1 -1
View File
@@ -83,7 +83,7 @@ pub use error::{SyntaxError, TOO_DEEP, TOO_LONG};
pub use host::{Arg, CallSite, Data, DataLimits, HostError, HostFunction, HostItem, ScriptItem, SiteFunction};
pub use lexer::{Goal, Lexer};
pub use number::to_string as number_to_string;
pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, UNKNOWN_SOURCE};
pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, SourceLocator, UNKNOWN_SOURCE};
pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind};
/// How a script is read.
+11
View File
@@ -63,6 +63,10 @@ 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 file and line that line `line` of the source with tag `tag` ([`Realm::error_tag`]) came
/// from, as `$.line` and `$.stack` give them ([`Realm::set_source_locator`]).
pub type SourceLocator = std::rc::Rc<dyn Fn(u32, u32) -> (String, u32)>;
/// 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 {
@@ -395,6 +399,13 @@ impl Realm {
self.interp.interrupt = interrupt.0.clone();
}
/// Has `$.line` give the line `locate` maps the running line to, and `$.stack` give each
/// frame's line that way, after the file `locate` names: `function@file:line`. Without a
/// locator they give the line of the compiled source, and `$.stack` names no file.
pub fn set_source_locator(&mut self, locate: SourceLocator) {
self.interp.locator = Some(locate);
}
/// Which source the last error's `line` and `column` are in: 0 for the script last given to
/// [`Realm::run`] or [`Realm::start`], `n` for the `n`th source a
/// [`crate::HostItem::Evaluator`] gave since then, and [`UNKNOWN_SOURCE`] otherwise, which
+1 -1
View File
@@ -2051,7 +2051,7 @@ VectorCraft's own, and `vc.api.has("dollar")` is true where they are available:
| `global` | the global object |
| `hiresTimer` | the microseconds since the run last read it, or since the run started; `0` on the web |
| `level`, `memCache` | `0`; setting `memCache` throws |
| `line`, `stack` | the line of the code that reads it; the running code, outermost first, one a line as `name:line` (`[global]` for the script's own code, `[anonymous]` for a function without a name), at most 64 lines |
| `line`, `stack` | the line of the code that reads it, counted in the file that code is in (the script, a file it included or a file `$.evalFile` ran, in this run or an earlier run of its named engine); the running code, outermost first, one a line as `name@file:line` (`[global]` for the script's own code, `[anonymous]` for a function without a name; the file as `$.fileName` gives it, or the run's name for a script given as `source`), at most 64 lines. Code that `eval` or `Function` compiled keeps the line of the source they compiled, in the file `[eval]`; code whose place a named engine no longer keeps is at line `0` of `[unknown]` |
| `locale` | the interface's language as a locale such as `de` or `pt_BR` (`en` when the interface follows the system); a script can set `ll` or `ll_RR`, and `null` or `""` sets the interface's again |
| `localize` | `false`; while `true`, `String(object)` of a localization object gives its text for `$.locale` |
| `os` | `Windows`, `macOS`, `Linux`, `FreeBSD`, `Unix` or `Web`, without a version |
+1
View File
@@ -792,6 +792,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- 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: `$.fileName` is a read-only name of the script file in use ([the help page](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: it names the file of the code that reads it, so a function a file included with `#include` or run with `$.evalFile` defined, or one an earlier run of a named engine kept, gives its own file and not the calling file; code from a script given as `source`, and kept code whose place the engine dropped, gives an empty name, and code that `eval` or `Function` compiled gives the file being run. `crates/engine/src/tests/script_include.rs`, `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. 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. `crates/engine/src/tests/script_engines.rs`.
- Scripts: `$.line` is the line of the running script, counting from 1, and `$.stack` is the current stack trace ([the help page](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: both count lines in the file the code is in (the script, a file it included, a file `$.evalFile` ran, or the file of an earlier run of the named engine), and `$.stack` writes each frame as `name@file:line`, outermost first; code that `eval` or `Function` compiled keeps its own line in `[eval]`, and code whose place the engine no longer keeps is at line 0 of `[unknown]`. `crates/engine/src/tests/script_include.rs`, `crates/engine/src/tests/script_engines.rs`, `crates/js/src/extension_tests.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`.