Store tty.isatty and process.cwd as function values (#2494)

This commit is contained in:
Chris Tate
2026-10-28 22:46:19 -05:00
committed by GitHub
27 changed files with 285 additions and 35 deletions
@@ -381,6 +381,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"process.stdio": "scr_process_stdio",
"process.stderrWrite": "scr_process_stderr_write",
"process.isTTY": "scr_process_is_tty",
"process.isTTYValue": "scr_process_is_tty_value",
"date.now": "scr_date_now",
"date.newNow": "scr_date_now",
"date.newMs": "scr_date_new_ms",
@@ -119,6 +119,7 @@ const INERT_LIB_CALLS = new Set([
"process.platform",
"process.execPath",
"process.isTTY",
"process.isTTYValue",
"worker.data",
"worker.isMainThread",
"worker.parentPort",
@@ -740,6 +740,11 @@ export function lowerProcessProperty(
"process.env as a value of this type (read one variable: process.env.NAME or process.env[name])",
);
}
// process.cwd as a value: the process module's member (surfaces.ts).
if (member === "cwd") {
const value = lowerer.lowerBuiltinCallableValue({ module: "process", member }, loc);
if (value) return value;
}
if (member === "exit" || member === "cwd" || member === "getuid" || member === "kill") {
lowerer.unsupported("SC1090", expr, `process methods as values (call '${member}' directly)`);
}
@@ -24,7 +24,14 @@ import {
localPrivateBrandClass,
privateNameClass,
} from "../private-brands.js";
import { type Lowerer, PoisonError, jsFuncNameOf, neverTaintedJsType, own } from "../lowerer.js";
import {
type Lowerer,
PoisonError,
builtinValueNameOf,
jsFuncNameOf,
neverTaintedJsType,
own,
} from "../lowerer.js";
import {
ARRAY_METHODS,
MAP_METHODS,
@@ -1203,13 +1210,15 @@ export function lowerPropertyAccessExpr(
(id) => lowerer.unions.get(id),
)
) {
const fnName = jsFuncNameOf(expr.expression);
// A builtin function value whose length is not its adapter's
// parameter count (`Math.max.length` is 2).
const fnLength =
probed.kind === "closure"
? lowerer.builtinCallableValueLengths.get(probed.fnName)
: undefined;
// A function is named by its definition, through const aliases: a
// builtin module member whose Node name differs from the member
// (`process.cwd` is `wrappedCwd`) by the module table, anything else
// by the property or function it reads. A builtin function value
// keeps its length when that is not its adapter's parameter count
// (`Math.max.length` is 2).
const fnName =
builtinValueNameOf(expr.expression, lowerer) ?? jsFuncNameOf(expr.expression, lowerer);
const fnLength = lowerer.builtinFunctionIdentity(probed).fnLength;
const boxed: IrExpr = {
kind: "dynFrom",
value: probed,
@@ -4771,6 +4771,16 @@ function stdlibGlobalTokenDestructure(
) {
// A data member reads its value once, exactly like the spelled
// `process.<member>` read the destructuring performs.
} else if (
globalName === "process" &&
propName === "cwd" &&
(value = lowerer.lowerBuiltinCallableValue(
{ module: "process", member: propName },
locOf(el),
)) !== null
) {
// process.cwd reads the directory when it is called, so the detached
// function is the same one every other spelling reads.
} else {
lowerer.unsupported(
"SC1031",
@@ -2105,6 +2105,67 @@ function anyPiecedFuncType(lowerer: Lowerer, node: ts.Node, t: ts.Type): IrType
* their own name, anonymous function/arrow expressions their
* NamedEvaluation home (a variable initializer or property assignment).
* Null when nothing names the value (the box stays anonymous). */
/** The name Node gives the builtin module function `node` reads, through
* const aliases (`const get = process.cwd`), when the module table names its
* value differently from its member (BuiltinModuleFn.valueName); else null. */
export function builtinValueNameOf(node: ts.Node, lowerer: Lowerer): string | null {
let n: ts.Node = node;
const seen = new Set<ts.Symbol>();
for (;;) {
while (ts.isParenthesizedExpression(n)) n = n.expression;
if (!ts.isIdentifier(n)) break;
const imported = lowerer.builtinImportOf(n);
if (imported)
return builtinModuleFnOf(lowerer, imported.module, imported.member)?.valueName ?? null;
const symbol = lowerer.resolveValueSymbol(n);
const declaration = symbol?.valueDeclaration;
if (!symbol || seen.has(symbol) || !declaration) return null;
seen.add(symbol);
// A member destructured from a module or the process global
// (`const { cwd } = process`).
if (ts.isBindingElement(declaration)) {
const pattern = declaration.parent;
const holder = pattern?.parent;
const key = declaration.propertyName ?? declaration.name;
if (
declaration.dotDotDotToken ||
!pattern ||
!ts.isObjectBindingPattern(pattern) ||
!holder ||
!ts.isVariableDeclaration(holder) ||
!holder.initializer ||
(ts.getCombinedNodeFlags(holder) & ts.NodeFlags.Const) === 0 ||
!ts.isIdentifier(key)
)
return null;
return builtinMemberValueName(lowerer, holder.initializer, key.text);
}
if (
!ts.isVariableDeclaration(declaration) ||
!declaration.initializer ||
(ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0
)
return null;
n = declaration.initializer;
}
if (!ts.isPropertyAccessExpression(n)) return null;
return builtinMemberValueName(lowerer, n.expression, n.name.text);
}
/** BuiltinModuleFn.valueName of `member` read from `receiver`, the process
* global or a builtin module's namespace; null for anything else. */
function builtinMemberValueName(
lowerer: Lowerer,
receiver: ts.Expression,
member: string,
): string | null {
const module = lowerer.isStdlibGlobal(receiver, "process")
? "process"
: lowerer.builtinNamespaceModuleOf(receiver);
if (module === null) return null;
return builtinModuleFnOf(lowerer, module, member)?.valueName ?? null;
}
export function jsFuncNameOf(node: ts.Node, lowerer?: Lowerer): string | null {
let n: ts.Node = node;
while (ts.isParenthesizedExpression(n)) n = n.expression;
@@ -2132,14 +2193,18 @@ export function jsFuncNameOf(node: ts.Node, lowerer?: Lowerer): string | null {
if (ts.isPropertyAccessExpression(n)) return n.name.text;
if ((ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n)) && n.name) return n.name.text;
if (ts.isFunctionExpression(n) || ts.isArrowFunction(n)) {
const p = n.parent;
if (p && ts.isVariableDeclaration(p) && p.initializer === n && ts.isIdentifier(p.name)) {
// An anonymous function takes the name of the binding or property it
// initializes, through parentheses (`const f = (function () {})`).
let outer: ts.Node = n;
while (outer.parent && ts.isParenthesizedExpression(outer.parent)) outer = outer.parent;
const p = outer.parent;
if (p && ts.isVariableDeclaration(p) && p.initializer === outer && ts.isIdentifier(p.name)) {
return p.name.text;
}
if (p && ts.isBindingElement(p) && p.initializer === n && ts.isIdentifier(p.name)) {
if (p && ts.isBindingElement(p) && p.initializer === outer && ts.isIdentifier(p.name)) {
return p.name.text;
}
if (p && ts.isPropertyAssignment(p) && p.initializer === n && ts.isIdentifier(p.name)) {
if (p && ts.isPropertyAssignment(p) && p.initializer === outer && ts.isIdentifier(p.name)) {
return p.name.text;
}
}
@@ -12351,20 +12416,13 @@ export class Lowerer {
liveReference(expr.type) ||
(expr.type.kind === "union" &&
(this.unions.get(expr.type.unionId)?.arms.some(liveReference) ?? false));
// A builtin function's adapter boxes under the builtin's name, the
// name Node's function carries (`[Function: randomUUID]`).
const builtinName =
expr.kind === "closure" ? this.builtinCallableValueNames.get(expr.fnName) : undefined;
const builtinLength =
expr.kind === "closure" ? this.builtinCallableValueLengths.get(expr.fnName) : undefined;
return {
kind: "dynFrom",
value: expr,
type: DYN,
loc: expr.loc,
...(liveRef ? { liveRef: true as const } : {}),
...(builtinName !== undefined ? { fnName: builtinName } : {}),
...(builtinLength !== undefined ? { fnLength: builtinLength } : {}),
...this.builtinFunctionIdentity(expr),
};
}
return expr;
@@ -12512,7 +12570,13 @@ export class Lowerer {
) {
return {
kind: "dynCheck",
value: { kind: "dynFrom", value: expr, type: DYN, loc: expr.loc },
value: {
kind: "dynFrom",
value: expr,
type: DYN,
loc: expr.loc,
...this.builtinFunctionIdentity(expr),
},
type: expected,
loc: expr.loc,
};
@@ -16959,7 +17023,10 @@ export class Lowerer {
if (!fnName) {
fnName = keyedHelperName(this, `%builtin.value.${fn.valueFn ?? fn.fn}`, key);
this.builtinCallableValueFns.set(key, fnName);
this.builtinCallableValueNames.set(fnName, display.slice(display.lastIndexOf(".") + 1));
this.builtinCallableValueNames.set(
fnName,
fn.valueName ?? display.slice(display.lastIndexOf(".") + 1),
);
if (fn.valueLength !== undefined)
this.builtinCallableValueLengths.set(fnName, fn.valueLength);
const params = funcType.params.map((type, index) => ({
@@ -17051,6 +17118,19 @@ export class Lowerer {
return { kind: "closure", fnName, captures: [], type: funcType, loc };
}
/** The name and length a builtin function's adapter boxes under: the ones
* Node's function carries (`[Function: randomUUID]`). Empty for any other
* value. */
builtinFunctionIdentity(expr: IrExpr): { fnName?: string; fnLength?: number } {
if (expr.kind !== "closure") return {};
const name = this.builtinCallableValueNames.get(expr.fnName);
const length = this.builtinCallableValueLengths.get(expr.fnName);
return {
...(name !== undefined ? { fnName: name } : {}),
...(length !== undefined ? { fnLength: length } : {}),
};
}
isBuiltinCallableValue(expr: IrExpr): boolean {
return (
expr.kind === "closure" &&
@@ -823,6 +823,9 @@ export interface BuiltinModuleFn {
/** The function value's `length` when it is not the count of its
* required value parameters (`Math.max`'s 2 over its rest pack). */
valueLength?: number;
/** The function value's `name` when it is not the member's
* (`process.cwd` is Node's `wrappedCwd`). */
valueName?: string;
}
export type BuiltinValueParam =
@@ -1894,6 +1897,7 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable {
{ mode: "optional", type: DYN, defaultValue: undefined },
],
},
// util.debug is util.debuglog, under its name.
debug: {
fn: "util.debuglog",
params: [DYN, DYN],
@@ -1902,6 +1906,7 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable {
{ mode: "required", type: DYN },
{ mode: "optional", type: DYN, defaultValue: undefined },
],
valueName: "debuglog",
},
},
// The util/types spoke supplies the probe name to the shared brand check.
@@ -1954,10 +1959,29 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable {
transcode: { fn: "buffer.transcode", params: [DYN, DYN, DYN], result: BYTES_U8 },
},
tty: {
isatty: { fn: "process.isTTY", params: [F64], result: BOOL },
isatty: {
fn: "process.isTTY",
params: [F64],
result: BOOL,
// As a value (loggers store `tty.isatty`), the adapter takes any
// argument: a value that is not an integral fd answers false, and is
// never coerced.
valueFn: "process.isTTYValue",
valueParams: [{ mode: "optional", type: DYN, defaultValue: undefined }],
valueLength: 1,
},
},
process: {
loadEnvFile: { fn: "process.loadEnvFile", params: [DYN], result: VOID },
// Every spelling of process.cwd as a value is this one function, which
// reads the directory when it is called.
cwd: {
fn: "process.cwd",
params: [],
result: STRING,
valueParams: [],
valueName: "wrappedCwd",
},
},
// node:string_decoder's surface is the StringDecoder CLASS — new/write/
// end are special-cased (lowerNew + lowerStringDecoderMethodCall); no
@@ -2289,6 +2313,9 @@ export const BUILTIN_MODULE_FN_ALIASES: Record<
randomInt: ["crypto.randomIntMax", "crypto.randomIntCb", "crypto.randomIntPromise"],
randomUUID: ["crypto.randomUUIDOpts"],
},
tty: {
isatty: ["process.isTTYValue"],
},
os: {
// lowerOsUserInfoCall assembles the record from scalar libCalls.
userInfo: ["os.userHomedir", "os.userShell"],
@@ -2720,12 +2747,6 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] {
fns: ["process.release"],
note: "the compatibility target's release metadata as one stable read-only object",
},
{
id: "node-builtin.process.cwd",
kind: "node-builtin",
name: "process.cwd",
fns: ["process.cwd"],
},
{
id: "node-builtin.process.chdir",
kind: "node-builtin",
@@ -2860,7 +2881,7 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] {
id: "node-builtin.process.isTTY",
kind: "node-builtin",
name: "process.isTTY",
fns: ["process.isTTY"],
fns: ["process.isTTY", "process.isTTYValue"],
note: "the isTTY read on process.stdin/stdout/stderr — one surface across the three streams",
},
{
@@ -5376,6 +5376,7 @@ function buildLibFnSignatures() {
/** Validated isatty(3) probe. Stream property lowering maps false to
* undefined; tty.isatty exposes the boolean result directly. */
"process.isTTY": { argTypes: [F64], result: BOOL },
"process.isTTYValue": { argTypes: [DYN], result: BOOL },
// Like process.envGet: the result is the module's interned
// `number | undefined` union — checked by arms in the libCall case.
/** Terminal width over an fd literal (1/2 — process.stdout/stderr
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/function-name-const-alias.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/process-cwd-value.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/process-cwd-value.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/tty-isatty-value-typed.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/tty-isatty-value.mjs"
],
"diags": []
}
+6
View File
@@ -5126,6 +5126,12 @@ bool scr_process_is_tty(double fd) {
return isatty((int)fd) != 0;
}
/* tty.isatty as a function value (borrowed argument): a value that is not a
* number answers false and is never coerced, as Node's integer test does. */
bool scr_process_is_tty_value(const ScrDyn *fd) {
return fd != NULL && fd->kind == SCR_DYN_NUM && scr_process_is_tty(fd->v.num);
}
/* Terminal geometry for process.stdout/stderr.columns/rows: ioctl(TIOCGWINSZ) on
* the stream's fd, exactly Node's tty.WriteStream source of truth. A
* non-TTY stream, or a terminal that refuses the ioctl, answers -1 and
+1
View File
@@ -386,6 +386,7 @@ double scr_atomics_notify_dyn(const ScrDyn *, double, double);
* non-TTY streams expose undefined (SEMANTICS.md). stdin_destroy is a
* documented no-op. */
bool scr_process_is_tty(double fd);
bool scr_process_is_tty_value(const ScrDyn *fd);
void scr_process_stdin_destroy(void);
void scr_process_stdin_set_raw_mode(bool raw);
@@ -0,0 +1,20 @@
// The name of a function value read through a const binding: a builtin
// keeps Node's name (including when destructured), an anonymous function or
// arrow takes the binding's name through parentheses, and an alias of a
// named function keeps the function's name.
import * as path from "node:path";
import * as processModule from "node:process";
const { cwd } = process;
const { cwd: renamed } = processModule;
const join = path.join;
const parenthesized = (function () {});
const arrow = (() => 0);
const nested = ((function () {}));
function named() {}
const alias = named;
const aliasOfAlias = alias;
console.log(cwd.name, renamed.name, join.name);
console.log(parenthesized.name, arrow.name, nested.name);
console.log(alias.name, aliasOfAlias.name);
console.log(cwd === process.cwd, typeof cwd());
+7
View File
@@ -0,0 +1,7 @@
// process.cwd stored as a value from CommonJS: the same function as the
// process module's member, read when it is called.
const processModule = require("node:process");
const get = process.cwd;
console.log(get === processModule.cwd, get.name, get() === process.cwd());
const { cwd } = processModule;
console.log(cwd === get, typeof cwd());
+22
View File
@@ -0,0 +1,22 @@
// process.cwd stored as a value: one identity across the global, named,
// namespace and default forms, Node's name and length, and the directory
// read when it is called.
import { cwd } from "node:process";
import * as processModule from "node:process";
import processDefault from "node:process";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const get = process.cwd;
console.log(typeof get, get.name, get.length);
console.log(get === cwd, get === processModule.cwd, get === processDefault.cwd);
console.log(get() === process.cwd(), cwd() === process.cwd());
const start = get();
const dir = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-cwd-value-")));
process.chdir(dir);
console.log(get() === dir, cwd() === dir, [0].map(() => get())[0] === dir);
process.chdir(start);
console.log(get() === start);
rmSync(dir, { recursive: true });
+10
View File
@@ -0,0 +1,10 @@
// A typed alias of tty.isatty works as a callback with its declared
// signature.
import * as tty from "node:tty";
const isTerminal: (fd: number) => boolean = tty.isatty;
function describe(fds: number[], test: (fd: number) => boolean): string[] {
return fds.map((fd) => `${fd}:${test(fd)}`);
}
console.log(describe([-1, 0.5, 2 ** 40], isTerminal).join(" "));
console.log(isTerminal === tty.isatty, [-1].map(isTerminal));
+21
View File
@@ -0,0 +1,21 @@
// tty.isatty stored as a value keeps the direct predicate's answers, its
// identity across import forms, and its name and length. Values that are
// not integral file descriptors answer false without being coerced.
import * as tty from "node:tty";
import { isatty } from "node:tty";
import ttyDefault from "node:tty";
const probe = tty.isatty;
console.log(typeof probe, probe.name, probe.length);
console.log(probe === isatty, probe === ttyDefault.isatty);
console.log(probe(-1), probe(1.5), probe(2 ** 31), probe(Number.NaN));
console.log([-1, 0.5].map(probe));
// Called through an untyped callback, values that are not numbers answer
// false without being coerced.
function forward(predicate, value) {
return predicate(value);
}
let reads = 0;
const object = { valueOf() { reads++; return 1; } };
console.log(forward(probe, undefined), forward(probe, "1"), forward(probe, 1n), forward(probe, null), forward(probe, object), reads);
console.log(forward(isatty, -1), forward(tty.isatty, 2 ** 31));
+1 -1
View File
@@ -6,7 +6,7 @@
import { mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
const read = readFileSync; console.log(read);
const cwd = process.cwd;
const exit = process.exit;
const p = process;
const env = process.env;
const flo = Math.floor; // fixed-arity Math functions have native value forms
+4 -4
View File
@@ -3,13 +3,13 @@ stdlib.ts:8:40 - error SC1090: a builtin function alias as an escaping value (ca
7 | import { writeFile } from "node:fs/promises";
8 | const read = readFileSync; console.log(read);
| ^~~~
9 | const cwd = process.cwd;
9 | const exit = process.exit;
stdlib.ts:9:13 - error SC1090: process methods as values (call 'cwd' directly) are not supported yet
stdlib.ts:9:14 - error SC1090: process methods as values (call 'exit' directly) are not supported yet
8 | const read = readFileSync; console.log(read);
9 | const cwd = process.cwd;
| ^~~~~~~~~~~
9 | const exit = process.exit;
| ^~~~~~~~~~~~
10 | const p = process;
stdlib.ts:13:15 - error SC1090: string methods as values (call 'toUpperCase' directly) are not supported yet
@@ -0,0 +1 @@
{ "plain": 2000, "san": 3000 }
@@ -0,0 +1 @@
{ "plain": 2000, "san": 3000 }
@@ -0,0 +1 @@
{ "plain": 2000, "san": 3000 }
@@ -0,0 +1 @@
{ "plain": 2000, "san": 3000 }
@@ -0,0 +1 @@
{ "plain": 2000, "san": 3000 }