Compare commits

..

2 Commits

Author SHA1 Message Date
autofix-ci[bot]
4a06b1c9d2 [autofix.ci] apply automated fixes 2026-02-19 10:27:37 +00:00
Claude Bot
e83c4083a8 fix(parser): reject optional chaining in assignment targets
The parser's minify_syntax optimization was stripping optional chains
(e.g. `{}?.y` → `{}.y`) during parsing, before the visitor's assignment
target validity check could see them. This caused `{}?.y = 0` to be
silently accepted instead of throwing a SyntaxError.

Move the optional chain removal optimization from the parse phase to the
visit phase, so `isValidAssignmentTarget` correctly rejects these cases
before the optimization runs.

Closes #15848

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 10:25:47 +00:00
5 changed files with 164 additions and 122 deletions

View File

@@ -137,10 +137,7 @@ pub fn canMergeSymbols(
if (Symbol.isKindHoistedOrFunction(new) and
Symbol.isKindHoistedOrFunction(existing) and
(scope.kind == .entry or scope.kind == .function_body or scope.kind == .function_args or
(new == existing and Symbol.isKindHoisted(existing) and
// In strict mode, block-scoped function declarations behave like `let` bindings
// and duplicates are a SyntaxError (ES2015+ B.3.2.1, 14.1.2).
!(scope.strict_mode != .sloppy_mode and new == .hoisted_function))))
(new == existing and Symbol.isKindHoisted(existing))))
{
return .replace_with_new;
}

View File

@@ -97,15 +97,7 @@ pub fn ParseSuffix(
}
fn t_question_dot(p: *P, level: Level, optional_chain: *?OptionalChain, left: *Expr) anyerror!Continuation {
try p.lexer.next();
var optional_start: ?OptionalChain = OptionalChain.start;
// Remove unnecessary optional chains
if (p.options.features.minify_syntax) {
const result = SideEffects.toNullOrUndefined(p, left.data);
if (result.ok and !result.value) {
optional_start = null;
}
}
const optional_start: ?OptionalChain = OptionalChain.start;
switch (p.lexer.token) {
.t_open_bracket => {
@@ -952,6 +944,5 @@ const T = js_lexer.T;
const js_parser = bun.js_parser;
const DeferredErrors = js_parser.DeferredErrors;
const JSXTransformType = js_parser.JSXTransformType;
const SideEffects = js_parser.SideEffects;
const TypeScript = js_parser.TypeScript;
const options = js_parser.options;

View File

@@ -550,6 +550,27 @@ pub fn VisitExpr(
});
e_.target = target_visited;
// Remove unnecessary optional chains
if (p.options.features.minify_syntax) {
if (e_.optional_chain == .start) {
const result = SideEffects.toNullOrUndefined(p, e_.target.data);
if (result.ok and !result.value) {
e_.optional_chain = null;
}
} else if (e_.optional_chain == .continuation) {
// Strip orphaned continuations whose target's chain was already removed
const target_chain = switch (e_.target.data) {
.e_dot => |dot| dot.optional_chain,
.e_index => |idx| idx.optional_chain,
.e_call => |call| call.optional_chain,
else => null,
};
if (target_chain == null) {
e_.optional_chain = null;
}
}
}
switch (e_.index.data) {
.e_private_identifier => |_private| {
var private = _private;
@@ -877,6 +898,27 @@ pub fn VisitExpr(
.property_access_for_method_call_maybe_should_replace_with_undefined = in.property_access_for_method_call_maybe_should_replace_with_undefined,
});
// Remove unnecessary optional chains
if (p.options.features.minify_syntax) {
if (e_.optional_chain == .start) {
const result = SideEffects.toNullOrUndefined(p, e_.target.data);
if (result.ok and !result.value) {
e_.optional_chain = null;
}
} else if (e_.optional_chain == .continuation) {
// Strip orphaned continuations whose target's chain was already removed
const target_chain = switch (e_.target.data) {
.e_dot => |dot| dot.optional_chain,
.e_index => |idx| idx.optional_chain,
.e_call => |call| call.optional_chain,
else => null,
};
if (target_chain == null) {
e_.optional_chain = null;
}
}
}
// 'require.resolve' -> .e_require_resolve_call_target
if (e_.target.data == .e_require_call_target and
strings.eqlComptime(e_.name, "resolve"))
@@ -1198,6 +1240,27 @@ pub fn VisitExpr(
.property_access_for_method_call_maybe_should_replace_with_undefined = true,
});
// Remove unnecessary optional chains
if (p.options.features.minify_syntax) {
if (e_.optional_chain == .start) {
const result = SideEffects.toNullOrUndefined(p, e_.target.data);
if (result.ok and !result.value) {
e_.optional_chain = null;
}
} else if (e_.optional_chain == .continuation) {
// Strip orphaned continuations whose target's chain was already removed
const target_chain = switch (e_.target.data) {
.e_dot => |dot| dot.optional_chain,
.e_index => |idx| idx.optional_chain,
.e_call => |call| call.optional_chain,
else => null,
};
if (target_chain == null) {
e_.optional_chain = null;
}
}
}
// Copy the call side effect flag over if this is a known target
switch (e_.target.data) {
.e_identifier => |ident| {

View File

@@ -1,108 +0,0 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/14273
// In strict mode, duplicate plain function declarations in a block scope
// should be a SyntaxError (they behave like `let` bindings per ES2015+ spec).
test("strict mode: duplicate function declarations in block scope is SyntaxError", async () => {
const cases = [
// Basic block scope
`"use strict"; { function f(){} function f(){} }`,
// Nested block
`"use strict"; { { function f(){} function f(){} } }`,
// Inside if
`"use strict"; if(true){ function f(){} function f(){} }`,
// Inside for
`"use strict"; for(;;){ function f(){} function f(){} break; }`,
// Inside switch case
`"use strict"; switch(1){ case 1: function f(){} function f(){} }`,
// Function body with "use strict"
`function outer(){ "use strict"; { function f(){} function f(){} } }`,
];
for (const code of cases) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect({
code,
exitCode,
hasError: stderr.includes("has already been declared"),
}).toEqual({
code,
exitCode: 1,
hasError: true,
});
}
}, 30_000);
test("sloppy mode: duplicate function declarations in block scope is allowed", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `{ function f(){} function f(){} }`],
env: bunEnv,
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("strict mode: duplicate async function declarations in block scope is SyntaxError", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `"use strict"; { async function f(){} async function f(){} }`],
env: bunEnv,
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toInclude("has already been declared");
expect(exitCode).toBe(1);
});
test("strict mode: duplicate generator function declarations in block scope is SyntaxError", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `"use strict"; { function* f(){} function* f(){} }`],
env: bunEnv,
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toInclude("has already been declared");
expect(exitCode).toBe(1);
});
test("strict mode: duplicate function declarations at top level is allowed", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `"use strict"; function f(){} function f(){}`],
env: bunEnv,
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("strict mode: duplicate var declarations in block scope is allowed", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `"use strict"; { var x = 1; var x = 2; }`],
env: bunEnv,
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

View File

@@ -0,0 +1,99 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/15848
// Optional chaining in assignment target should be a SyntaxError
test("optional chaining dot access in for-of assignment target", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `for ([{set y(val){console.log("accessed")}}?.y = 0] of [[]]);`],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("Invalid assignment target");
expect(stdout).not.toContain("accessed");
expect(exitCode).not.toBe(0);
});
test("optional chaining bracket access in assignment", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `({})?.["x"] = 1`],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("Invalid assignment target");
expect(exitCode).not.toBe(0);
});
test("array literal optional chaining in assignment", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `[1]?.x = 0`],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("Invalid assignment target");
expect(exitCode).not.toBe(0);
});
test("class expression optional chaining in assignment", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `(class {})?.x = 0`],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("Invalid assignment target");
expect(exitCode).not.toBe(0);
});
test("function expression optional chaining in assignment", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `(function(){})?.x = 0`],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("Invalid assignment target");
expect(exitCode).not.toBe(0);
});
test("valid optional chaining is not affected", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const obj = { a: { b: 1 } };
console.log(obj?.a?.b);
console.log({}?.x);
console.log([1]?.length);
`,
],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toContain("1");
expect(exitCode).toBe(0);
});