Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
a1be62304a fix(shell): use shell PATH for command resolution
When using the Bun shell `$` template literal, setting PATH via `.env()`
or `export` was not affecting command resolution. The `which` builtin
correctly used the shell environment PATH, but actual command execution
used the process PATH instead.

The fix checks the shell environment for PATH (cmd_local_env first,
then export_env) before falling back to the process PATH, matching
how the `which` builtin already handles this.

Closes #25885

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:27:54 +00:00
6 changed files with 74 additions and 143 deletions

View File

@@ -2204,7 +2204,7 @@ pub fn NewParser_(
var is_sloppy_mode_block_level_fn_stmt = false;
const original_member_ref = value.ref;
if (p.willUseRenamer() and symbol.kind == .hoisted_function and scope.kind != .label) {
if (p.willUseRenamer() and symbol.kind == .hoisted_function) {
// Block-level function declarations behave like "let" in strict mode
if (scope.strict_mode != .sloppy_mode) {
continue;

View File

@@ -810,12 +810,7 @@ pub fn Visit(
// This is only done for function declarations that are not generators
// or async functions, since this is a backwards-compatibility hack from
// Annex B of the JavaScript standard.
//
// However, function declarations inside labeled statements should NOT
// be treated as block-level functions. Per ECMAScript Annex B, they
// should hoist like regular function declarations in sloppy mode.
!p.current_scope.kindStopsHoisting() and
p.current_scope.kind != .label and
p.symbols.items[data.func.name.?.ref.?.innerIndex()].kind == .hoisted_function)
{
break :list_getter &before;

View File

@@ -688,19 +688,7 @@ pub fn VisitStmt(
else => {},
}
// For function declarations inside labels in sloppy mode, we need special handling.
// Per ECMAScript Annex B, they should hoist like regular function declarations,
// not like block-scoped functions. We can't use visitSingleStmt because it would
// wrap the function in a block via stmtsToSingleStmt.
if (data.stmt.data == .s_function and p.current_scope.strict_mode == .sloppy_mode) {
var inner_stmts = ListManaged(Stmt).initCapacity(p.allocator, 1) catch unreachable;
inner_stmts.append(data.stmt) catch unreachable;
p.visitStmts(&inner_stmts, StmtsKind.none) catch unreachable;
// The function should remain as a single statement without block wrapping
data.stmt = if (inner_stmts.items.len == 1) inner_stmts.items[0] else p.stmtsToSingleStmt(data.stmt.loc, inner_stmts.items);
} else {
data.stmt = p.visitSingleStmt(data.stmt, StmtsKind.none);
}
data.stmt = p.visitSingleStmt(data.stmt, StmtsKind.none);
p.popScope();
try stmts.append(stmt.*);

View File

@@ -485,7 +485,16 @@ fn initSubproc(this: *Cmd) Yield {
const path_buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(path_buf);
const resolved = which(path_buf, spawn_args.PATH, spawn_args.cwd, first_arg_real) orelse blk: {
// Check shell environment for PATH before falling back to process PATH.
// cmd_local_env has priority (set via .env()), then export_env.
const path_key = shell.EnvStr.initSlice("PATH");
const lookup_path = if (this.base.shell.cmd_local_env.get(path_key)) |p|
p.slice()
else if (this.base.shell.export_env.get(path_key)) |p|
p.slice()
else
spawn_args.PATH;
const resolved = which(path_buf, lookup_path, spawn_args.cwd, first_arg_real) orelse blk: {
if (bun.strings.eqlComptime(first_arg_real, "bun") or bun.strings.eqlComptime(first_arg_real, "bun-debug")) blk2: {
break :blk bun.selfExePath() catch break :blk2;
}

View File

@@ -1,123 +0,0 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("function declaration inside labeled statement should be accessible in sloppy mode", async () => {
using dir = tempDir("issue-25737", {
"test.cjs": `
foo:
function bar() { return "bar"; }
console.log(bar());
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stdout.trim()).toBe("bar");
expect(exitCode).toBe(0);
});
test("function declaration inside nested labeled statements should be accessible", async () => {
using dir = tempDir("issue-25737-nested", {
"test.cjs": `
outer:
inner:
function baz() { return "baz"; }
console.log(baz());
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stdout.trim()).toBe("baz");
expect(exitCode).toBe(0);
});
test("function declaration inside labeled statement with break should work", async () => {
using dir = tempDir("issue-25737-break", {
"test.cjs": `
let result = "";
foo: {
function bar() { return "bar"; }
result = bar();
break foo;
}
console.log(result);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stdout.trim()).toBe("bar");
expect(exitCode).toBe(0);
});
test("transpiler output should not wrap labeled function in block", async () => {
using dir = tempDir("issue-25737-transpile", {
"test.cjs": `
foo:
function bar() { return "bar"; }
console.log(bar());
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "--no-bundle", "test.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// The output should NOT contain "{ function bar" or "{ let bar"
// It should be a simple labeled function declaration
expect(stdout).not.toContain("{ function bar");
expect(stdout).not.toContain("{ let bar");
expect(stdout).not.toContain("foo: {");
expect(stdout).toContain("foo:");
expect(stdout).toContain("function bar()");
expect(exitCode).toBe(0);
});

View File

@@ -0,0 +1,62 @@
import { $ } from "bun";
import { expect, test } from "bun:test";
import { tempDir } from "harness";
// Regression test for https://github.com/oven-sh/bun/issues/25885
// Shell command resolution should respect PATH set via .env()
test("shell respects PATH set via .env() for command resolution", async () => {
using dir = tempDir("shell-path-env", {
"mytest": '#!/bin/bash\necho "hello from mytest"',
});
// Make the script executable
await $`chmod +x ${String(dir)}/mytest`.quiet();
const enhancedPath = `${String(dir)}:${process.env.PATH}`;
// Both `which` builtin and direct command execution should find the binary
const whichResult = await $`which mytest`.env({ ...process.env, PATH: enhancedPath }).quiet();
expect(whichResult.stdout.toString().trim()).toBe(`${String(dir)}/mytest`);
// This was the bug: direct execution failed even though `which` worked
const execResult = await $`mytest`.env({ ...process.env, PATH: enhancedPath }).quiet();
expect(execResult.stdout.toString().trim()).toBe("hello from mytest");
expect(execResult.exitCode).toBe(0);
});
test("shell respects PATH set via export for command resolution", async () => {
using dir = tempDir("shell-path-export", {
"mytest2": '#!/bin/bash\necho "hello from mytest2"',
});
// Make the script executable
await $`chmod +x ${String(dir)}/mytest2`.quiet();
const enhancedPath = `${String(dir)}:${process.env.PATH}`;
// Test with export (export_env)
const result = await $`export PATH=${enhancedPath}; mytest2`.quiet();
expect(result.stdout.toString().trim()).toBe("hello from mytest2");
expect(result.exitCode).toBe(0);
});
test("shell PATH lookup priority: cmd_local_env > export_env", async () => {
using dir1 = tempDir("shell-path-priority-1", {
"prioritytest": '#!/bin/bash\necho "from dir1"',
});
using dir2 = tempDir("shell-path-priority-2", {
"prioritytest": '#!/bin/bash\necho "from dir2"',
});
await $`chmod +x ${String(dir1)}/prioritytest`.quiet();
await $`chmod +x ${String(dir2)}/prioritytest`.quiet();
// cmd_local_env (PATH=x command) should take priority over export_env
const path1 = `${String(dir1)}:${process.env.PATH}`;
const path2 = `${String(dir2)}:${process.env.PATH}`;
// export sets export_env, then PATH=x sets cmd_local_env for that command
// cmd_local_env should win for command resolution
const result = await $`export PATH=${path2}; PATH=${path1} prioritytest`.quiet();
expect(result.stdout.toString().trim()).toBe("from dir1");
});