Evaluate an fs argument inside satisfies once after a URL path

The arguments after a URL path that are evaluated ahead of the
conversion were keyed by the expression without its parentheses. When
the fs lowering read through a satisfies wrapper, it lowered the inner
expression again, so fs.chmodSync(url, mode() satisfies number) called
mode() twice. The saved arguments are now keyed and looked up without
their type-only wrappers (satisfies, as, type assertions, !), so each
runs once.
This commit is contained in:
Chris Tate
2026-10-21 19:17:07 -05:00
parent 644f423734
commit 220199dfec
3 changed files with 18 additions and 6 deletions
@@ -15,6 +15,7 @@ import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { locOf } from "../../program.js";
import { sideEffectFreeOptionValue } from "../surfaces.js";
import { stripTypeWrappers } from "../typed-arguments.js";
import { type IrExpr, type IrStmt, STRING, URL_T } from "../../../ir/ir.js";
/** The fs functions whose path parameters accept a URL: the path
@@ -142,8 +143,9 @@ function checkUrlPaths(
for (let i = first + 1; i < args.length; i++) if (!sideEffectFreeOptionValue(args[i]!)) last = i;
const staged: ts.Expression[] = [];
for (let i = first + 1; i <= last; i++) {
let e = args[i]!;
while (ts.isParenthesizedExpression(e)) e = e.expression;
// Keyed without type-only wrappers (`satisfies`, `as`, `!`), so a
// lowering that reads through them finds the saved value.
const e = stripTypeWrappers(args[i]!);
// Literals are read by their members, so a whole-value slot cannot
// hold them.
if (ts.isObjectLiteralExpression(e) || ts.isArrayLiteralExpression(e)) {
@@ -1,4 +1,4 @@
import { onlyForwarded } from "./typed-arguments.js";
import { onlyForwarded, stripTypeWrappers } from "./typed-arguments.js";
import {
needsGeneralNodeStreams,
noteUntypedStreamFlow,
@@ -12046,11 +12046,11 @@ export class Lowerer {
return this.stagedArgument(expr) ?? lowerExpr(this, expr);
}
/** The saved value of a staged fs argument (stagedArguments), or null for
* every other expression. */
/** The saved value of a staged fs argument (stagedArguments, keyed
* without type-only wrappers), or null for every other expression. */
private stagedArgument(node: ts.Expression): IrExpr | null {
if (this.stagedArguments.size === 0) return null;
while (ts.isParenthesizedExpression(node)) node = node.expression;
node = stripTypeWrappers(node);
if (!this.stagedArguments.has(node)) return null;
const slot = this.stagedArguments.get(node);
if (!slot)
+10
View File
@@ -103,6 +103,16 @@ console.log("uid", uid);
uid = -1;
attempt("chown order scheme", () => fs.chownSync(new URL("http://a/"), uid, bumpUid()));
console.log("uid", uid);
// A later argument with effects inside a type-only wrapper runs once.
let modeCalls = 0;
function countedMode(): number {
modeCalls++;
return 0o600;
}
attempt("satisfies", () => fs.chmodSync(file, countedMode() satisfies number));
attempt("as", () => fs.chmodSync(file, countedMode() as number));
attempt("non-null", () => fs.chmodSync(file, countedMode()!));
console.log("mode calls", modeCalls);
fs.rmSync(dir, { recursive: true, force: true });
console.log("done", fs.existsSync(dir));