Support Node 24 text utilities (#647)

This commit is contained in:
Chris Tate
2026-10-03 19:21:34 -05:00
committed by GitHub
parent b8344a6cb5
commit eb83febcf2
33 changed files with 461 additions and 66 deletions
@@ -2,6 +2,6 @@
"schemaVersion": 3,
"nodeVersion": "24.15.0",
"nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119",
"artifactVersion": "b22c67d8306e45a80390",
"artifactVersion": "5c443421620cc3431067",
"rowCount": 3662
}
+14 -14
View File
@@ -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"
}
},
@@ -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"] },
@@ -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"
]
}
}
@@ -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"
+8
View File
@@ -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.";
@@ -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"] },
+2
View File
@@ -1887,6 +1887,8 @@ declare module "util" {
export function getSystemErrorName(err: number): string;
export function getSystemErrorMessage(err: number): string;
export function getSystemErrorMap(): Map<number, [string, string]>;
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;
@@ -60,6 +60,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"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",
@@ -28,6 +28,9 @@ export function builtinPrototypeMethod(lowerer: Lowerer, expr: ts.PropertyAccess
export function isNativeBuiltinValueInitializer(lowerer: Lowerer, expr: ts.Expression | undefined, seen = new Set<ts.Symbol>()): 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.
@@ -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;
@@ -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 };
@@ -888,6 +888,8 @@ export const BUILTIN_MODULE_FNS: Record<string, Record<string, BuiltinModuleFn |
getSystemErrorName: { fn: "util.getSystemErrorName", params: [DYN], result: STRING, valueParams: exactValueParams(DYN) },
getSystemErrorMessage: { fn: "util.getSystemErrorMessage", params: [DYN], result: STRING, valueParams: exactValueParams(DYN) },
getSystemErrorMap: { fn: "util.systemErrorEntries", params: [], result: DYN },
stripVTControlCharacters: { fn: "util.stripVTControlCharacters", params: [DYN], result: STRING, valueParams: exactValueParams(DYN) },
toUSVString: { fn: "util.toUSVString", params: [DYN], result: STRING, valueParams: exactValueParams(DYN) },
},
buffer: {
isAscii: { fn: "buffer.isAscii", params: [DYN], result: BOOL },
+5 -1
View File
@@ -2577,6 +2577,8 @@ export type IrLibFn =
| "util.getSystemErrorName"
| "util.getSystemErrorMessage"
| "util.systemErrorEntries"
| "util.stripVTControlCharacters"
| "util.toUSVString"
/** ES Symbol values (scr_symbol.c — link-gated by moduleUsesSymbol).
* sym.new: `Symbol(desc)` — a fresh runtime-unique identity (+1) whose
* one arg is the description string (borrowed); sym.newAnon is the
@@ -6809,7 +6811,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run
const expr = (node: IrExpr): boolean => {
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<IrLibFn> = 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",
+2
View File
@@ -435,6 +435,8 @@ export const LIB_FN_SIGS: Record<IrLibFn, { argTypes: (IrType | null)[]; result:
"util.getSystemErrorName": { argTypes: [DYN], result: STRING },
"util.getSystemErrorMessage": { argTypes: [DYN], result: STRING },
"util.systemErrorEntries": { argTypes: [], result: DYN },
"util.stripVTControlCharacters": { argTypes: [DYN], result: STRING },
"util.toUSVString": { argTypes: [DYN], result: STRING },
"fs.statSync": { argTypes: [STRING], result: STATS_T },
"fs.lstatSync": { argTypes: [STRING], result: STATS_T },
"fs.fstatSync": { argTypes: [F64], result: STATS_T },
+12
View File
@@ -1989,6 +1989,18 @@
"code": "SC2020",
"note": "the lowered shapes are const bindings over child_process.execFile and fs.readFile; call the bound function directly"
},
{
"id": "node-builtin.util.stripVTControlCharacters",
"kind": "node-builtin",
"name": "util.stripVTControlCharacters",
"status": "static"
},
{
"id": "node-builtin.util.toUSVString",
"kind": "node-builtin",
"name": "util.toUSVString",
"status": "static"
},
{
"id": "node-builtin.util.types",
"kind": "node-builtin",
@@ -13540,6 +13540,42 @@
"<repo>/tests/fixtures/node-types/system-errors.ts"
],
"diags": []
},
"<repo>/tests/corpus/node24-text-utilities.ts": {
"order": [
"<repo>/tests/corpus/node24-text-utilities.ts"
],
"diags": []
},
"<repo>/tests/corpus/node24-text-utilities.cjs": {
"order": [
"<repo>/tests/corpus/node24-text-utilities.cjs"
],
"diags": []
},
"<repo>/tests/corpus/node24-text-utilities-validation.js": {
"order": [
"<repo>/tests/corpus/node24-text-utilities-validation.js"
],
"diags": []
},
"<repo>/tests/fixtures/npm/cases/node24-text-utilities/main.ts": {
"order": [
"<repo>/tests/fixtures/npm/cases/node24-text-utilities/main.ts"
],
"diags": []
},
"<repo>/tests/fixtures/node-types/text-utilities.ts": {
"order": [
"<repo>/tests/fixtures/node-types/text-utilities.ts"
],
"diags": []
},
"<repo>/tests/corpus/node24-text-utilities-coercion.js": {
"order": [
"<repo>/tests/corpus/node24-text-utilities-coercion.js"
],
"diags": []
}
}
}
+29 -7
View File
@@ -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])|(?<![\\uD800-\\uDBFF])[\\uDC00-\\uDFFF]/g, \"�\");\n"
" const toUSVString = (s) => {\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])|(?<![\\uD800-\\uDBFF])[\\uDC00-\\uDFFF]/g, \"�\");\n"
" };\n"
" const _extend = (target, source) => {\n"
" if (source === null || typeof source !== \"object\") return target;\n"
" for (const k of Object.keys(source)) target[k] = source[k];\n"
+74 -1
View File
@@ -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
+54
View File
@@ -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 <sindresorhus@gmail.com>, 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
+5
View File
@@ -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
+5
View File
@@ -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;
}
@@ -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(","));
@@ -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); }
+7
View File
@@ -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);
+23
View File
@@ -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);
+6
View File
@@ -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")));
@@ -0,0 +1,4 @@
// @dynamic
// @stderr
import { reportTextUtilities } from "node24-text-utility-fixture";
reportTextUtilities();
@@ -0,0 +1 @@
{"private":true,"type":"module","dependencies":{"node24-text-utility-fixture":"1.0.0"}}
@@ -0,0 +1 @@
export function reportTextUtilities(): void;
+42
View File
@@ -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);
}
@@ -0,0 +1 @@
{"name":"node24-text-utility-fixture","version":"1.0.0","type":"module","exports":"./index.js","types":"./index.d.ts"}
+11
View File
@@ -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");