Address review: missing strings in bindings and calls, evaluated recursive flags

- A string held in a binding or returned by a call can be a missing element
  read (undefined), so `head + PAD[j]` and `at(i) + PAD[j]` still add to NaN
  when both are missing. Only string literals, templates and sums with such
  an operand now count as always a string.
- `readdirSync(path, { recursive })` folds only the `true` and `false`
  keywords. Any other flag expression, including one typed as a literal,
  evaluates at run time for its effects and exceptions.
This commit is contained in:
Chris Tate
2026-10-26 02:42:04 -05:00
parent 055d1f9682
commit 7183a76422
4 changed files with 53 additions and 8 deletions
@@ -1351,9 +1351,16 @@ export function lowerFsReaddirTypesCall(
}
}
if (recursive !== null) {
const flag = lowerer.typeOf(recursive);
// Only the `true`/`false` keywords fold; any other expression, even one
// typed as a literal, evaluates at run time for its effects.
let flag = recursive;
while (ts.isParenthesizedExpression(flag)) flag = flag.expression;
const literal =
flag.flags & ts.TypeFlags.BooleanLiteral ? lowerer.checker.typeToString(flag) : null;
flag.kind === ts.SyntaxKind.TrueKeyword
? "true"
: flag.kind === ts.SyntaxKind.FalseKeyword
? "false"
: null;
if (sawWithFileTypes && literal !== "false") {
lowerer.noLowering(
`${operation} with withFileTypes and recursive`,
@@ -6011,12 +6011,19 @@ export class Lowerer {
if (!primitive(e.left) || !primitive(e.right)) return null;
// A missing read is undefined, and `+` yields a number only when
// neither operand is a string: an operand that is always a string
// (`"x-" + PAD[i]`) makes the result a string. A read, or a sum or
// call whose result may itself be NaN, is not always a string.
const definitelyString = (part: ts.Expression): boolean =>
!stringRead(part) &&
this.mapTypeOf(this.typeOf(part))?.kind === "string" &&
optionalPrimitiveResultType(part) === null;
// (`"x-" + PAD[i]`) makes the result a string. Only literals,
// templates and sums with such an operand are always strings; a
// binding or call can hold a missing read.
const definitelyString = (part: ts.Expression): boolean => {
const p = peel(part);
if (ts.isStringLiteral(p) || ts.isNoSubstitutionTemplateLiteral(p)) return true;
if (ts.isTemplateExpression(p)) return true;
return (
ts.isBinaryExpression(p) &&
p.operatorToken.kind === ts.SyntaxKind.PlusToken &&
(definitelyString(p.left) || definitelyString(p.right))
);
};
if (definitelyString(e.left) || definitelyString(e.right)) return null;
return { kind: "union", unionId: this.unions.intern([F64, STRING]) };
};
+16
View File
@@ -27,6 +27,22 @@ for (const flag of [false, true]) {
}
console.log(readdirSync(join(root, "empty"), { recursive: true }).length);
// A flag expression typed `true` or `false` still evaluates, once.
let probes = 0;
function yes(): true {
probes++;
return true;
}
function no(): false {
probes++;
return false;
}
console.log(
readdirSync(join(root, "b"), { recursive: yes() }).length,
readdirSync(join(root, "b"), { recursive: no() }).length,
probes,
);
try {
readdirSync(join(root, "missing"), { recursive: true });
} catch (e) {
@@ -25,3 +25,18 @@ function pair(i: number, j: number): string {
}
const both = pair(8, 9);
console.log(pair(0, 1), typeof both, String(both));
// A missing element held in a binding or returned by a function is undefined,
// so two of them still add to NaN.
function at(i: number): string {
return PAD[i];
}
function viaBinding(i: number, j: number): string {
const head = PAD[i];
return head + PAD[j];
}
function viaCall(i: number, j: number): string {
return at(i) + PAD[j];
}
console.log(viaBinding(0, 1), String(viaBinding(5, 6)), typeof viaBinding(5, 6));
console.log(viaCall(0, 2), String(viaCall(5, 6)), viaBinding(0, 9));