From 995eb54dbf20be2fa419cf7561acece02b0a2551 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:06:20 -0700 Subject: [PATCH] Pin the Intl-over-ICU forms against Node in the corpus Three corpus programs execute the new forms under both Node and the compiled binary: default-locale number formatting (grouping, CLDR fraction digits, infinities, -0, the capability probe), default-locale Date formatting and time formatting, and word segmentation (segments, isWordLike, re-iteration, containing). Their ts7 order/preflight baselines record each as one module with no diagnostics. The harness learns two directives: `@env: NAME=VALUE` pins TZ/LC_ALL for BOTH sides (the directive is part of the program bytes the oracle cache key hashes), and `@requires-icu` skips programs on hosts whose packs do not carry ICU in this slice. The npm-static word-segmenter pilot no longer expects the package's load-time segmenter to fence into a Node island: word segmentation lowers now, so the package compiles statically (checked on the linux-x64 pack; other machines check the analysis and stop before the link). The coverage snapshots and the mixed/tier fixtures move with the narrowed refusal text. Co-Authored-By: Claude Opus 5.5 --- .../intl-date-tolocalestring-default.ts.json | 6 ++ ...intl-number-tolocalestring-default.ts.json | 6 ++ .../tests/corpus/intl-word-segmenter.ts.json | 6 ++ .../intl-date-tolocalestring-default.ts | 30 +++++++++ .../intl-number-tolocalestring-default.ts | 34 ++++++++++ tests/corpus/intl-word-segmenter.ts | 45 +++++++++++++ .../__snapshots__/coverage-dynamic-mix.txt | 6 +- .../coverage-explain-dynamic.txt | 6 +- .../__snapshots__/coverage-tier-demotions.txt | 6 +- .../__snapshots__/date-formatters.ts.txt | 22 +++---- .../__snapshots__/dynamic-surface.ts.txt | 8 +-- .../__snapshots__/intl-segmenter.ts.txt | 30 ++++----- tests/harness/__snapshots__/intl.ts.txt | 65 ++++++++----------- .../harness/__snapshots__/stdlib-fence.ts.txt | 10 +-- .../intl-date-tolocalestring-default.ts.json | 1 + ...intl-number-tolocalestring-default.ts.json | 1 + .../corpus-costs/intl-word-segmenter.ts.json | 1 + tests/harness/differential.test.ts | 52 +++++++++++++-- tests/harness/llvm-differential.test.ts | 49 ++++++++++++-- tests/harness/npm-static.test.ts | 18 +++-- 20 files changed, 302 insertions(+), 100 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-date-tolocalestring-default.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-number-tolocalestring-default.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter.ts.json create mode 100644 tests/corpus/intl-date-tolocalestring-default.ts create mode 100644 tests/corpus/intl-number-tolocalestring-default.ts create mode 100644 tests/corpus/intl-word-segmenter.ts create mode 100644 tests/harness/corpus-costs/intl-date-tolocalestring-default.ts.json create mode 100644 tests/harness/corpus-costs/intl-number-tolocalestring-default.ts.json create mode 100644 tests/harness/corpus-costs/intl-word-segmenter.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-date-tolocalestring-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-date-tolocalestring-default.ts.json new file mode 100644 index 000000000..8bd66074e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-date-tolocalestring-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/intl-date-tolocalestring-default.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-number-tolocalestring-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-number-tolocalestring-default.ts.json new file mode 100644 index 000000000..914d291a1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-number-tolocalestring-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/intl-number-tolocalestring-default.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter.ts.json new file mode 100644 index 000000000..a38a2d192 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/intl-word-segmenter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/intl-word-segmenter.ts" + ], + "diags": [] +} diff --git a/tests/corpus/intl-date-tolocalestring-default.ts b/tests/corpus/intl-date-tolocalestring-default.ts new file mode 100644 index 000000000..3cca6e104 --- /dev/null +++ b/tests/corpus/intl-date-tolocalestring-default.ts @@ -0,0 +1,30 @@ +// @requires-icu +// @env: TZ=UTC,LC_ALL=de_DE.UTF-8 +// Date.prototype.toLocaleString() and toLocaleTimeString() with no +// arguments: the DEFAULT locale's date-time and time patterns, in the host +// time zone (scr_intl.c asks ICU for the best pattern of the skeleton V8 +// asks for, "yMdjms" and "jms", and applies V8's U+202F → U+0020 revert). +// The corpus pins TZ and LC_ALL so the two sides read the same zone and +// locale; everything below — field order, separators, the 24-hour cycle +// this locale prefers, and the Invalid Date text — compares byte for byte. +const dates = [ + new Date(0), + new Date(1700000000000), + new Date(1234567890123), + new Date(2024, 1, 29, 0, 0, 0), + new Date(1999, 11, 31, 23, 59, 59, 999), +]; +for (const d of dates) console.log(d.toLocaleString(), "|", d.toLocaleTimeString()); + +// The morning/afternoon boundary and the yMdjms pattern's own separators. +console.log(new Date(2023, 10, 14, 9, 5, 7).toLocaleString()); +console.log(new Date(2023, 10, 14, 13, 5, 7).toLocaleString()); +console.log(new Date(2023, 10, 14, 13, 5, 7).toLocaleTimeString()); + +// An invalid date answers "Invalid Date" on both methods. +const invalid = new Date(NaN); +console.log(invalid.toLocaleString(), "|", invalid.toLocaleTimeString()); + +// The results are ordinary strings. +const label = `Zeit: ${new Date(1700000000000).toLocaleTimeString()}`; +console.log(label, label.length, new Date(0).toLocaleString().length); diff --git a/tests/corpus/intl-number-tolocalestring-default.ts b/tests/corpus/intl-number-tolocalestring-default.ts new file mode 100644 index 000000000..e63a9373a --- /dev/null +++ b/tests/corpus/intl-number-tolocalestring-default.ts @@ -0,0 +1,34 @@ +// @requires-icu +// @env: LC_ALL=de_DE.UTF-8 +// Number.prototype.toLocaleString() at the DEFAULT locale, which is the one +// form of the method that only ICU can answer (scr_intl.c over the vendored +// ICU archive): grouping, the decimal separator and the CLDR fraction-digit +// rules come from the locale's data, not from the engine-free en-US path +// that 2562-intl-numberformat-en-us.ts pins. The bare call and an explicit +// undefined are the same operation in EMCAScript; both lower here. +const xs = [0, 1, 100, 1000, 1234.5, 1234567.891, -9876543.21, 0.5, 1e6, 1e21]; +for (const x of xs) console.log(x.toLocaleString()); +console.log((1234.5).toLocaleString(undefined), (-0).toLocaleString()); +console.log((0.0625).toLocaleString(), (1.0005).toLocaleString(), (7.995).toLocaleString()); +console.log((NaN).toLocaleString(), (1 / 0).toLocaleString(), (-1 / 0).toLocaleString()); +console.log((5e-4).toLocaleString(), (1e-7).toLocaleString(), (5e-324).toLocaleString()); + +// The result is an ordinary string: template interpolation, concatenation +// and methods all see the formatted text. +const label = `${(1234.5).toLocaleString()} Einheiten`; +console.log(label, label.length, (42).toLocaleString().padStart(8, ".")); +const mapped = xs.map((x) => x.toLocaleString()); +console.log(mapped.join("|")); + +// An explicit undefined argument may carry effects and is evaluated first. +let order = ""; +function undef(value: string): undefined { + order += value; + return undefined; +} +console.log((1234.5).toLocaleString(undef("a")), order); + +// The capability probe Node answers from its own ICU build: an Intl program +// links ICU, so the binary's answer is Node's (the number 1), and reading it +// is what links ICU. +console.log(process.config.variables.v8_enable_i18n_support); diff --git a/tests/corpus/intl-word-segmenter.ts b/tests/corpus/intl-word-segmenter.ts new file mode 100644 index 000000000..cb455d42c --- /dev/null +++ b/tests/corpus/intl-word-segmenter.ts @@ -0,0 +1,45 @@ +// @requires-icu +// @env: LC_ALL=en_US.UTF-8 +// Intl.Segmenter({ granularity: "word" }) — ICU's UAX #29 word break +// iterator (scr_intl.c over the ICU archive), which is the one +// Intl form pi's tui layer needs: the segments carry isWordLike, exactly as +// Node's do. Word boundaries at the default locale and under a locale tag, +// punctuation and digits, re-iteration, and the containing() lookup all pin +// byte-identically against Node. +const text = "Hello, world! It's 3.14 — don't stop, Übermensch."; +const segments = new Intl.Segmenter(undefined, { granularity: "word" }).segment(text); +for (const { segment, index, input, isWordLike } of segments) { + console.log(JSON.stringify(segment), index, input === text, isWordLike); +} + +// The same input through a locale tag: German word breaking is still +// UAX #29 root rules at heart, but the segmenter must accept the tag. +const german = new Intl.Segmenter("de", { granularity: "word" }).segment("Straße 42, größer."); +let words = ""; +let wordCount = 0; +for (const part of german) { + if (part.isWordLike === true) { + words += `${wordCount === 0 ? "" : "|"}${part.segment}`; + wordCount += 1; + } +} +console.log(words, wordCount); + +// Re-iterating one Segments object restarts from the first segment. +const again = new Intl.Segmenter("en", { granularity: "word" }).segment("one two"); +let first = ""; +for (const part of again) first += `[${part.segment}]`; +let second = ""; +for (const part of again) second += `[${part.segment}]`; +console.log(first, second, first === second); + +// containing() answers the segment covering a position and undefined past +// the end, the same contract as the grapheme segmenter's. +const lookup = new Intl.Segmenter("en", { granularity: "word" }).segment("a b"); +for (const position of [-1, 0, 1, 2, 3, 4]) { + const result = lookup.containing(position); + console.log( + position, + result === undefined ? "none" : `${result.index}:${result.segment}:${result.isWordLike}`, + ); +} diff --git a/tests/harness/__snapshots__/coverage-dynamic-mix.txt b/tests/harness/__snapshots__/coverage-dynamic-mix.txt index 0bff0f03c..175f85bc9 100644 --- a/tests/harness/__snapshots__/coverage-dynamic-mix.txt +++ b/tests/harness/__snapshots__/coverage-dynamic-mix.txt @@ -5,6 +5,6 @@ scriptc coverage tests/coverage-fixtures/dynamic-mix.ts in dynamic tier 2 (operate on values typed only at runtime; --explain-dynamic lists them) blockers: - ×1 SC2020 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet - at tests/coverage-fixtures/dynamic-mix.ts:6:12 - hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file + ×1 SC2020 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet + at tests/coverage-fixtures/dynamic-mix.ts:6:35 + hint: what lowers is the default locale (no argument) and the composed en-US form — other locales are ICU data the binary does not negotiate yet \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-explain-dynamic.txt b/tests/harness/__snapshots__/coverage-explain-dynamic.txt index 6e561634d..5cab27066 100644 --- a/tests/harness/__snapshots__/coverage-explain-dynamic.txt +++ b/tests/harness/__snapshots__/coverage-explain-dynamic.txt @@ -10,7 +10,5 @@ <- SC1090 reflection at tests/coverage-fixtures/tier-demotions.ts:10:1: assignment to non-variables are not supported yet -> function turns dynamic function tests/coverage-fixtures/tier-demotions.ts:1:1 (1 site inside) - function precise tests/coverage-fixtures/tier-demotions.ts:21:1 (1 site inside) - binding n in precise tests/coverage-fixtures/tier-demotions.ts:21:18 (1 request) - <- SC2020 operation at tests/coverage-fixtures/tier-demotions.ts:22:10: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet - -> function precise turns dynamic \ No newline at end of file + function precise tests/coverage-fixtures/tier-demotions.ts:21:1 (1 request, 1 site inside) + <- SC2020 operation at tests/coverage-fixtures/tier-demotions.ts:22:27: 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-tier-demotions.txt b/tests/harness/__snapshots__/coverage-tier-demotions.txt index aaf1f4e3b..19b712dbf 100644 --- a/tests/harness/__snapshots__/coverage-tier-demotions.txt +++ b/tests/harness/__snapshots__/coverage-tier-demotions.txt @@ -8,6 +8,6 @@ scriptc coverage tests/coverage-fixtures/tier-demotions.ts ×1 SC1090 assignment to non-variables at tests/coverage-fixtures/tier-demotions.ts:10:1 hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary) - ×1 SC2020 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet - at tests/coverage-fixtures/tier-demotions.ts:22:10 - hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file + ×1 SC2020 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet + at tests/coverage-fixtures/tier-demotions.ts:22:27 + hint: what lowers is the default locale (no argument) and the composed en-US form — other locales are ICU data the binary does not negotiate yet \ No newline at end of file diff --git a/tests/harness/__snapshots__/date-formatters.ts.txt b/tests/harness/__snapshots__/date-formatters.ts.txt index d6dd27c5a..2cbd739d9 100644 --- a/tests/harness/__snapshots__/date-formatters.ts.txt +++ b/tests/harness/__snapshots__/date-formatters.ts.txt @@ -1,17 +1,17 @@ -date-formatters.ts:4:1 - error SC2020: 'Date.prototype.toLocaleTimeString' is part of the standard library types but has no scriptc lowering yet +date-formatters.ts:6:1 - error SC2020: 'Date.prototype.toLocaleDateString' is part of the standard library types but has no scriptc lowering yet - 3 | const date = new Date(0); - 4 | date.toLocaleTimeString(); + 5 | const date = new Date(0); + 6 | date.toLocaleDateString(); | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 5 | date.toLocaleString(); + 7 | date.toLocaleString("en-US"); - hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering + hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), toLocaleString(), toLocaleTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale and options arguments, toLocaleDateString() and the Intl.DateTimeFormat surface have no lowering -date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part of the standard library types but has no scriptc lowering yet +date-formatters.ts:7:1 - error SC2020: 'Date.prototype.toLocaleString with arguments' is part of the standard library types but has no scriptc lowering yet - 4 | date.toLocaleTimeString(); - 5 | date.toLocaleString(); - | ^~~~~~~~~~~~~~~~~~~~~ - 6 | + 6 | date.toLocaleDateString(); + 7 | date.toLocaleString("en-US"); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 8 | - hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering \ No newline at end of file + hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), toLocaleString(), toLocaleTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale and options arguments, toLocaleDateString() and the Intl.DateTimeFormat surface have no lowering \ No newline at end of file diff --git a/tests/harness/__snapshots__/dynamic-surface.ts.txt b/tests/harness/__snapshots__/dynamic-surface.ts.txt index d4fe2ac7e..47834fe92 100644 --- a/tests/harness/__snapshots__/dynamic-surface.ts.txt +++ b/tests/harness/__snapshots__/dynamic-surface.ts.txt @@ -1,8 +1,8 @@ -dynamic-surface.ts:13:15 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet +dynamic-surface.ts:13:38 - error SC2020: 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet 12 | const tau = Math.PI * 2; - 13 | const price = (19.99).toLocaleString(); - | ^~~~~~~~~~~~~~~~~~~~~~~~ + 13 | const price = (19.99).toLocaleString("de-DE"); + | ^~~~~~~ 14 | const n = Number.parseFloat("3.14"); - hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file + hint: what lowers is the default locale (no argument) and the composed en-US form — other locales are ICU data the binary does not negotiate yet \ No newline at end of file diff --git a/tests/harness/__snapshots__/intl-segmenter.ts.txt b/tests/harness/__snapshots__/intl-segmenter.ts.txt index 19a13ef4f..88f442df9 100644 --- a/tests/harness/__snapshots__/intl-segmenter.ts.txt +++ b/tests/harness/__snapshots__/intl-segmenter.ts.txt @@ -1,26 +1,26 @@ -intl-segmenter.ts:2:1 - error SC2020: 'Intl.Segmenter locale negotiation' is part of the standard library types but has no scriptc lowering yet +intl-segmenter.ts:4:1 - error SC2020: 'Intl.Segmenter locale negotiation' is part of the standard library types but has no scriptc lowering yet - 1 | // The native implementation covers Unicode grapheme segmentation. - 2 | new Intl.Segmenter(["en-US", "de"]); + 3 | // sentence segmentation stay fenced. + 4 | new Intl.Segmenter(["en-US", "de"]); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 3 | new Intl.Segmenter(undefined, { granularity: "word" }); + 5 | new Intl.Segmenter(undefined, { granularity: "word", localeMatcher: "best fit" }); hint: omit the locale, pass undefined, or pass one language tag as a string literal -intl-segmenter.ts:3:31 - error SC2020: 'Intl.Segmenter options' is part of the standard library types but has no scriptc lowering yet +intl-segmenter.ts:5:31 - error SC2020: 'Intl.Segmenter options' is part of the standard library types but has no scriptc lowering yet - 2 | new Intl.Segmenter(["en-US", "de"]); - 3 | new Intl.Segmenter(undefined, { granularity: "word" }); - | ^~~~~~~~~~~~~~~~~~~~~~~ - 4 | new Intl.Segmenter(undefined, { granularity: "sentence" }); + 4 | new Intl.Segmenter(["en-US", "de"]); + 5 | new Intl.Segmenter(undefined, { granularity: "word", localeMatcher: "best fit" }); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 6 | new Intl.Segmenter(undefined, { granularity: "sentence" }); - hint: the native segmenter supports default options and { granularity: "grapheme" }; word and sentence segmentation remain unsupported + hint: the native segmenter supports default options, { granularity: "grapheme" } and { granularity: "word" }; sentence segmentation remains unsupported -intl-segmenter.ts:4:31 - error SC2020: 'Intl.Segmenter options' is part of the standard library types but has no scriptc lowering yet +intl-segmenter.ts:6:31 - error SC2020: 'Intl.Segmenter options' is part of the standard library types but has no scriptc lowering yet - 3 | new Intl.Segmenter(undefined, { granularity: "word" }); - 4 | new Intl.Segmenter(undefined, { granularity: "sentence" }); + 5 | new Intl.Segmenter(undefined, { granularity: "word", localeMatcher: "best fit" }); + 6 | new Intl.Segmenter(undefined, { granularity: "sentence" }); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~ - 5 | // End of the diagnostic fixture. + 7 | // End of the diagnostic fixture. - hint: the native segmenter supports default options and { granularity: "grapheme" }; word and sentence segmentation remain unsupported \ No newline at end of file + hint: the native segmenter supports default options, { granularity: "grapheme" } and { granularity: "word" }; sentence segmentation remains unsupported \ No newline at end of file diff --git a/tests/harness/__snapshots__/intl.ts.txt b/tests/harness/__snapshots__/intl.ts.txt index b26d81dbc..d4be83e17 100644 --- a/tests/harness/__snapshots__/intl.ts.txt +++ b/tests/harness/__snapshots__/intl.ts.txt @@ -32,7 +32,7 @@ intl.ts:13:7 - error SC2020: 'NumberFormat' is part of the standard library type | ^~~~~~~ 14 | const viaValue = hoisted.format(1234.5); - hint: Intl formatter values have no representation — the COMPOSED en-US forms lower: new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options; the rest is ICU locale data the binary does not carry + hint: Intl formatter values have no representation — the COMPOSED forms lower: new Intl.NumberFormat("en-US").format(x), x.toLocaleString("en-US") and x.toLocaleString() with default options, d.toLocaleString(), d.toLocaleTimeString(), and Intl.Segmenter with grapheme or word granularity; the formatter objects themselves (locale negotiation, resolvedOptions, options bags) remain unsupported intl.ts:13:17 - error SC2020: 'new Intl.NumberFormat' is part of the standard library types but has no scriptc lowering yet @@ -50,67 +50,58 @@ intl.ts:14:18 - error SC2020: 'NumberFormat.format' is part of the standard libr | ^~~~~~~~~~~~~~ 15 | - hint: locale- and ICU-backed behavior lives outside the static runtime (the localeCompare stance: code-unit order, no collation/locale data) — what lowers: the composed new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options, plus default Unicode grapheme segmentation with Intl.Segmenter; locale negotiation, word/sentence segmentation, and resolvedOptions remain unsupported + hint: collation and locale negotiation stay outside the static runtime (the localeCompare stance: code-unit order, no collation) — what lowers: the composed new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options, their default-locale forms (x.toLocaleString(), d.toLocaleString(), d.toLocaleTimeString()), and Intl.Segmenter with grapheme or word granularity; sentence segmentation, resolvedOptions, and the remaining Intl constructors remain unsupported -intl.ts:17:20 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet +intl.ts:17:40 - error SC2020: 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet 16 | // toLocaleString's unlowered variants. - 17 | const envDefault = (1234.5).toLocaleString(); - | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 18 | const german = (1234.5).toLocaleString("de-DE"); - - hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") - -intl.ts:18:40 - error SC2020: 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet - - 17 | const envDefault = (1234.5).toLocaleString(); - 18 | const german = (1234.5).toLocaleString("de-DE"); + 17 | const german = (1234.5).toLocaleString("de-DE"); | ^~~~~~~ - 19 | const grouped = (1234.5).toLocaleString("en-US", { useGrouping: false }); + 18 | const grouped = (1234.5).toLocaleString("en-US", { useGrouping: false }); - hint: "en-US" (Node's default-build locale) is the one locale whose data the runtime embeds — everything else is ICU data the binary does not carry + hint: what lowers is the default locale (no argument) and the composed en-US form — other locales are ICU data the binary does not negotiate yet -intl.ts:19:17 - error SC2020: 'Number.prototype.toLocaleString with an options bag' is part of the standard library types but has no scriptc lowering yet +intl.ts:18:17 - error SC2020: 'Number.prototype.toLocaleString with an options bag' is part of the standard library types but has no scriptc lowering yet - 18 | const german = (1234.5).toLocaleString("de-DE"); - 19 | const grouped = (1234.5).toLocaleString("en-US", { useGrouping: false }); + 17 | const german = (1234.5).toLocaleString("de-DE"); + 18 | const grouped = (1234.5).toLocaleString("en-US", { useGrouping: false }); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 20 | + 19 | - hint: the embedded data covers DEFAULT options only (decimal notation, up to 3 fraction digits, grouping) — x.toLocaleString("en-US") + hint: what lowers is the DEFAULT options shape (decimal notation, up to 3 fraction digits, grouping) at the default locale or at en-US — omit the options bag -intl.ts:22:14 - error SC2020: 'new Intl.DateTimeFormat' is part of the standard library types but has no scriptc lowering yet +intl.ts:21:14 - error SC2020: 'new Intl.DateTimeFormat' is part of the standard library types but has no scriptc lowering yet - 21 | // The rest of Intl keeps the ICU story. - 22 | const when = new Intl.DateTimeFormat("en-US").format(); + 20 | // The rest of Intl keeps the ICU story. + 21 | const when = new Intl.DateTimeFormat("en-US").format(); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 23 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); + 22 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) -intl.ts:23:13 - error SC2020: 'new Intl.DurationFormat' is part of the standard library types but has no scriptc lowering yet +intl.ts:22:13 - error SC2020: 'new Intl.DurationFormat' is part of the standard library types but has no scriptc lowering yet - 22 | const when = new Intl.DateTimeFormat("en-US").format(); - 23 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); + 21 | const when = new Intl.DateTimeFormat("en-US").format(); + 22 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 24 | const rule = new Intl.PluralRules("en-US").select(1); + 23 | const rule = new Intl.PluralRules("en-US").select(1); hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) -intl.ts:24:14 - error SC2020: 'new Intl.PluralRules' is part of the standard library types but has no scriptc lowering yet +intl.ts:23:14 - error SC2020: 'new Intl.PluralRules' is part of the standard library types but has no scriptc lowering yet - 23 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); - 24 | const rule = new Intl.PluralRules("en-US").select(1); + 22 | const dur = new Intl.DurationFormat("en-US").format({ hours: 1, minutes: 23 }); + 23 | const rule = new Intl.PluralRules("en-US").select(1); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 25 | const canon = Intl.getCanonicalLocales("en-us"); + 24 | const canon = Intl.getCanonicalLocales("en-us"); hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) -intl.ts:25:15 - error SC2020: 'Intl.getCanonicalLocales' is part of the standard library types but has no scriptc lowering yet +intl.ts:24:15 - error SC2020: 'Intl.getCanonicalLocales' is part of the standard library types but has no scriptc lowering yet - 24 | const rule = new Intl.PluralRules("en-US").select(1); - 25 | const canon = Intl.getCanonicalLocales("en-us"); + 23 | const rule = new Intl.PluralRules("en-US").select(1); + 24 | const canon = Intl.getCanonicalLocales("en-us"); | ^~~~~~~~~~~~~~~~~~~~~~~~ - 26 | + 25 | - hint: locale- and ICU-backed behavior lives outside the static runtime (the localeCompare stance: code-unit order, no collation/locale data) — what lowers: the composed new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options, plus default Unicode grapheme segmentation with Intl.Segmenter; locale negotiation, word/sentence segmentation, and resolvedOptions remain unsupported \ No newline at end of file + hint: collation and locale negotiation stay outside the static runtime (the localeCompare stance: code-unit order, no collation) — what lowers: the composed new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options, their default-locale forms (x.toLocaleString(), d.toLocaleString(), d.toLocaleTimeString()), and Intl.Segmenter with grapheme or word granularity; sentence segmentation, resolvedOptions, and the remaining Intl constructors remain unsupported \ No newline at end of file diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index 6ba862c53..3daa5357e 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -32,7 +32,7 @@ stdlib-fence.ts:20:7 - error SC2020: 'NumberFormat' is part of the standard libr | ^~~ 21 | - hint: Intl formatter values have no representation — the COMPOSED en-US forms lower: new Intl.NumberFormat("en-US").format(x) and x.toLocaleString("en-US") with default options; the rest is ICU locale data the binary does not carry + hint: Intl formatter values have no representation — the COMPOSED forms lower: new Intl.NumberFormat("en-US").format(x), x.toLocaleString("en-US") and x.toLocaleString() with default options, d.toLocaleString(), d.toLocaleTimeString(), and Intl.Segmenter with grapheme or word granularity; the formatter objects themselves (locale negotiation, resolvedOptions, options bags) remain unsupported stdlib-fence.ts:20:13 - error SC2020: 'new Intl.NumberFormat' is part of the standard library types but has no scriptc lowering yet @@ -77,14 +77,14 @@ stdlib-fence.ts:39:31 - error SC1090: '.split()' on a 'RegExp | string' separato | ^~~~~~~~~~~~~~~~~~~~~~~~~ 40 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) -stdlib-fence.ts:41:19 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:41:43 - error SC2020: 'Number.prototype.toLocaleString at locale "de-DE"' is part of the standard library types but has no scriptc lowering yet 40 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) - 41 | const localized = (1234.5).toLocaleString(); - | ^~~~~~~~~~~~~~~~~~~~~~~~~ + 41 | const localized = (1234.5).toLocaleString("de-DE"); + | ^~~~~~~ 42 | // Unlowered call FORMS of lowered members. (push is fully variadic now — - hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") + hint: what lowers is the default locale (no argument) and the composed en-US form — other locales are ICU data the binary does not negotiate yet stdlib-fence.ts:47:1 - error SC2020: '.forEach with 2 arguments' is part of the standard library types but has no scriptc lowering yet diff --git a/tests/harness/corpus-costs/intl-date-tolocalestring-default.ts.json b/tests/harness/corpus-costs/intl-date-tolocalestring-default.ts.json new file mode 100644 index 000000000..222ff453e --- /dev/null +++ b/tests/harness/corpus-costs/intl-date-tolocalestring-default.ts.json @@ -0,0 +1 @@ +{ "plain": 6283, "san": 187569 } diff --git a/tests/harness/corpus-costs/intl-number-tolocalestring-default.ts.json b/tests/harness/corpus-costs/intl-number-tolocalestring-default.ts.json new file mode 100644 index 000000000..cf43f47fa --- /dev/null +++ b/tests/harness/corpus-costs/intl-number-tolocalestring-default.ts.json @@ -0,0 +1 @@ +{ "plain": 5069, "san": 54111 } diff --git a/tests/harness/corpus-costs/intl-word-segmenter.ts.json b/tests/harness/corpus-costs/intl-word-segmenter.ts.json new file mode 100644 index 000000000..081935b85 --- /dev/null +++ b/tests/harness/corpus-costs/intl-word-segmenter.ts.json @@ -0,0 +1 @@ +{ "plain": 5195, "san": 90586 } diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index 645992cde..5096359ef 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -107,6 +107,25 @@ function wantsTransformTypes(file: string): boolean { return programInputs(file).some((f) => /\benum\s+[A-Za-z_$]/.test(readFileSync(f, "utf8"))); } +/** `// @env: NAME=VALUE[,NAME=VALUE]` in the entry file's directive head: + * the variables are set for BOTH sides — the Node oracle and the compiled + * binary — so a program can pin the ambient state that decides Intl output + * (TZ for the time zone, LC_ALL/LANG for the default locale). The directive + * lives in the program bytes the oracle cache key hashes, so a recorded + * verdict can never be replayed under different variables. */ +function programEnv(file: string): Record { + const env: Record = {}; + for (const line of directiveHead(file)) { + const m = /^\/\/ @env:\s*(.+?)\s*$/.exec(line); + if (m === null) continue; + for (const pair of m[1]!.split(",")) { + const eq = pair.indexOf("="); + if (eq > 0) env[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); + } + } + return env; +} + /** `// @tsc-decorators` in the entry file's directive head: the Node side * runs tsc's ES-decorator downlevel of the program (target ES2022) — * decorators are the one supported construct Node cannot execute AT ALL @@ -156,6 +175,20 @@ function wantsNoDeprecation(file: string): boolean { return directiveHead(file).some((l) => /^\/\/ @no-deprecation\s*$/.test(l)); } +/** `// @requires-icu` in the directive head: the program's Intl forms are + * answered by ICU, which the runtime packs build only for the Linux x64 + * target in this slice. Other lanes (the macOS-native contracts, the wasm + * and Windows packs) skip these programs instead of failing: the compiler + * refuses the forms there with SC3002. */ +function requiresIcu(file: string): boolean { + return directiveHead(file).some((l) => /^\/\/ @requires-icu\s*$/.test(l)); +} + +/** Whether this machine's runtime pack can link ICU (see requiresIcu). */ +function icuAvailable(): boolean { + return process.platform === "linux" && process.arch === "x64"; +} + /** The Node oracle's argv for a corpus entry (shims + the per-program * transform flag). */ /** `// @disallow-code-generation` in the directive head: the Node oracle @@ -187,7 +220,11 @@ function nodeOracleArgs(file: string): string[] { * (readFileSync(0)), and the default open pipe would block both sides * forever — this way Node and the native binary see the same empty, * closed stream. */ -async function runBinary(cmd: string, args: string[]): Promise { +async function runBinary( + cmd: string, + args: string[], + env: Record = {}, +): Promise { // Linux fork/exec race (observed on the sandbox lane, llvm flavor): a // sibling worker's fork can inherit the freshly-linked binary's write // fd across its own spawn window, and exec answers ETXTBSY until that @@ -195,7 +232,13 @@ async function runBinary(cmd: string, args: string[]): Promise { // briefly, the npm/cargo stance. (No-op on macOS: exec there never // answers ETXTBSY.) for (let attempt = 0; ; attempt++) { - const pending = execFileAsync(cmd, args, { encoding: "buffer" }); + const pending = execFileAsync(cmd, args, { + encoding: "buffer", + // The program's `// @env:` pins layer over the inherited environment, + // so both sides see the same TZ/locale as the rest of the corpus sees + // SCRIPTC_TEST_ENV. + env: { ...process.env, ...env }, + }); pending.child.stdin?.end(); try { const { stdout, stderr } = await pending; @@ -349,7 +392,7 @@ async function runNode(file: string): Promise { // Node 24 strips types natively; the supported subset is erasable by // construction — except `// @transform-types` programs (namespaces), // which run under Node's transform mode. - const res = await runBinary("node", nodeOracleArgs(file)); + const res = await runBinary("node", nodeOracleArgs(file), programEnv(file)); if (cachePath !== null) { try { const tmp = `${cachePath}.${process.pid}.${Math.random().toString(36).slice(2)}`; @@ -396,7 +439,7 @@ async function compileAndRun(file: string): Promise { result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), ); } - return runBinary(result.binaryPath, []); + return runBinary(result.binaryPath, [], programEnv(file)); } describe(`differential corpus (${files.length} programs${sanitize ? ", sanitized" : ""}${shardSuffix()})`, () => { @@ -412,6 +455,7 @@ describe(`differential corpus (${files.length} programs${sanitize ? ", sanitized // `// @requires-libnode`: the binary loads Node from a libnode pack, // required in the Linux CI lanes (libnode.ts), else skipped without one. if (requiresLibnode(file) && libnodePack() === null) skip(); + if (requiresIcu(file) && !icuAvailable()) skip(); const [nodeRes, nativeRes] = await Promise.all([runNode(file), compileAndRun(file)]); // Compare as bytes; decode only for the failure diff. if (!nodeRes.stdout.equals(nativeRes.stdout)) { diff --git a/tests/harness/llvm-differential.test.ts b/tests/harness/llvm-differential.test.ts index 6ffb44b0b..28a9e90c4 100644 --- a/tests/harness/llvm-differential.test.ts +++ b/tests/harness/llvm-differential.test.ts @@ -67,16 +67,51 @@ function expectedExitCode(file: string): number { return 0; } -async function runBinary(cmd: string, args: string[], cpuLevel?: string): Promise { +/** `// @env: NAME=VALUE[,NAME=VALUE]` (differential.test.ts's twin): the + * variables are set for BOTH sides, so a program can pin the ambient state + * that decides Intl output (TZ, LC_ALL/LANG) and the oracle and the binary + * read the same one. */ +function programEnv(file: string): Record { + const env: Record = {}; + for (const line of directiveHead(file)) { + const m = /^\/\/ @env:\s*(.+?)\s*$/.exec(line); + if (m === null) continue; + for (const pair of m[1]!.split(",")) { + const eq = pair.indexOf("="); + if (eq > 0) env[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); + } + } + return env; +} + +/** `// @requires-icu` (differential.test.ts's twin): the program's Intl + * forms are answered by ICU, which only the linux-x64 runtime pack carries + * in this slice; other lanes skip instead of hitting the refusal. */ +function requiresIcu(file: string): boolean { + return directiveHead(file).some((l) => /^\/\/ @requires-icu\s*$/.test(l)); +} + +function icuAvailable(): boolean { + return process.platform === "linux" && process.arch === "x64"; +} + +async function runBinary( + cmd: string, + args: string[], + cpuLevel?: string, + extraEnv: Record = {}, +): Promise { // Linux fork/exec race (observed on the sandbox lane): a sibling // worker's fork can inherit the freshly-linked binary's write fd across // its own spawn window, and exec answers ETXTBSY until that fd closes. // The condition is transient by construction — retry briefly, the // npm/cargo stance. for (let attempt = 0; ; attempt++) { + const env: NodeJS.ProcessEnv = { ...process.env, ...extraEnv }; + if (cpuLevel !== undefined) env["SCRIPTC_CPU_LEVEL"] = cpuLevel; const pending = execFileAsync(cmd, args, { encoding: "buffer", - ...(cpuLevel === undefined ? {} : { env: { ...process.env, SCRIPTC_CPU_LEVEL: cpuLevel } }), + ...(cpuLevel === undefined && Object.keys(extraEnv).length === 0 ? {} : { env }), }); pending.child.stdin?.end(); try { @@ -207,7 +242,13 @@ describe(`llvm differential corpus (${files.length} programs, ${optimizationMode async ([rel, file], { skip }) => { // Programs that call into Node need a libnode pack (libnode.ts). if (requiresLibnode(file) && libnodePack() === null) skip(); - const oracle = await runBinary(process.execPath, nodeOracleArgs(file)); + if (requiresIcu(file) && !icuAvailable()) skip(); + const oracle = await runBinary( + process.execPath, + nodeOracleArgs(file), + undefined, + programEnv(file), + ); const expectedExit = expectedExitCode(file); expect(oracle.exitCode).toBe(expectedExit); for (const optimization of optimizationModes) { @@ -220,7 +261,7 @@ describe(`llvm differential corpus (${files.length} programs, ${optimizationMode expect(result.backend).toBe("llvm"); expect(result.llvmPath.endsWith(".ll")).toBe(true); for (const level of cpuLevels) { - const actual = await runBinary(result.binaryPath, [], level); + const actual = await runBinary(result.binaryPath, [], level, programEnv(file)); const lane = level === undefined ? optimization : `${optimization} level ${level}`; expect(actual.stdout, `${lane} stdout`).toEqual(oracle.stdout); if (expectedExit === 0) diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 337a4b595..7d30a04b8 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -15,7 +15,6 @@ import { promisify } from "node:util"; import { beforeEach, describe, expect, test } from "vitest"; import { analyze, compile } from "@scriptc/compiler"; import { skipUnownedTest } from "./shard.js"; -import { libnodePack } from "./libnode.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); @@ -636,14 +635,18 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { } }); - // The package builds its segmenter at module load, a run-time fence that - // surely fires, so the package runs in its Node island and segments as - // Node does. - test("a word segmenter a package builds at load runs in its Node island", async () => { + // The package builds its word segmenter at module load. Word segmentation + // lowers to ICU now, so nothing fences: the package compiles into the + // program itself (no Node island) and splits words as Node does. The ICU + // supporting units live in the linux-x64 runtime pack only in this slice, + // so other machines check the analysis and stop before the link. + test("a word segmenter a package builds at load compiles statically", async () => { const entry = join(pilotRoot, "word-segments-cli.ts"); const { coverage } = analyze(entry, { npmStatic: "auto" }); expect(coverage.npmStatic).toEqual([{ package: "word-segments", status: "static" }]); expect(coverage.diagnostics).toHaveLength(0); + expect(coverage.runtimeFences ?? []).toHaveLength(0); + if (process.platform !== "linux" || process.arch !== "x64") return; const outDir = join(cacheDir, "word-segments", sanitize ? "san" : "plain"); const result = await compile(entry, { backend: "llvm", @@ -652,11 +655,6 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { outDir, outPath: join(outDir, "program"), }); - if (libnodePack() === null) { - expect(result.ok, "no libnode pack: the compile stops").toBe(false); - if (!result.ok) expect(result.diagnostics.map((d) => d.code)).toContain("SC8005"); - return; - } if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); const nativeRes = await runBinary(result.binaryPath, []);