JavaScript: characters assigned in Unicode 17 are identifier characters

vectorcraft-js and Check Script took identifier categories from
unicode-general-category 1.1.0, which carries Unicode 16.0, so letters
and marks assigned in Unicode 17.0 (U+088F at the start of a name,
U+1ACF after its first character) were refused. Both lexers now read
general categories from unicode-properties 0.1.4 (Unicode 17.0), which
Cargo.lock already holds. The version is exact because 0.1.5 adds the
Unicode-3.0 license, which is not on the license allowlist. The
Other_ID_Start, Other_ID_Continue and Pattern_Syntax lists are the same
in PropList 17.0.

The exhaustive identifier test now expects the DerivedCoreProperties
17.0 totals (145,916 ID_Start, 149,240 ID_Continue), and new tests read
U+088F and U+1ACF written out and as escapes in both lexers.
This commit is contained in:
dotCooCoo
2026-11-11 13:57:54 -08:00
parent 566f720868
commit 283d127e2a
9 changed files with 59 additions and 30 deletions
Generated
+2 -2
View File
@@ -6591,7 +6591,7 @@ dependencies = [
"chrono",
"getrandom 0.4.3",
"proptest",
"unicode-general-category",
"unicode-properties",
]
[[package]]
@@ -6727,7 +6727,7 @@ name = "vectorcraft-scriptcheck"
version = "0.24.0"
dependencies = [
"proptest",
"unicode-general-category",
"unicode-properties",
]
[[package]]
@@ -89,11 +89,12 @@ fn both_lexers_find_the_same_unclosed_token_on_the_same_line() {
}
/// Characters on both sides of the identifier rule: `Other_ID_Start`, `Other_ID_Continue`, connector
/// punctuation, marks, digits and letters of other scripts, and a superscript digit, a
/// `Pattern_Syntax` letter and punctuation that no name holds.
/// punctuation, marks, digits and letters of other scripts, a letter and a mark Unicode 17.0
/// assigned, and a superscript digit, a `Pattern_Syntax` letter and punctuation that no name holds.
const NAME_CHARS: &[char] = &[
'\u{AA}', '\u{B2}', '\u{B7}', '\u{D7}', '\u{300}', '\u{387}', '\u{660}', '\u{1369}', '\u{1885}', '\u{19DA}', '\u{200C}', '\u{200D}', '\u{203F}',
'\u{2040}', '\u{2118}', '\u{212E}', '\u{2160}', '\u{2E2F}', '\u{3005}', '\u{309B}', '\u{30FB}', '\u{FE33}', '\u{FF10}', '\u{FF65}',
'\u{AA}', '\u{B2}', '\u{B7}', '\u{D7}', '\u{300}', '\u{387}', '\u{660}', '\u{88F}', '\u{1369}', '\u{1885}', '\u{19DA}', '\u{1ACF}', '\u{200C}',
'\u{200D}', '\u{203F}', '\u{2040}', '\u{2118}', '\u{212E}', '\u{2160}', '\u{2E2F}', '\u{3005}', '\u{309B}', '\u{30FB}', '\u{FE33}', '\u{FF10}',
'\u{FF65}',
];
/// Whether the parser reads `src` as one identifier spelling `name`.
@@ -111,6 +112,7 @@ fn checker_member(src: &str, name: &str) -> bool {
#[test]
fn both_lexers_take_the_same_characters_into_names() {
assert!(checker_member("a\u{2118}b", "a\u{2118}b") && one_identifier("a\u{2118}b", "a\u{2118}b"));
assert!(checker_member("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}") && one_identifier("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}"));
for &c in NAME_CHARS {
let escape = format!("\\u{:04X}", u32::from(c));
for (src, name) in [
+3 -1
View File
@@ -11,7 +11,9 @@ workspace = true
[dependencies]
chrono = { workspace = true }
unicode-general-category = "1"
# Unicode 17.0 general categories for identifiers (chars.rs). 0.1.5 adds the Unicode-3.0 license,
# which is not on the license allowlist, so the version is exact.
unicode-properties = { version = "=0.1.4", default-features = false, features = ["general-category"] }
# The seed of Math.random (interp.rs).
getrandom = "0.4"
+4 -4
View File
@@ -4,7 +4,7 @@
use std::cmp::Ordering;
use unicode_general_category::{GeneralCategory as G, get_general_category};
use unicode_properties::{GeneralCategory as G, UnicodeGeneralCategory};
/// A line terminator: LF, CR, U+2028 or U+2029 (section 7.3).
pub fn is_line_terminator(c: u32) -> bool {
@@ -20,7 +20,7 @@ pub fn is_whitespace(c: u32) -> bool {
/// A character an identifier may start with: a Unicode `ID_Start` character, `$` or `_`
/// (the Names and Keywords clause of current ECMA-262 editions). `ID_Start` is the letters (categories Lu, Ll, Lt, Lm, Lo and Nl)
/// and `Other_ID_Start`, less `Pattern_Syntax` and `Pattern_White_Space` (Unicode Standard Annex
/// #31). The categories come from Unicode 16.0. `\u` escapes are decoded before this check.
/// #31). The categories come from Unicode 17.0. `\u` escapes are decoded before this check.
pub fn is_id_start(c: u32) -> bool {
match c {
0x24 | 0x5F => true,
@@ -58,7 +58,7 @@ fn is_other_id_start(c: u32) -> bool {
matches!(c, 0x1885 | 0x1886 | 0x2118 | 0x212E | 0x309B | 0x309C)
}
/// `Other_ID_Continue` (Unicode PropList 16.0): characters kept in `ID_Continue` though their
/// `Other_ID_Continue` (Unicode PropList 17.0): characters kept in `ID_Continue` though their
/// category is not a letter, mark, digit or connector.
fn is_other_id_continue(c: u32) -> bool {
matches!(c, 0xB7 | 0x387 | 0x1369..=0x1371 | 0x19DA | 0x200C | 0x200D | 0x30FB | 0xFF65)
@@ -110,7 +110,7 @@ fn is_pattern_syntax(c: u32) -> bool {
/// The Unicode general category of a code point; `None` for a lone surrogate.
fn category(c: u32) -> Option<G> {
char::from_u32(c).map(get_general_category)
char::from_u32(c).map(char::general_category)
}
/// The value of a hexadecimal digit.
+27 -6
View File
@@ -424,6 +424,27 @@ fn identifiers_take_the_other_id_start_and_continue_characters() {
}
}
#[test]
fn identifiers_take_the_characters_unicode_17_assigned() {
// U+088F (a letter, Lo) starts a name; U+1ACF (a combining mark, Mn) may follow the first
// character but cannot start a name. Unicode 17.0 assigned both.
for (src, name) in [
("var \u{88F} = 1;", "\u{88F}"),
("var \\u088F = 1;", "\u{88F}"),
(r"var \u{88F} = 1;", "\u{88F}"),
("var _\u{1ACF} = 1;", "_\u{1ACF}"),
("var _\\u1ACF = 1;", "_\u{1ACF}"),
(r"var _\u{1ACF} = 1;", "_\u{1ACF}"),
] {
let p = ok(src);
let Stmt::Var(decls) = stmt(&p, p.body[0]) else { panic!("{src:?}") };
assert_eq!(decls[0].name.name, name, "{src:?}");
}
assert!(err("var \u{1ACF} = 1;").message.contains("unexpected character"));
assert!(err("var \\u1ACF = 1;").message.contains("identifier escape"));
assert!(err(r"var \u{1ACF} = 1;").message.contains("identifier escape"));
}
#[test]
fn identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes() {
// U+2E2F is a letter by category but Pattern_Syntax; U+2010, U+00B6 and U+2190 are
@@ -480,12 +501,12 @@ fn no_pattern_syntax_or_white_space_character_is_an_identifier_character() {
}
#[test]
fn identifier_characters_are_unicode_16_id_start_and_id_continue() {
fn identifier_characters_are_unicode_17_id_start_and_id_continue() {
use crate::chars::{is_id_part, is_id_start};
use unicode_general_category::{GeneralCategory as G, get_general_category};
use unicode_properties::{GeneralCategory as G, UnicodeGeneralCategory};
// Unicode Standard Annex #31: ID_Start is the categories Lu, Ll, Lt, Lm, Lo and Nl plus
// Other_ID_Start; ID_Continue adds Mn, Mc, Nd, Pc and Other_ID_Continue; both leave out
// Pattern_Syntax and Pattern_White_Space. Other_ID_Start and Other_ID_Continue as PropList 16.0
// Pattern_Syntax and Pattern_White_Space. Other_ID_Start and Other_ID_Continue as PropList 17.0
// lists them.
let other_start = [0x1885..=0x1886, 0x2118..=0x2118, 0x212E..=0x212E, 0x309B..=0x309C];
let other_continue = [0xB7..=0xB7, 0x387..=0x387, 0x1369..=0x1371, 0x19DA..=0x19DA, 0x200C..=0x200D, 0x30FB..=0x30FB, 0xFF65..=0xFF65];
@@ -498,7 +519,7 @@ fn identifier_characters_are_unicode_16_id_start_and_id_continue() {
let (mut id_starts, mut id_continues) = (0, 0);
for c in (0..=0x10_FFFF_u32).filter(|c| !(0xD800..=0xDFFF).contains(c)) {
let Some(ch) = char::from_u32(c) else { panic!("U+{c:04X}") };
let g = get_general_category(ch);
let g = ch.general_category();
let letter = matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber);
let id_start = (letter || other_start.iter().any(|r| r.contains(&c))) && !excluded[c as usize];
let continuing = matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation);
@@ -509,8 +530,8 @@ fn identifier_characters_are_unicode_16_id_start_and_id_continue() {
assert_eq!(is_id_start(c), id_start || c == 0x24 || c == 0x5F, "start U+{c:04X}");
assert_eq!(is_id_part(c), id_continue || c == 0x24, "part U+{c:04X}");
}
// The totals of ID_Start and ID_Continue in DerivedCoreProperties 16.0.
assert_eq!((id_starts, id_continues), (141_269, 144_541));
// The totals of ID_Start and ID_Continue in DerivedCoreProperties 17.0.
assert_eq!((id_starts, id_continues), (145_916, 149_240));
}
#[test]
+3 -1
View File
@@ -10,7 +10,9 @@ rust-version.workspace = true
workspace = true
[dependencies]
unicode-general-category = "1"
# Unicode 17.0 general categories for names (lex.rs). 0.1.5 adds the Unicode-3.0 license, which is
# not on the license allowlist, so the version is exact.
unicode-properties = { version = "=0.1.4", default-features = false, features = ["general-category"] }
[dev-dependencies]
proptest = { workspace = true }
+6 -9
View File
@@ -10,7 +10,7 @@
use std::cmp::Ordering;
use unicode_general_category::{GeneralCategory as G, get_general_category};
use unicode_properties::{GeneralCategory as G, UnicodeGeneralCategory};
use crate::catalog;
use crate::report::{TOO_DEEP, UNTERMINATED_COMMENT, UNTERMINATED_REGEXP, UNTERMINATED_STRING, UNTERMINATED_TEMPLATE};
@@ -137,7 +137,7 @@ fn is_blank(c: char) -> bool {
/// A character a name may start with: a Unicode `ID_Start` character, `$` or `_` (the Names and
/// Keywords clause of current ECMA-262 editions). `ID_Start` is the letters (categories Lu, Ll, Lt,
/// Lm, Lo and Nl) and `Other_ID_Start`, less `Pattern_Syntax` and `Pattern_White_Space` (Unicode
/// Standard Annex #31). The categories come from Unicode 16.0.
/// Standard Annex #31). The categories come from Unicode 17.0.
pub(crate) fn ident_start(c: char) -> bool {
match c {
'$' | '_' => true,
@@ -145,7 +145,7 @@ pub(crate) fn ident_start(c: char) -> bool {
_ if is_other_id_start(c) => true,
_ => {
matches!(
get_general_category(c),
c.general_category(),
G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber
) && !is_pattern_syntax(c)
}
@@ -162,10 +162,7 @@ pub(crate) fn ident_part(c: char) -> bool {
_ if ident_start(c) => true,
_ if c.is_ascii() => false,
_ if is_other_id_continue(c) => true,
_ => {
matches!(get_general_category(c), G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation)
&& !is_pattern_syntax(c)
}
_ => matches!(c.general_category(), G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation) && !is_pattern_syntax(c),
}
}
@@ -179,13 +176,13 @@ pub(crate) fn escaped(s: &str) -> Option<char> {
.and_then(char::from_u32)
}
/// `Other_ID_Start` (Unicode PropList 16.0): characters kept in `ID_Start` though their category is
/// `Other_ID_Start` (Unicode PropList 17.0): characters kept in `ID_Start` though their category is
/// not a letter.
fn is_other_id_start(c: char) -> bool {
matches!(c, '\u{1885}' | '\u{1886}' | '\u{2118}' | '\u{212E}' | '\u{309B}' | '\u{309C}')
}
/// `Other_ID_Continue` (Unicode PropList 16.0): characters kept in `ID_Continue` though their
/// `Other_ID_Continue` (Unicode PropList 17.0): characters kept in `ID_Continue` though their
/// category is not a letter, mark, digit or connector.
fn is_other_id_continue(c: char) -> bool {
matches!(c, '\u{B7}' | '\u{387}' | '\u{1369}'..='\u{1371}' | '\u{19DA}' | '\u{200C}' | '\u{200D}' | '\u{30FB}' | '\u{FF65}')
+6 -1
View File
@@ -278,12 +278,14 @@ fn unescaped(src: &str) -> String {
fn names_are_made_of_the_unicode_identifier_characters() {
// U+2118 (Other_ID_Start) starts a name, and U+203F (connector punctuation), U+00B7
// (Other_ID_Continue) and the zero width non-joiner and joiner continue one.
for name in ["\u{2118}a", "a\u{203F}b", "a\u{B7}b", "a\u{200C}b", "a\u{200D}b", "_\u{2118}", "$_a1", "\u{E9}t\u{E9}"] {
// Unicode 17.0 assigned the letter U+088F and the combining mark U+1ACF.
for name in ["\u{2118}a", "a\u{203F}b", "a\u{B7}b", "a\u{200C}b", "a\u{200D}b", "_\u{2118}", "$_a1", "\u{E9}t\u{E9}", "\u{88F}", "_\u{1ACF}"] {
assert_eq!(tokens(name), [(T::Ident, name)], "{name:?}");
}
for (src, want) in [
// Characters that only continue a name, and digits, don't start one.
("\u{203F}a", [(T::Other, "\u{203F}"), (T::Ident, "a")]),
("\u{1ACF}a", [(T::Other, "\u{1ACF}"), (T::Ident, "a")]),
("\u{B7}a", [(T::Other, "\u{B7}"), (T::Ident, "a")]),
("\u{200C}a", [(T::Other, "\u{200C}"), (T::Ident, "a")]),
("0a", [(T::Number, "0"), (T::Ident, "a")]),
@@ -318,8 +320,11 @@ fn an_escape_stands_for_a_character_only_where_a_name_may_hold_it() {
("a\\u200Db", "a\u{200D}b"),
("a\\u0030", "a0"),
("\\u0024\\u005F", "$_"),
("\\u088F", "\u{88F}"),
("_\\u1ACF", "_\u{1ACF}"),
// Not at the start of a name.
("\\u0030abc", "\\u0030abc"),
("\\u1ACFa", "\\u1ACFa"),
("\\u203Fa", "\\u203Fa"),
("\\u00B7a", "\\u00B7a"),
// In no name.
+2 -2
View File
@@ -749,9 +749,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`).
- File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files.
- Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 16.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. A `\uXXXX` escape is read as its character only where a name may hold it, so `0abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`).
- Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 17.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. Of the characters Unicode 17.0 assigned, U+088F starts a name and U+1ACF may follow its first character. A `\uXXXX` escape is read as its character only where a name may hold it, so `0abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`).
- `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`.
- `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 16.0, so characters assigned in later Unicode versions are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_16_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`).
- `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 17.0, so U+088F starts an identifier and U+1ACF may follow its first character; characters assigned after Unicode 17.0 are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_17_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_characters_unicode_17_assigned`, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`).
- `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`.
- `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`.
- `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`.