Keep held format arguments static and compiling

The runtime format branch packed its arguments with a checked-dynamic
array literal, a dynamic operation: a console.log with a string first
argument put its function in the dynamic tier, and the property-bag
analysis read the format calls as possible dynamic stores. The held
arguments now go to the formatter as a native list of checked values
(insp.formatList and insp.formatPrimitives), which is a tier boundary, and
the format and inspect entries join the helpers that never store into a
class instance.

A first argument whose type may hold a string only through a union, or
whose checker type does not map, packed every argument as a checked value
and failed to compile when one did not convert (7380's
`console.log(top ? [...top] : "none", byName)`). It now takes the
held-argument form: the lowered value is tested for a substitution and
otherwise joins as before. In TypeScript an argument without a
checked-dynamic form is refused at compile time only when the joined
form cannot print it either; otherwise the branch that substitutes
throws SC2020, as in JavaScript.
This commit is contained in:
Chris Tate
2026-10-22 21:24:51 -05:00
parent 0d016b7554
commit e48b2c3435
8 changed files with 81 additions and 25 deletions
@@ -1036,6 +1036,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"insp.dynS": "scr_insp_dyn_s",
"insp.format": "scr_insp_format_pack",
"insp.formatPrimitives": "scr_insp_format_primitives",
"insp.formatList": "scr_insp_format_list",
"insp.formatWithOptions": "scr_insp_format_with_options",
"insp.inspect": "scr_insp_inspect",
"insp.customEnabled": "scr_insp_custom_enabled",
@@ -21,6 +21,7 @@ import {
lowerInspectValue,
lowerRuntimeFormat,
runtimeFormatString,
formatStringConverts,
} from "./lower-inspect.js";
import { streamSidesOf } from "./lower-stream.js";
@@ -372,7 +373,7 @@ export function lowerConsoleOutput(
return consoleWrite(stdout, [lowerRuntimeFormat(lowerer, nodes, loc)], loc);
const runtime =
nodes.length > 1 && !isStringLiteralLike(first) ? runtimeFormatString(lowerer, first!) : null;
if (runtime === "other")
if (runtime === "other" && !formatStringConverts(lowerer, first!))
return consoleWrite(stdout, [lowerRuntimeFormat(lowerer, nodes, loc)], loc);
if (runtime !== null) {
const held = heldFormatArgs(lowerer, nodes, loc);
@@ -35,6 +35,7 @@ import {
canConvertToDyn,
canDynCheckTo,
funcOf,
arrayOf,
CLASS_FIELD_ATTRIBUTES_PREFIX,
classDynViewSupported,
DYN_CLASS_PROPERTIES,
@@ -3205,7 +3206,8 @@ export function lowerFormatCall(
if (!firstIsLiteral) {
const runtime = argNodes.length > 1 ? runtimeFormatString(lowerer, first) : null;
if (runtime === "other") return lowerRuntimeFormat(lowerer, argNodes, loc);
if (runtime === "other" && !formatStringConverts(lowerer, first))
return lowerRuntimeFormat(lowerer, argNodes, loc);
if (runtime !== null) {
// A string that substitutes formats at run time; any other string
// joins with the remaining arguments, as below.
@@ -3576,6 +3578,14 @@ function runtimeFenceValue(
};
}
/** Whether the held-argument form can test a format string whose type
* only may hold a string: a union that converts to a checked-dynamic value,
* or a value whose checker type does not map (its lowered type decides). */
export function formatStringConverts(lowerer: Lowerer, first: ts.Expression): boolean {
const t = lowerer.mapTypeOf(lowerer.typeOf(first));
return t === null || t.kind === "dyn" || lowerer.dynConvertible(t);
}
/** How a non-literal format string is known: "string" and "dyn" values
* test for a substitution at run time, "other" types (unions with a string
* arm) format at run time, and null types never hold a string. */
@@ -3673,14 +3683,10 @@ export function heldFormatArgs(
"util.format of a void call result (call it as its own statement, then pass undefined)",
);
}
if (value.type.kind !== "dyn" && !lowerer.dynConvertible(value.type)) {
// JavaScript keeps the space-joined branch, which printed these
// values before, and refuses only a string that substitutes.
if (!isJsSourceFile(ts.getSourceFileOfNode(node))) {
lowerer.noLowering(unconvertibleSurface(value.type), node, UNCONVERTIBLE_HINT);
}
// The space-joined branch prints these values as before; only a string
// that substitutes refuses them, where it runs.
if (value.type.kind !== "dyn" && !lowerer.dynConvertible(value.type))
unconvertible ??= { node, type: value.type };
}
// Unit values have no local: their effects run here and a literal
// stands in.
const k = value.type.kind;
@@ -3694,16 +3700,20 @@ export function heldFormatArgs(
return { node, ref: () => varRef(held.id, value.type, loc) };
});
const first = args[0]!.ref();
// A lowered value that is neither a string nor convertible holds no
// string, so it never substitutes.
const specifies: IrExpr =
first.type.kind === "string"
? { kind: "libCall", fn: "insp.formatSpecifies", args: [first], type: BOOL, loc }
: {
kind: "libCall",
fn: "insp.formatSpecifiesDyn",
args: [lowerer.coerceToExpected(first, DYN)],
type: BOOL,
loc,
};
: first.type.kind === "dyn" || lowerer.dynConvertible(first.type)
? {
kind: "libCall",
fn: "insp.formatSpecifiesDyn",
args: [lowerer.coerceToExpected(first, DYN)],
type: BOOL,
loc,
}
: { kind: "boolLit", value: false, type: BOOL, loc };
const format = (): IrExpr => {
if (unconvertible !== null) {
const fenced = unconvertible;
@@ -3725,14 +3735,18 @@ export function heldFormatArgs(
t.kind === "undefinedT" ||
t.kind === "nullT" ||
(t.kind === "union" && (lowerer.unions.get(t.unionId)?.arms.every(primitive) ?? false));
// A native list of the checked values: converting into it is a tier
// boundary, so the statement stays static.
return {
kind: "libCall",
fn: args.every((arg) => primitive(arg.ref().type)) ? "insp.formatPrimitives" : "insp.format",
fn: args.every((arg) => primitive(arg.ref().type))
? "insp.formatPrimitives"
: "insp.formatList",
args: [
{
kind: "dynArrLit",
kind: "arrayLit",
elems: args.map((arg) => lowerer.coerceToExpected(arg.ref(), DYN)),
type: DYN,
type: arrayOf(DYN),
loc,
},
],
@@ -3748,6 +3762,11 @@ export function heldFormatArgs(
if (t.kind === "bytes") return true;
return inspectSupport(lowerer, t, new Set(), { recursive: false }) === null;
});
// Without a joined branch every call formats: TypeScript refuses the
// value at compile time.
const refused = unconvertible as { node: ts.Expression; type: IrType } | null;
if (!joinable && refused !== null && !isJsSourceFile(ts.getSourceFileOfNode(refused.node)))
lowerer.noLowering(unconvertibleSurface(refused.type), refused.node, UNCONVERTIBLE_HINT);
return { stmts, args, specifies, format, joinable };
}
@@ -183,6 +183,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
// The runtime formatter calls toString, valueOf and toJSON, and refuses
// renderings it has no lowering for.
"insp.format",
"insp.formatList",
"insp.formatWithOptions",
"insp.inspect",
// The options object reads the caller's options (getters included), and
@@ -4376,7 +4376,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"insp.format": { argTypes: [DYN], result: STRING },
/** insp.format over arguments that are all strings, numbers, booleans,
* null or undefined, without the checked-dynamic walk. */
"insp.formatPrimitives": { argTypes: [DYN], result: STRING },
"insp.formatPrimitives": { argTypes: [arrayOf(DYN)], result: STRING },
"insp.formatList": { argTypes: [arrayOf(DYN)], result: STRING },
"insp.formatWithOptions": { argTypes: [DYN, DYN], result: STRING },
/** util.inspect(value, options, depth, colors) with the call's argument
* count: the runtime reads the options and renders the checked-dynamic
+12
View File
@@ -407,6 +407,18 @@ const DYN_READ_ONLY = new Set<string>([
"fs.utimesSync",
"insp.dyn",
"insp.dynS",
// util.format and util.inspect over checked values: the inspect walk and
// the format conversions read them; insp.registerCustom keeps a class's
// custom inspect thunk in the runtime's registry.
"insp.customIsSelf",
"insp.customResult",
"insp.format",
"insp.formatList",
"insp.formatPrimitives",
"insp.formatSpecifiesDyn",
"insp.formatWithOptions",
"insp.inspect",
"insp.registerCustom",
// The dynamic-tier operators run their dyn twins (scr_js_value.c); the
// conversions move a value between dyn and jsval, and js.toF64 is
// dynCheck's number check.
+20 -3
View File
@@ -2575,11 +2575,21 @@ static ScrStr *insp_prim_number(ScrDyn *v, int spec) {
static const InspFmtOps insp_fmt_primitive_ops = {insp_prim_s, insp_prim_j, insp_prim_number,
insp_prim_inspect};
ScrStr *scr_insp_format_primitives(ScrDyn *pack) {
/* A native list of checked values (the compiled format calls) as the
* runtime's argument pack. +1. */
static ScrDyn *insp_list_pack(ScrArr *list) {
ScrDyn *pack = scr_dyn_new_arr();
for (uint32_t i = 0; i < list->len; i++) scr_dyn_arr_push(pack, scr_arr_get_ref(list, i));
return pack;
}
ScrStr *scr_insp_format_primitives(ScrArr *list) {
ScrInspFormatOptions o;
scr_insp_format_defaults(&o, "util.format");
if (pack->kind != SCR_DYN_ARR) return scr_str_new("", 0);
return insp_format_loop(pack->v.arr.items, pack->v.arr.len, &o, &insp_fmt_primitive_ops);
ScrDyn *pack = insp_list_pack(list);
ScrStr *out = insp_format_loop(pack->v.arr.items, pack->v.arr.len, &o, &insp_fmt_primitive_ops);
scr_dyn_release(pack);
return out;
}
/* The arguments of a dyn array pack (+1 text, or NULL pending). */
@@ -2594,6 +2604,13 @@ ScrStr *scr_insp_format_pack(ScrDyn *pack) {
return insp_fmt_pack(pack, &o);
}
ScrStr *scr_insp_format_list(ScrArr *list) {
ScrDyn *pack = insp_list_pack(list);
ScrStr *out = scr_insp_format_pack(pack);
scr_dyn_release(pack);
return out;
}
/* Whether a format string can substitute: a '%' before its last character.
* A string without one formats as its arguments joined with spaces. */
bool scr_insp_format_specifies(ScrStr *first) {
+6 -2
View File
@@ -76,9 +76,13 @@ ScrStr *scr_insp_format(ScrDyn *const *args, size_t argc, const ScrInspFormatOpt
/* util.format(...pack) and util.formatWithOptions(options, ...pack) over a
* dyn array of the arguments (+1). */
ScrStr *scr_insp_format_pack(ScrDyn *pack);
/* util.format(...pack) when every argument is a string, number, boolean,
/* util.format over a native list of checked values, the compiled calls'
* held arguments (+1). */
struct ScrArr;
ScrStr *scr_insp_format_list(struct ScrArr *list);
/* scr_insp_format_list when every argument is a string, number, boolean,
* null or undefined, without the checked-dynamic walk (+1). */
ScrStr *scr_insp_format_primitives(ScrDyn *pack);
ScrStr *scr_insp_format_primitives(struct ScrArr *list);
ScrStr *scr_insp_format_with_options(ScrDyn *options, ScrDyn *pack);
/* util.inspect(value, options, depth, colors) over checked-dynamic
* arguments with Node's legacy-argument rules; argc is the call's