From eb83febcf2d5a79298e911cffcdae9035cf05fca Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 3 Oct 2026 19:21:34 -0500 Subject: [PATCH] Support Node 24 text utilities (#647) --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 28 +++---- internal/compatibility/dynamic-support.json | 1 + .../generated/node-v24-backlog.json | 42 +++++------ .../generated/node-v24-internal.json | 50 +++++++++---- internal/compatibility/src/generate.mjs | 8 ++ internal/compatibility/static-support.json | 2 + .../ambient/scriptc-node-fallback.d.ts | 2 + .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../frontend/lowering/lower-builtin-values.ts | 3 + .../src/frontend/lowering/lower-builtins.ts | 4 + .../src/frontend/lowering/lower-inspect.ts | 6 +- .../src/frontend/lowering/surfaces.ts | 2 + packages/compiler/src/ir/ir.ts | 6 +- packages/compiler/src/ir/validate.ts | 2 + packages/compiler/surface-manifest.json | 12 +++ .../test/ts7/baselines/order-parity.json | 36 +++++++++ packages/runtime/src/scr_island.c | 36 +++++++-- packages/runtime/src/scr_json.c | 75 ++++++++++++++++++- packages/runtime/src/scr_regex.c | 54 +++++++++++++ packages/runtime/src/scr_runtime.h | 5 ++ packages/runtime/src/scr_symbol.c | 5 ++ .../corpus/node24-text-utilities-coercion.js | 23 ++++++ .../node24-text-utilities-validation.js | 25 +++++++ tests/corpus/node24-text-utilities.cjs | 7 ++ tests/corpus/node24-text-utilities.ts | 23 ++++++ tests/fixtures/node-types/text-utilities.ts | 6 ++ .../npm/cases/node24-text-utilities/main.ts | 4 + .../cases/node24-text-utilities/package.json | 1 + .../node24-text-utility-fixture/index.d.ts | 1 + .../node24-text-utility-fixture/index.js | 42 +++++++++++ .../node24-text-utility-fixture/package.json | 1 + tests/harness/project-config.test.ts | 11 +++ 33 files changed, 461 insertions(+), 66 deletions(-) create mode 100644 tests/corpus/node24-text-utilities-coercion.js create mode 100644 tests/corpus/node24-text-utilities-validation.js create mode 100644 tests/corpus/node24-text-utilities.cjs create mode 100644 tests/corpus/node24-text-utilities.ts create mode 100644 tests/fixtures/node-types/text-utilities.ts create mode 100644 tests/fixtures/npm/cases/node24-text-utilities/main.ts create mode 100644 tests/fixtures/npm/cases/node24-text-utilities/package.json create mode 100644 tests/fixtures/npm/node_modules/node24-text-utility-fixture/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/node24-text-utility-fixture/index.js create mode 100644 tests/fixtures/npm/node_modules/node24-text-utility-fixture/package.json diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 355d59cf8..083d603b3 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", - "artifactVersion": "b22c67d8306e45a80390", + "artifactVersion": "5c443421620cc3431067", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index bc763c613..399041929 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -1202,16 +1202,16 @@ "apiEntries": 105, "static": { "supported": 0, - "partial": 18, + "partial": 20, "refused": 0, - "not-implemented": 87, + "not-implemented": 85, "by-design": 0, "unreviewed": 0, "not-applicable": 0 }, "dynamic": { - "supported": 16, - "partial": 76, + "supported": 18, + "partial": 74, "refused": 0, "not-implemented": 13, "by-design": 0, @@ -85570,13 +85570,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Removes ANSI control sequences with the Node matcher and validates common non-string argument forms.", + "verification": "test-backed" }, "dynamic": { - "status": "partial", - "detail": "Available through the embedded Node compatibility shim; exact member and overload coverage may be narrower.", + "status": "supported", + "detail": "Removes ANSI control sequences with the Node matcher and validates non-string arguments.", "verification": "test-backed" } }, @@ -85626,13 +85626,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Converts native strings, primitives, and tested object conversion hooks to well-formed Unicode strings; native storage replaces unpaired surrogates at creation.", + "verification": "test-backed" }, "dynamic": { - "status": "partial", - "detail": "Available through the embedded Node compatibility shim; exact member and overload coverage may be narrower.", + "status": "supported", + "detail": "Replaces unpaired surrogate code units and preserves Node string coercion, custom conversion hooks, and conversion errors.", "verification": "test-backed" } }, diff --git a/internal/compatibility/dynamic-support.json b/internal/compatibility/dynamic-support.json index f9ec935be..ac88daa50 100644 --- a/internal/compatibility/dynamic-support.json +++ b/internal/compatibility/dynamic-support.json @@ -19,6 +19,7 @@ "wasi": "by-design" }, "features": [ + { "chapter": "util", "symbols": ["util.stripVTControlCharacters", "util.toUSVString"], "status": "supported", "evidence": ["tests/fixtures/npm/cases/node24-text-utilities/main.ts"] }, { "chapter": "util", "symbols": ["util.getSystemErrorName", "util.getSystemErrorMessage", "util.getSystemErrorMap"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/node24-system-errors/main.ts"] }, { "chapter": "url", "symbols": ["url.fileURLToPath", "url.fileURLToPathBuffer", "url.pathToFileURL"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/node24-file-url-conversion/main.ts"] }, { "chapter": "diagnostics_channel", "symbols": ["start", "end", "asyncStart", "asyncEnd", "error", "TracingChannel", "channel.bindStore", "channel.unbindStore", "channel.runStores", "tracingChannel.subscribe", "tracingChannel.unsubscribe", "tracingChannel.traceSync", "tracingChannel.tracePromise", "tracingChannel.traceCallback"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/node24-diagnostics-tracing/main.ts", "tests/fixtures/npm/cases/node24-diagnostics-async/main.ts", "tests/fixtures/npm/cases/node24-diagnostics-subscriber-throw/main.ts", "tests/fixtures/npm/cases/node24-diagnostics-transform-throw/main.ts"] }, diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 35442029c..2b13017ee 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -4,24 +4,24 @@ "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", "summary": { "taskCount": 3368, - "tierItemCount": 6647, + "tierItemCount": 6645, "tiers": { "static": { "replace-refusal": 43, - "verify-gap": 2512, - "audit-partial": 657, + "verify-gap": 2510, + "audit-partial": 659, "classify": 2, "implement": 81 }, "dynamic": { - "audit-partial": 848, + "audit-partial": 846, "verify-gap": 2178, "replace-refusal": 112, "implement": 214 } }, "priorities": { - "high": 4484, + "high": 4482, "normal": 1411, "low": 752 } @@ -95707,21 +95707,17 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] - }, - "dynamic": { "status": "partial", "action": "audit-partial", "verification": "test-backed", "confidence": "high", - "source": "island-export:node:util.stripVTControlCharacters", + "source": "compiler-feature:util.util.stripVTControlCharacters", "tests": [ - "tests/fixtures/npm/cases/util-shims/main.ts" + "tests/corpus/node24-text-utilities.ts", + "tests/corpus/node24-text-utilities-validation.js", + "tests/corpus/node24-text-utilities-coercion.js", + "tests/corpus/node24-text-utilities.cjs", + "tests/fixtures/node-types/text-utilities.ts" ] } } @@ -95777,21 +95773,17 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] - }, - "dynamic": { "status": "partial", "action": "audit-partial", "verification": "test-backed", "confidence": "high", - "source": "island-export:node:util.toUSVString", + "source": "compiler-feature:util.util.toUSVString", "tests": [ - "tests/fixtures/npm/cases/util-shims/main.ts" + "tests/corpus/node24-text-utilities.ts", + "tests/corpus/node24-text-utilities-validation.js", + "tests/corpus/node24-text-utilities-coercion.js", + "tests/corpus/node24-text-utilities.cjs", + "tests/fixtures/node-types/text-utilities.ts" ] } } diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 8e04d68dc..456e5b06e 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -1323,16 +1323,16 @@ "apiEntries": 105, "static": { "supported": 0, - "partial": 18, + "partial": 20, "refused": 0, - "not-implemented": 87, + "not-implemented": 85, "by-design": 0, "unreviewed": 0, "not-applicable": 0 }, "dynamic": { - "supported": 16, - "partial": 76, + "supported": 18, + "partial": 74, "refused": 0, "not-implemented": 13, "by-design": 0, @@ -32294,7 +32294,7 @@ "nodeStability": null, "static": { "status": "not-applicable", - "evidence": "compiler-chapter-policy:deprecations" + "evidence": "compiler-feature:deprecations.util.toUSVString" }, "dynamic": { "status": "not-applicable", @@ -111273,11 +111273,16 @@ "tests/corpus/node24-system-errors.ts", "tests/corpus/node24-text-codec-properties.ts", "tests/corpus/node24-text-encode-into.ts", + "tests/corpus/node24-text-utilities-coercion.js", + "tests/corpus/node24-text-utilities-validation.js", + "tests/corpus/node24-text-utilities.cjs", + "tests/corpus/node24-text-utilities.ts", "tests/corpus/text-codec-values/main.ts", "tests/fixtures/node-types/env-files.ts", "tests/fixtures/node-types/system-errors.ts", "tests/fixtures/node-types/text-codecs.mts", "tests/fixtures/node-types/text-encoding.ts", + "tests/fixtures/node-types/text-utilities.ts", "tests/harness/project-config.test.ts" ] }, @@ -111291,6 +111296,7 @@ "tests/fixtures/npm/cases/node24-parseargs/main.ts", "tests/fixtures/npm/cases/node24-system-errors/main.ts", "tests/fixtures/npm/cases/node24-text-encoding/main.ts", + "tests/fixtures/npm/cases/node24-text-utilities/main.ts", "tests/fixtures/npm/cases/node24-util-adapters/main.ts", "tests/fixtures/npm/cases/util-shims/main.ts" ] @@ -113252,14 +113258,21 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.stripVTControlCharacters", + "tests": [ + "tests/corpus/node24-text-utilities.ts", + "tests/corpus/node24-text-utilities-validation.js", + "tests/corpus/node24-text-utilities-coercion.js", + "tests/corpus/node24-text-utilities.cjs", + "tests/fixtures/node-types/text-utilities.ts" + ] }, "dynamic": { - "status": "partial", - "evidence": "island-export:node:util.stripVTControlCharacters", + "status": "supported", + "evidence": "island-feature:util.util.stripVTControlCharacters", "tests": [ - "tests/fixtures/npm/cases/util-shims/main.ts" + "tests/fixtures/npm/cases/node24-text-utilities/main.ts" ] }, "anchorSource": "exact" @@ -113312,14 +113325,21 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.toUSVString", + "tests": [ + "tests/corpus/node24-text-utilities.ts", + "tests/corpus/node24-text-utilities-validation.js", + "tests/corpus/node24-text-utilities-coercion.js", + "tests/corpus/node24-text-utilities.cjs", + "tests/fixtures/node-types/text-utilities.ts" + ] }, "dynamic": { - "status": "partial", - "evidence": "island-export:node:util.toUSVString", + "status": "supported", + "evidence": "island-feature:util.util.toUSVString", "tests": [ - "tests/fixtures/npm/cases/util-shims/main.ts" + "tests/fixtures/npm/cases/node24-text-utilities/main.ts" ] }, "anchorSource": "exact" diff --git a/internal/compatibility/src/generate.mjs b/internal/compatibility/src/generate.mjs index a642be17a..8e4e9e50a 100644 --- a/internal/compatibility/src/generate.mjs +++ b/internal/compatibility/src/generate.mjs @@ -628,6 +628,12 @@ function publicDetail(tier) { if (source === "compiler-feature:util.util.getSystemErrorMap") { return "Returns a fresh Map of platform-specific error codes, names, and messages; entry tuples support updates but retain their fixed size."; } + if (source === "compiler-feature:util.util.stripVTControlCharacters") { + return "Removes ANSI control sequences with the Node matcher and validates common non-string argument forms."; + } + if (source === "compiler-feature:util.util.toUSVString") { + return "Converts native strings, primitives, and tested object conversion hooks to well-formed Unicode strings; native storage replaces unpaired surrogates at creation."; + } if (/^compiler-feature:util\.util\.getSystemError(?:Name|Message)$/.test(source)) { return "Converts platform-specific negative error codes to names or messages, with Node-compatible validation for common argument forms."; } @@ -690,6 +696,8 @@ function publicDetail(tier) { return "No dynamic-island implementation exists for this Node API family yet."; } if (source.startsWith("island-feature:")) { + if (source === "island-feature:util.util.stripVTControlCharacters") return "Removes ANSI control sequences with the Node matcher and validates non-string arguments."; + if (source === "island-feature:util.util.toUSVString") return "Replaces unpaired surrogate code units and preserves Node string coercion, custom conversion hooks, and conversion errors."; if (source.startsWith("island-feature:diagnostics_channel.")) return "Publishes synchronous, promise, and callback tracing lifecycle events with result/error identity, subscriber management, and store bindings. Bound stores follow the embedded engine's synchronous AsyncLocalStorage model and do not propagate across awaits."; if (source.startsWith("island-feature:timers.timersPromises.")) return "Provides cancellable promise timers, buffered interval iteration, scheduler waits and yields, and ref options over the native event loop; delay warnings remain outside this subset."; if (source.startsWith("island-feature:timers.")) return "Provides timer and immediate handles with reference control, refresh, cancellation, and disposal over the native event loop."; diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 509165f48..d1fe7c46b 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -102,6 +102,8 @@ "stdlib.abort": ["tests/harness/fetch-conformance.test.ts"] }, "features": [ + { "chapter": "deprecations", "symbols": ["util.toUSVString"], "status": "not-applicable" }, + { "chapter": "util", "symbols": ["util.stripVTControlCharacters", "util.toUSVString"], "status": "partial", "evidence": ["tests/corpus/node24-text-utilities.ts", "tests/corpus/node24-text-utilities-validation.js", "tests/corpus/node24-text-utilities-coercion.js", "tests/corpus/node24-text-utilities.cjs", "tests/fixtures/node-types/text-utilities.ts"] }, { "chapter": "util", "symbols": ["util.getSystemErrorName", "util.getSystemErrorMessage", "util.getSystemErrorMap"], "status": "partial", "evidence": ["tests/corpus/node24-system-errors.ts", "tests/corpus/node24-system-error-validation.js", "tests/corpus/node24-system-errors.cjs", "tests/fixtures/node-types/system-errors.ts"] }, { "chapter": "buffer", "symbols": ["buffer.isAscii", "buffer.isUtf8", "buffer.transcode"], "status": "partial", "evidence": ["tests/corpus/node24-buffer-encoding.ts", "tests/corpus/node24-buffer-encoding-errors.ts", "tests/fixtures/node-types/text-encoding.ts"] }, { "chapter": "util", "parent": "Class: `util.TextEncoder`", "signatures": ["`textEncoder.encodeInto(src, dest)`", "`encoding` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/node24-text-encode-into.ts", "tests/corpus/node24-text-codec-properties.ts", "tests/fixtures/node-types/text-encoding.ts"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index e619960e1..0f6a95d8d 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1887,6 +1887,8 @@ declare module "util" { export function getSystemErrorName(err: number): string; export function getSystemErrorMessage(err: number): string; export function getSystemErrorMap(): Map; + export function stripVTControlCharacters(str: string): string; + export function toUSVString(string: string): string; export function parseEnv(content: string): { [key: string]: string | undefined }; export type TextEncoder = globalThis.TextEncoder; export type TextDecoder = globalThis.TextDecoder; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 378ca7a6f..a363aafbf 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -60,6 +60,8 @@ export const LIB_FN_SYMS: Record = { "util.getSystemErrorName": "scr_util_system_error_name", "util.getSystemErrorMessage": "scr_util_system_error_message", "util.systemErrorEntries": "scr_util_system_error_entries", + "util.stripVTControlCharacters": "scr_util_strip_vt", + "util.toUSVString": "scr_util_to_usv_string", "math.maxArr": "scr_math_max_arr", "math.minArr": "scr_math_min_arr", "math.hypotArr": "scr_math_hypot_arr", diff --git a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts index a2593cd44..408308811 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts @@ -28,6 +28,9 @@ export function builtinPrototypeMethod(lowerer: Lowerer, expr: ts.PropertyAccess export function isNativeBuiltinValueInitializer(lowerer: Lowerer, expr: ts.Expression | undefined, seen = new Set()): boolean { if (!expr) return false; while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + const builtin = ts.isIdentifier(expr) ? lowerer.builtinImportOf(expr) + : ts.isPropertyAccessExpression(expr) ? lowerer.builtinMemberOf(expr) : null; + if (builtin?.module === "util" && ["stripVTControlCharacters", "toUSVString"].includes(builtin.member)) return true; // JS global snapshots may use an opaque builtin identity rather than // the ambient constructor's callable ABI. Store that actual value in // the checked representation, including optional capability probes. diff --git a/packages/compiler/src/frontend/lowering/lower-builtins.ts b/packages/compiler/src/frontend/lowering/lower-builtins.ts index 8c8789c49..0771b3624 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtins.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtins.ts @@ -3952,6 +3952,10 @@ export function lowerForkCall(lowerer: Lowerer, expr: ts.CallExpression, loc: Sr ? { module: existing.module, member: existing.member } : lowerer.builtinImportOf(targetExpr); if (!target || !builtinModuleFnOf(lowerer, target.module, target.member)) return false; + // These checked native functions retain a runtime slot in JavaScript so + // aliases can escape without narrowing their arguments to string. + if (isJsSourceFile(nameNode.getSourceFile()) && target.module === "util" && + ["stripVTControlCharacters", "toUSVString"].includes(target.member)) return false; if (lowerer.checker.getCallSignatures(lowerer.typeOf(nameNode)).length === 0) return false; const symbol = lowerer.checker.getSymbolAtLocation(nameNode); if (!symbol) return false; diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 54313d8fa..96c1edeb0 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -1390,12 +1390,14 @@ export function lowerUtilModuleCall( return stmts.length ? { kind: "seqExpr", stmts, result, type: result.type, loc } : result; } case "getSystemErrorName": - case "getSystemErrorMessage": { + case "getSystemErrorMessage": + case "stripVTControlCharacters": + case "toUSVString": { if (expr.arguments.some(ts.isSpreadElement)) lowerer.noLowering(`util.${bi.member} with spread arguments`, expr); const input = expr.arguments[0] ? lowerer.lowerExprExpecting(expr.arguments[0], DYN) : { kind: "dynFrom" as const, value: { kind: "unitLit" as const, unit: "undefined" as const, type: UNDEFINED_T, loc }, type: DYN, loc }; if (expr.arguments.length <= 1) return { kind: "libCall", fn: `util.${bi.member}`, args: [input], type: STRING, loc }; - const saved = lowerer.declareHiddenLocal("%systemError", DYN); + const saved = lowerer.declareHiddenLocal("%utilInput", DYN); return { kind: "seqExpr", stmts: [{ kind: "varDecl", localId: saved.id, init: input, loc }, ...expr.arguments.slice(1).map((arg): IrStmt => ({ kind: "exprStmt", expr: lowerer.lowerExpr(arg), loc }))], result: { kind: "libCall", fn: `util.${bi.member}`, args: [varRef(saved.id, DYN, loc)], type: STRING, loc }, type: STRING, loc }; diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 451a64fd0..1f0a22a95 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -888,6 +888,8 @@ export const BUILTIN_MODULE_FNS: Record { if (node.kind === "libCall") { const fn = node.fn; - if (fn === "regexp.escape" || fn === "dyn.nativeRegexIs") features.regex = true; + if (fn === "regexp.escape" || fn === "dyn.nativeRegexIs" || fn === "util.stripVTControlCharacters") features.regex = true; if (fn === "text.decodeLegacy" || fn === "text.decodeLegacyOptions" || fn === "text.decodeStream") features.legacyTextDecoder = true; if (fn.startsWith("fetch.")) features.fetch = true; if (PROCESS_EVENT_LIB_FNS.has(fn)) features.processEvents = true; @@ -7782,6 +7784,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "util.parseEnv", "util.getSystemErrorName", "util.getSystemErrorMessage", + "util.stripVTControlCharacters", + "util.toUSVString", // decodeURIComponent throws the spec's URIError on bad hex/invalid // UTF-8 octets (encodeURIComponent never throws — see the IrLibFn doc). "str.decodeUriComponent", diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index f60f02dfb..f6b0204ae 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -435,6 +435,8 @@ export const LIB_FN_SIGS: Record/tests/fixtures/node-types/system-errors.ts" ], "diags": [] + }, + "/tests/corpus/node24-text-utilities.ts": { + "order": [ + "/tests/corpus/node24-text-utilities.ts" + ], + "diags": [] + }, + "/tests/corpus/node24-text-utilities.cjs": { + "order": [ + "/tests/corpus/node24-text-utilities.cjs" + ], + "diags": [] + }, + "/tests/corpus/node24-text-utilities-validation.js": { + "order": [ + "/tests/corpus/node24-text-utilities-validation.js" + ], + "diags": [] + }, + "/tests/fixtures/npm/cases/node24-text-utilities/main.ts": { + "order": [ + "/tests/fixtures/npm/cases/node24-text-utilities/main.ts" + ], + "diags": [] + }, + "/tests/fixtures/node-types/text-utilities.ts": { + "order": [ + "/tests/fixtures/node-types/text-utilities.ts" + ], + "diags": [] + }, + "/tests/corpus/node24-text-utilities-coercion.js": { + "order": [ + "/tests/corpus/node24-text-utilities-coercion.js" + ], + "diags": [] } } } diff --git a/packages/runtime/src/scr_island.c b/packages/runtime/src/scr_island.c index a7a60a3ee..bc8eb10b1 100644 --- a/packages/runtime/src/scr_island.c +++ b/packages/runtime/src/scr_island.c @@ -8697,13 +8697,9 @@ static const char isl_modules_bootstrap[] = " }\n" " };\n" " const isDeepStrictEqual = (a, b) => deepEquals(a, b, undefined);\n" - " const ansiRe = /[\\u001b›][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/\\\\#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/\\\\#&.:=?%@~_]*)*)?(?:|\\u001b\\|œ))|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;\n" + " const ansiRe = /[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/\\#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/\\#&.:=?%@~_]*)*)?(?:\\u0007|\\u001B\\u005C|\\u009C))|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;\n" " const stripVTControlCharacters = (str) => {\n" - " if (typeof str !== \"string\") {\n" - " const e = new TypeError('The \"str\" argument must be of type string. Received ' + (str === null ? \"null\" : typeof str === \"object\" ? \"an instance of Object\" : typeof str === \"undefined\" ? \"undefined\" : \"type \" + typeof str));\n" - " e.code = \"ERR_INVALID_ARG_TYPE\";\n" - " throw e;\n" - " }\n" + " if (typeof str !== \"string\") envArgumentError(\"str\", \"of type string\", str);\n" " return str.replace(ansiRe, \"\");\n" " };\n" " const styleText = (fmt, text, options) => {\n" @@ -8866,7 +8862,33 @@ static const char isl_modules_bootstrap[] = " }\n" " return result;\n" " };\n" - " const toUSVString = (s) => String(s).replace(/[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(? {\n" + " let primitive = s;\n" + " const isObject = (v) => v !== null && (typeof v === 'object' || typeof v === 'function');\n" + " if (isObject(s)) {\n" + " const exotic = s[Symbol.toPrimitive];\n" + " if (exotic !== undefined && exotic !== null) {\n" + " if (typeof exotic !== 'function') {\n" + " const kind = typeof exotic;\n" + " const label = ['object', 'symbol', 'bigint'].includes(kind) ? kind : kind + ' ' + (kind === 'string' ? '\"' + exotic + '\"' : String(exotic));\n" + " throw new TypeError(label + ' is not a function');\n" + " }\n" + " primitive = Reflect.apply(exotic, s, ['string']);\n" + " if (isObject(primitive)) throw new TypeError('Cannot convert object to primitive value');\n" + " } else {\n" + " let converted = false;\n" + " for (const name of ['toString', 'valueOf']) {\n" + " const method = s[name];\n" + " if (typeof method !== 'function') continue;\n" + " primitive = Reflect.apply(method, s, []);\n" + " if (!isObject(primitive)) { converted = true; break; }\n" + " }\n" + " if (!converted) throw new TypeError('Cannot convert object to primitive value');\n" + " }\n" + " }\n" + " if (typeof primitive === 'symbol') throw new TypeError('Cannot convert a Symbol value to a string');\n" + " return `${primitive}`.replace(/[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(? {\n" " if (source === null || typeof source !== \"object\") return target;\n" " for (const k of Object.keys(source)) target[k] = source[k];\n" diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index f957a3b70..9f0cdd4ce 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -57,6 +57,7 @@ static void scr_json_oom(void) { static SCR_TL ScrDyn *scr_iterator_symbol; static SCR_TL ScrDyn *scr_async_iterator_symbol; +static SCR_TL ScrDyn *scr_to_primitive_symbol; static bool scr_dyn_generator(const ScrDyn *value) { return value && value->kind == SCR_DYN_TYPED_REF && (!strncmp(value->v.typed_ref.type_key, "generator<", 10) || !strncmp(value->v.typed_ref.type_key, "async-generator<", 16)); @@ -104,6 +105,17 @@ static void scr_async_iterator_symbol_cleanup(void) { scr_async_iterator_symbol = NULL; } +static void scr_to_primitive_symbol_cleanup(void) { + scr_dyn_release(scr_to_primitive_symbol); + scr_to_primitive_symbol = NULL; +} + +void scr_dyn_install_to_primitive_symbol(ScrDyn *key) { + if (scr_to_primitive_symbol) return; + scr_to_primitive_symbol = scr_dyn_retain(key); + scr_atexit(scr_to_primitive_symbol_cleanup); +} + void scr_dyn_install_async_iterator_symbol(ScrDyn *key) { if (scr_async_iterator_symbol) return; scr_async_iterator_symbol = scr_dyn_retain(key); @@ -2352,7 +2364,9 @@ const char *scr_dyn_specific_type(const ScrDyn *cb, char *detail, size_t cap) { } case SCR_DYN_NUM: { char num[32]; - size_t n = scr_f64_to_str(cb->v.num, num); + size_t n; + if (cb->v.num == 0 && signbit(cb->v.num)) { memcpy(num, "-0", 3); n = 2; } + else n = scr_f64_to_str(cb->v.num, num); snprintf(detail, cap, "type number (%.*s)", (int)n, num); break; } @@ -4576,6 +4590,65 @@ ScrStr *scr_dyn_string_coerce_js(const ScrDyn *d) { return scr_dyn_string_coerce(d); } +ScrStr *scr_util_to_usv_string(const ScrDyn *input) { + const ScrDyn *value = input ? input : scr_dyn_undefined(); + /* Native string producers already replace unpaired surrogate code units. + * Preserve ToString's object protocol before using that storage invariant. */ + if (scr_to_primitive_symbol && scr_iterator_object(value)) { + ScrDyn *method = scr_dyn_reflect_get((ScrDyn *)value, scr_to_primitive_symbol, (ScrDyn *)value); + if (!method) return NULL; + if (method->kind != SCR_DYN_UNDEF && method->kind != SCR_DYN_NULL) { + if (!scr_dyn_is_callable(method)) { + ScrJsonBuf buffer; + scr_jb_init(&buffer); + if (scr_iterator_object(method)) scr_jb_puts(&buffer, "object"); + else { + ScrStr *type = scr_dyn_typeof(method); + scr_jb_put_str(&buffer, type); + scr_str_release(type); + if (method->kind != SCR_DYN_SYMBOL && method->kind != SCR_DYN_BIGINT) scr_jb_puts(&buffer, " "); + if (method->kind == SCR_DYN_STR) { + scr_jb_puts(&buffer, "\""); + scr_jb_put_str(&buffer, method->v.str); + scr_jb_puts(&buffer, "\""); + } + else if (method->kind != SCR_DYN_SYMBOL && method->kind != SCR_DYN_BIGINT) { + ScrStr *text = scr_dyn_string_coerce(method); + scr_jb_put_str(&buffer, text); + scr_str_release(text); + } + } + scr_jb_puts(&buffer, " is not a function"); + ScrStr *message = scr_jb_finish(&buffer); + scr_throw_error_msg(SCR_ERR_TYPE, message->data, message->len); + scr_str_release(message); + scr_dyn_release(method); + return NULL; + } + ScrStr *hint = scr_str_new("string", 6); + ScrDyn *argument = scr_dyn_new_str(hint); + scr_str_release(hint); + scr_dyn_this_push_dyn(value); + ScrDyn *primitive = scr_dyn_call(method, &argument, 1, "Symbol.toPrimitive"); + scr_dyn_this_pop(); + scr_dyn_release(argument); + scr_dyn_release(method); + if (!primitive) return NULL; + if (scr_dyn_to_primitive_result_is_object(primitive)) { + static const char message[] = "Cannot convert object to primitive value"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + scr_dyn_release(primitive); + return NULL; + } + ScrStr *text = scr_dyn_string_coerce(primitive); + scr_dyn_release(primitive); + return text; + } + scr_dyn_release(method); + } + return scr_dyn_string_coerce_js(value); +} + /* JS ToNumber over a checked-dynamic value, including OrdinaryToPrimitive's * NUMBER hint for object snapshots. This is the numeric twin of * scr_dyn_string_coerce_js above: valueOf precedes toString, inherited diff --git a/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index 79191f9b5..e66064b47 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -719,6 +719,60 @@ ScrStr *scr_regex_replace(ScrStr *s, ScrRegex *re, ScrStr *rep) { return scr_replace_impl(s, re, rep); } + +/* The pinned Node ANSI matcher derives from ansi-regex, copyright (c) + * Sindre Sorhus , used under the MIT license: + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * Keep the pattern equivalent to Node v24.15.0 internal/util/inspect.js. */ +#define SCR_VT_PATTERN "[\\u001B\\u009B][[\\]()#;?]*" \ + "(?:(?:(?:(?:;[-a-zA-Z\\d\\/\\#&.:=?%@~_]+)*" \ + "|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/\\#&.:=?%@~_]*)*)?" \ + "(?:\\u0007|\\u001B\\u005C|\\u009C))" \ + "|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))" + +ScrStr *scr_util_strip_vt(const ScrDyn *input) { + if (!input || input->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("str", "of type string", input ? input : scr_dyn_undefined()); + return NULL; + } + ScrStr *subject = input->v.str; + bool escape = false; + for (size_t i = 0; i < subject->len; i++) { + if (subject->data[i] == '\x1b' || ((unsigned char)subject->data[i] == 0xc2 && + i + 1 < subject->len && (unsigned char)subject->data[i + 1] == 0x9b)) { + escape = true; + break; + } + } + if (!escape) return scr_str_retain(subject); + static const struct { size_t rc, len, cap; char data[sizeof SCR_VT_PATTERN]; } + source = { SIZE_MAX, sizeof SCR_VT_PATTERN - 1, sizeof SCR_VT_PATTERN - 1, SCR_VT_PATTERN }; + static const struct { size_t rc, len, cap; char data[2]; } + flags = { SIZE_MAX, 1, 1, "g" }; + static SCR_TL ScrRegex template = { SIZE_MAX, (ScrStr *)&source, (ScrStr *)&flags, NULL, 0, NULL }; + ScrRegex *regex = scr_regex_literal(&template); + ScrStr *empty = scr_str_new("", 0); + ScrStr *result = scr_regex_replace(subject, regex, empty); + scr_str_release(empty); + scr_regex_release(regex); + return result; +} +#undef SCR_VT_PATTERN + ScrStr *scr_regex_replace_all(ScrStr *s, ScrRegex *re, ScrStr *rep) { if (!(lre_get_flags(scr_regex_bc(re)) & LRE_FLAG_GLOBAL)) { /* Node's exact TypeError — a real instance now: catch bindings narrow diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index d25b6dc31..72947a77e 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -818,6 +818,11 @@ ScrStr *scr_str_pad_end(ScrStr *s, double maxLength, ScrStr *fill); * both are no-ops on well-formed input). Borrow s; toWellFormed +1. */ bool scr_str_is_well_formed(ScrStr *s); ScrStr *scr_str_to_well_formed(ScrStr *s); +/* Node text utilities: borrowed input, +1 result or NULL+pending. The VT + * helper lives in the optional regex unit; USV conversion uses native strings. */ +ScrStr *scr_util_strip_vt(const struct ScrDyn *input); +ScrStr *scr_util_to_usv_string(const struct ScrDyn *input); +void scr_dyn_install_to_primitive_symbol(struct ScrDyn *key); /* RegExp.escape(s) — ES2025 EncodeForRegExpEscape per code point: leading * ASCII alphanumeric hex-escapes, SyntaxCharacters and '/' take a diff --git a/packages/runtime/src/scr_symbol.c b/packages/runtime/src/scr_symbol.c index 36415c57b..d54a21110 100644 --- a/packages/runtime/src/scr_symbol.c +++ b/packages/runtime/src/scr_symbol.c @@ -90,6 +90,11 @@ ScrSym *scr_sym_well_known(ScrStr *name) { scr_dyn_install_async_iterator_symbol(key); scr_dyn_release(key); } + if (name->len == 11 && !memcmp(name->data, "toPrimitive", 11)) { + ScrDyn *key = scr_dyn_new_symbol(symbol); + scr_dyn_install_to_primitive_symbol(key); + scr_dyn_release(key); + } return symbol; } diff --git a/tests/corpus/node24-text-utilities-coercion.js b/tests/corpus/node24-text-utilities-coercion.js new file mode 100644 index 000000000..467bbaef5 --- /dev/null +++ b/tests/corpus/node24-text-utilities-coercion.js @@ -0,0 +1,23 @@ +import { toUSVString as usv } from "node:util"; + +const calls = []; +const exotic = { [Symbol.toPrimitive](hint) { calls.push(hint); return "\ud800"; } }; +console.log("exotic", JSON.stringify(usv(exotic)), calls.join(",")); +const ordinary = { toString() { calls.push("toString"); return {}; }, valueOf() { calls.push("valueOf"); return "\udfff"; } }; +console.log("ordinary", JSON.stringify(usv(ordinary)), calls.join(",")); +const sentinel = new Error("conversion failed"); +try { usv({ toString() { throw sentinel; } }); } catch (error) { console.log("throw-identity", error === sentinel); } +try { usv({ [Symbol.toPrimitive]() { throw sentinel; } }); } catch (error) { console.log("exotic-throw-identity", error === sentinel); } +try { usv({ [Symbol.toPrimitive]() { return Symbol("x"); } }); } catch (error) { console.log("symbol-result", error.name, error.message); } +for (const method of [1, -0, NaN, Infinity, true, 2n, Symbol("x"), "bad", "a\nb", '"quoted"', {}, null, undefined]) { + try { console.log("exotic-method", usv({ [Symbol.toPrimitive]: method })); } + catch (error) { console.log("exotic-method", error.name, error.message); } +} +try { usv({ [Symbol.toPrimitive]() { return {}; } }); } catch (error) { console.log("exotic-object", error.name, error.message); } +try { usv({ toString() { return {}; }, valueOf() { return {}; } }); } catch (error) { console.log("non-primitive", error.name, error.message); } +const child = Object.create({ [Symbol.toPrimitive](hint) { return this.label + hint; } }); +child.label = "inherited:"; +console.log("inherited", usv(child)); +const getter = {}; +Object.defineProperty(getter, Symbol.toPrimitive, { get() { calls.push("get"); return function (hint) { return hint; }; } }); +console.log("getter", usv(getter), calls.join(",")); diff --git a/tests/corpus/node24-text-utilities-validation.js b/tests/corpus/node24-text-utilities-validation.js new file mode 100644 index 000000000..c50d0ac47 --- /dev/null +++ b/tests/corpus/node24-text-utilities-validation.js @@ -0,0 +1,25 @@ +import { stripVTControlCharacters as strip, toUSVString as usv } from "node:util"; +const stripStored = strip; +const usvStored = usv; + +for (const value of [undefined, null, 0, -0, 1.5, NaN, Infinity, true, false, 2n, Symbol("x"), {}, [], [1, 2]]) { + try { console.log("strip", strip(value)); } + catch (error) { console.log(error.name, error.code, error.message); } + try { console.log("usv", usv(value)); } + catch (error) { console.log(error.name, error.code, error.message); } + try { console.log("stored-strip", stripStored(value)); } + catch (error) { console.log(error.name, error.code, error.message); } + try { console.log("stored-usv", usvStored(value)); } + catch (error) { console.log(error.name, error.code, error.message); } +} +try { strip(); } catch (error) { console.log("omitted", error.name, error.code, error.message); } +console.log("omitted-usv", usv()); +console.log("identity", strip === stripStored, usv === usvStored); +try { stripStored(); } catch (error) { console.log("omitted-stored", error.name, error.code, error.message); } +console.log("omitted-stored-usv", usvStored()); +let order = ""; +function extra() { order += "extra"; return null; } +console.log("extras", strip("\u001b[1mx", extra()), order); +console.log("extras", usv("\ud800", extra()), order); +try { strip(12, extra()); } catch (error) { console.log("validation-after-arguments", order, error.code); } +try { usv(Symbol("x"), extra()); } catch (error) { console.log("conversion-after-arguments", order, error.name, error.message); } diff --git a/tests/corpus/node24-text-utilities.cjs b/tests/corpus/node24-text-utilities.cjs new file mode 100644 index 000000000..ac496831c --- /dev/null +++ b/tests/corpus/node24-text-utilities.cjs @@ -0,0 +1,7 @@ +const { stripVTControlCharacters: strip, toUSVString: usv } = require("node:util"); +const util = require("util"); +const stripValue = util.stripVTControlCharacters; +const usvValue = util.toUSVString; +console.log(strip("\u001b[1mhello\u001b[0m"), stripValue("\u001b]0;title\u001b\\hello")); +console.log(usv("a\ud800b"), usvValue("😀")); +console.log(strip === stripValue, usv === usvValue); diff --git a/tests/corpus/node24-text-utilities.ts b/tests/corpus/node24-text-utilities.ts new file mode 100644 index 000000000..355e48925 --- /dev/null +++ b/tests/corpus/node24-text-utilities.ts @@ -0,0 +1,23 @@ +import { stripVTControlCharacters as strip, toUSVString as usv } from "node:util"; +import * as util from "util"; + +const stripValue = util.stripVTControlCharacters; +const usvValue = util.toUSVString; +for (const text of ["", "plain café 😀", "line\n\tend\r", "\u001b[31mred\u001b[0m", "\u009b1;32mgreen\u009b0m", "\u001b[2J\u001b[Hclear", "\u001b]0;window title\u0007tail", "\u001b]8;;https://example.com\u001b\\link\u001b]8;;\u001b\\", "\u001b]0;title\u009ctail", "\u001b[?25lhidden\u001b[?25h", "\u001b[38;2;1;2;3mRGB\u001b[0m", "\u001b7saved\u001b8", "\u001b[", "\u001b]unterminated", "\u001b\\", "embedded\u0000\u001b[1mNUL\u001b[0m"] ) { + console.log(JSON.stringify(strip(text)), JSON.stringify(stripValue(text))); +} +for (const text of ["", "café 😀", "\ud800", "\udfff", "a\ud800z", "\ud800\ud800\udc00\udc00", "\ud83d\ude00", "\udc00\ud800", "\udbff\udfff"] ) { + console.log(JSON.stringify(usv(text)), JSON.stringify(usvValue(text))); +} +// Repeated calls keep the shared regex bytecode's state independent. +console.log(strip("\u001b[31mone"), strip("\u001b[32mtwo"), strip("three")); +let digest = 0; +for (const intro of ["\u001b", "\u009b"]) { + for (const body of ["", "[", "]", "[31", "[1;2", "]0;title", "]8;;https://x", ";x/y#&.:=?%@~_", "[?25", "[12345", "[;;", "(B", "abc;xyz", "abc;", "[]#;?"]) { + for (let code = 0; code < 160; code++) { + const result = strip("前" + intro + body + String.fromCharCode(code) + "後"); + for (let i = 0; i < result.length; i++) digest = (digest * 31 + result.charCodeAt(i)) >>> 0; + } + } +} +console.log("control-matrix", digest); diff --git a/tests/fixtures/node-types/text-utilities.ts b/tests/fixtures/node-types/text-utilities.ts new file mode 100644 index 000000000..24de28b02 --- /dev/null +++ b/tests/fixtures/node-types/text-utilities.ts @@ -0,0 +1,6 @@ +import { stripVTControlCharacters, toUSVString } from "node:util"; + +const strip: (value: string) => string = stripVTControlCharacters; +const usv: (value: string) => string = toUSVString; +console.log(strip("\u001b[32mtyped\u001b[0m")); +console.log(JSON.stringify(usv("\ud800😀\udfff"))); diff --git a/tests/fixtures/npm/cases/node24-text-utilities/main.ts b/tests/fixtures/npm/cases/node24-text-utilities/main.ts new file mode 100644 index 000000000..76e884503 --- /dev/null +++ b/tests/fixtures/npm/cases/node24-text-utilities/main.ts @@ -0,0 +1,4 @@ +// @dynamic +// @stderr +import { reportTextUtilities } from "node24-text-utility-fixture"; +reportTextUtilities(); diff --git a/tests/fixtures/npm/cases/node24-text-utilities/package.json b/tests/fixtures/npm/cases/node24-text-utilities/package.json new file mode 100644 index 000000000..ca208a83a --- /dev/null +++ b/tests/fixtures/npm/cases/node24-text-utilities/package.json @@ -0,0 +1 @@ +{"private":true,"type":"module","dependencies":{"node24-text-utility-fixture":"1.0.0"}} diff --git a/tests/fixtures/npm/node_modules/node24-text-utility-fixture/index.d.ts b/tests/fixtures/npm/node_modules/node24-text-utility-fixture/index.d.ts new file mode 100644 index 000000000..5282901ad --- /dev/null +++ b/tests/fixtures/npm/node_modules/node24-text-utility-fixture/index.d.ts @@ -0,0 +1 @@ +export function reportTextUtilities(): void; diff --git a/tests/fixtures/npm/node_modules/node24-text-utility-fixture/index.js b/tests/fixtures/npm/node_modules/node24-text-utility-fixture/index.js new file mode 100644 index 000000000..1432f5eb4 --- /dev/null +++ b/tests/fixtures/npm/node_modules/node24-text-utility-fixture/index.js @@ -0,0 +1,42 @@ +import { stripVTControlCharacters as strip, toUSVString as usv } from "node:util"; +import * as util from "util"; + +export function reportTextUtilities() { + console.log("identity", strip === util.stripVTControlCharacters, usv === util.toUSVString); + for (const text of ["", "plain café 😀", "\u001b[31mred\u001b[0m", "\u009b1;32mgreen\u009b0m", "\u001b]0;title\u0007tail", "\u001b]8;;https://example.com\u001b\\link\u001b]8;;\u001b\\", "\u001b]0;title\u009ctail", "\u001b[?25lhide\u001b[?25h", "\u001b[", "\u001b]unterminated", "\u001b\\", "embedded\u0000\u001b[1mNUL"]) console.log("strip", JSON.stringify(strip(text))); + for (const text of ["", "café 😀", "\ud800", "\udfff", "a\ud800z", "\ud800\ud800\udc00\udc00", "\ud83d\ude00", "\udc00\ud800", "\udbff\udfff"]) console.log("usv", JSON.stringify(usv(text))); + for (const value of [undefined, null, 0, -0, 1.5, NaN, Infinity, true, false, 2n, Symbol("x"), {}, [], [1, 2], new String("boxed"), new Number(2), function sample() {}]) { + try { console.log("strip-value", strip(value)); } + catch (error) { console.log(error.name, error.code, error.message); } + try { console.log("usv-value", usv(value)); } + catch (error) { console.log(error.name, error.code, error.message); } + } + const calls = []; + const exotic = { [Symbol.toPrimitive](hint) { calls.push(hint); return "\ud800"; } }; + console.log("exotic", JSON.stringify(usv(exotic)), calls.join(",")); + const ordinary = { toString() { calls.push("toString"); return {}; }, valueOf() { calls.push("valueOf"); return "\udfff"; } }; + console.log("ordinary", JSON.stringify(usv(ordinary)), calls.join(",")); + const sentinel = new Error("conversion failed"); + try { usv({ toString() { throw sentinel; } }); } catch (error) { console.log("throw-identity", error === sentinel); } + try { usv({ [Symbol.toPrimitive]() { throw sentinel; } }); } catch (error) { console.log("exotic-throw-identity", error === sentinel); } + try { usv({ [Symbol.toPrimitive]() { return Symbol("x"); } }); } catch (error) { console.log("symbol-result", error.name, error.message); } + for (const method of [1, -0, NaN, Infinity, true, 2n, Symbol("x"), "bad", "a\nb", '"quoted"', {}, null, undefined]) { + try { console.log("exotic-method", usv({ [Symbol.toPrimitive]: method })); } + catch (error) { console.log("exotic-method", error.name, error.message); } + } + try { usv({ toString() { return {}; }, valueOf() { return {}; } }); } catch (error) { console.log("non-primitive", error.name, error.message); } + try { usv({ [Symbol.toPrimitive]() { return {}; } }); } catch (error) { console.log("exotic-object", error.name, error.message); } + try { strip(); } catch (error) { console.log("omitted", error.name, error.code, error.message); } + console.log("omitted-usv", usv()); + // Exercise every control terminator and parameter class in the pinned matcher. + let digest = 0; + const bodies = ["", "[", "]", "[31", "[1;2", "]0;title", "]8;;https://x", ";x/y#&.:=?%@~_", "[?25", "[12345", "[;;", "(B", "abc;xyz", "abc;", "[]#;?"]; + for (const intro of ["\u001b", "\u009b"]) for (const body of bodies) { + for (let code = 0; code < 160; code++) { + const text = "前" + intro + body + String.fromCharCode(code) + "後"; + const result = strip(text); + for (let i = 0; i < result.length; i++) digest = (digest * 31 + result.charCodeAt(i)) >>> 0; + } + } + console.log("control-matrix", digest); +} diff --git a/tests/fixtures/npm/node_modules/node24-text-utility-fixture/package.json b/tests/fixtures/npm/node_modules/node24-text-utility-fixture/package.json new file mode 100644 index 000000000..ed8f8ed8a --- /dev/null +++ b/tests/fixtures/npm/node_modules/node24-text-utility-fixture/package.json @@ -0,0 +1 @@ +{"name":"node24-text-utility-fixture","version":"1.0.0","type":"module","exports":"./index.js","types":"./index.d.ts"} diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index dcf2cfff0..20e3c2962 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -250,6 +250,17 @@ test("node-types: URL factories match Node under @types/node", async () => { expect(native.stderr).toBe(node.stderr); }); +test("node-types: text utilities match Node under @types/node", async () => { + const outDir = outDirFor("node-text-utilities"); + const entry = join(nodeTypesDir, "text-utilities.ts"); + const result = await compile(entry, { outPath: join(outDir, "text-utilities"), outDir, sanitize }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([execFileAsync(result.binaryPath), execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry])]); + expect(native.stdout).toBe(node.stdout); + expect(native.stderr).toBe(node.stderr); +}); + test("node-types: system error utilities match Node under @types/node", async () => { const outDir = outDirFor("node-system-errors"); const entry = join(nodeTypesDir, "system-errors.ts");