From e42bc940f876074c16bdacac01a625a7daa9ff9a Mon Sep 17 00:00:00 2001 From: Behzad Motaghi Date: Sat, 31 Oct 2026 07:24:22 +0100 Subject: [PATCH 1/4] fix(shared): search cwd for empty POSIX PATH components --- packages/shared/src/shell.test.ts | 87 +++++++++++++++++++++++++++++++ packages/shared/src/shell.ts | 12 +++-- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 690e6d94523c9..4e472e9badb8c 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -518,6 +518,93 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { Effect.provideService(CommandResolutionCache, new Map()), ), ); + + // Candidates are checked against the process cwd, so the lookup runs in a child with a temp cwd. + const resolveInChild = ` +import { createRequire } from "node:module"; +const [shellPath, command, rawPaths] = process.argv.slice(1); +const requireFromShell = createRequire(shellPath); +const Effect = await import(requireFromShell.resolve("effect/Effect")); +const { layer } = await import(requireFromShell.resolve("@effect/platform-node/NodeServices")); +const HostProcess = await import(shellPath.replace(/shell\\.ts$/, "HostProcess.ts")); +const Shell = await import(shellPath); +const resolved = {}; +for (const [label, PATH] of Object.entries(JSON.parse(rawPaths))) { + resolved[label] = { + effect: await Effect.runPromise( + Shell.resolveCommandPath(command, { env: { PATH } }).pipe( + Effect.provideService(HostProcess.Platform, process.platform), + Effect.provideService(Shell.CommandResolutionCache, new Map()), + Effect.provide(layer), + ), + ), + spawn: Shell.SpawnExecutableResolution.defaultValue()(command, process.platform, { PATH }) ?? null, + }; +} +console.log(JSON.stringify(resolved)); +`; + + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "searches the current directory for empty components of a nonempty POSIX PATH", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-empty-path-cwd-" }); + const listed = yield* fs.makeTempDirectoryScoped({ prefix: "t3-empty-path-listed-" }); + const command = "t3-empty-path-cli"; + const inCwd = path.join(cwd, command); + const inListed = path.join(listed, command); + yield* fs.writeFileString(inCwd, "#!/bin/sh\n", { mode: 0o755 }); + yield* fs.writeFileString(inListed, "#!/bin/sh\n", { mode: 0o755 }); + const missing = path.join(cwd, "missing"); + + const searches = { + dot: { PATH: ".", expected: inCwd }, + absolute: { PATH: listed, expected: inListed }, + leading: { PATH: `:${missing}`, expected: inCwd }, + trailing: { PATH: `${missing}:`, expected: inCwd }, + interior: { PATH: `${missing}::${missing}-2`, expected: inCwd }, + leadingBeforeListed: { PATH: `:${listed}`, expected: inCwd }, + listedBeforeTrailing: { PATH: `${listed}:`, expected: inListed }, + entirelyEmpty: { PATH: "", expected: null }, + whitespaceOnly: { PATH: `${missing}: `, expected: null }, + quotedEmpty: { PATH: `${missing}:""`, expected: null }, + }; + + const entries = Object.entries(searches); + const stdout = NodeChildProcess.execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + resolveInChild, + path.join(import.meta.dirname, "shell.ts"), + command, + JSON.stringify(Object.fromEntries(entries.map(([label, row]) => [label, row.PATH]))), + ], + { cwd, encoding: "utf8", env: {} }, + ); + const resolved: Record = + JSON.parse(stdout); + + // A relative result is relative to the child's cwd. + const anchored = (found: string | null) => + found === null ? null : path.resolve(cwd, found); + expect( + Object.fromEntries( + Object.entries(resolved).map(([label, found]) => [ + label, + { effect: anchored(found.effect), spawn: anchored(found.spawn) }, + ]), + ), + ).toEqual( + Object.fromEntries( + entries.map(([label, row]) => [label, { effect: row.expected, spawn: row.expected }]), + ), + ); + }), + ); }); describe("preferGitForWindowsBinary", () => { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 73c54d1f8b88d..e1b5a47b33c23 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -99,8 +99,13 @@ function resolveSpawnExecutableWithNode( return candidates.find(isExecutable); } - for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { - const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); + const pathValue = readEnvPath(env) ?? ""; + for (const pathEntry of pathValue.split(pathDelimiterForPlatform(platform))) { + // A raw empty component of a nonempty POSIX PATH is the current directory. + const normalizedPathEntry = + pathEntry === "" && pathValue !== "" && platform !== "win32" + ? "." + : stripWrappingQuotes(pathEntry.trim()); if (normalizedPathEntry.length === 0) continue; for (const candidate of candidates) { const candidatePath = path.join(normalizedPathEntry, candidate); @@ -636,7 +641,8 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat const pathEntries: string[] = []; const seenPathEntries = new Set(); for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { - const pathEntry = stripWrappingQuotes(entry.trim()); + const pathEntry = + entry === "" && platform !== "win32" ? "." : stripWrappingQuotes(entry.trim()); if (pathEntry.length === 0 || seenPathEntries.has(pathEntry)) continue; seenPathEntries.add(pathEntry); From 31fc18580e316df29285bbaccb2ed1198bc86e3b Mon Sep 17 00:00:00 2001 From: Behzad Motaghi Date: Sat, 31 Oct 2026 12:56:27 +0100 Subject: [PATCH 2/4] fix(shared): scope relative PATH caches to the working directory --- packages/shared/src/shell.test.ts | 83 ++++++++++++++++++++++++------- packages/shared/src/shell.ts | 37 ++++++++------ 2 files changed, 87 insertions(+), 33 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 4e472e9badb8c..6d8eff1a6063f 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -529,34 +529,40 @@ const { layer } = await import(requireFromShell.resolve("@effect/platform-node/N const HostProcess = await import(shellPath.replace(/shell\\.ts$/, "HostProcess.ts")); const Shell = await import(shellPath); const resolved = {}; -for (const [label, PATH] of Object.entries(JSON.parse(rawPaths))) { - resolved[label] = { - effect: await Effect.runPromise( - Shell.resolveCommandPath(command, { env: { PATH } }).pipe( +await Effect.runPromise(Effect.gen(function* () { + const caches = new Map(); + for (const [label, { PATH, cwd, cacheGroup }] of Object.entries(JSON.parse(rawPaths))) { + process.chdir(cwd); + let cache = caches.get(cacheGroup); + if (!cache) caches.set(cacheGroup, cache = new Map()); + resolved[label] = { + effect: yield* Shell.resolveCommandPath(command, { env: { PATH } }).pipe( Effect.provideService(HostProcess.Platform, process.platform), - Effect.provideService(Shell.CommandResolutionCache, new Map()), - Effect.provide(layer), + Effect.provideService(Shell.CommandResolutionCache, cache), ), - ), - spawn: Shell.SpawnExecutableResolution.defaultValue()(command, process.platform, { PATH }) ?? null, - }; -} + spawn: Shell.SpawnExecutableResolution.defaultValue()(command, process.platform, { PATH }) ?? null, + }; + } +}).pipe(Shell.withPathDirectoryListings, Effect.provide(layer))); console.log(JSON.stringify(resolved)); `; it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "searches the current directory for empty components of a nonempty POSIX PATH", + "searches the current directory for empty POSIX PATH components across cached lookups", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-empty-path-cwd-" }); const listed = yield* fs.makeTempDirectoryScoped({ prefix: "t3-empty-path-listed-" }); + const empty = yield* fs.makeTempDirectoryScoped({ prefix: "t3-empty-path-absent-" }); const command = "t3-empty-path-cli"; const inCwd = path.join(cwd, command); const inListed = path.join(listed, command); yield* fs.writeFileString(inCwd, "#!/bin/sh\n", { mode: 0o755 }); yield* fs.writeFileString(inListed, "#!/bin/sh\n", { mode: 0o755 }); + yield* fs.utimes(cwd, 4_102_444_800, 4_102_444_800); + yield* fs.utimes(empty, 4_102_444_800, 4_102_444_800); const missing = path.join(cwd, "missing"); const searches = { @@ -572,7 +578,27 @@ console.log(JSON.stringify(resolved)); quotedEmpty: { PATH: `${missing}:""`, expected: null }, }; - const entries = Object.entries(searches); + const cachedSearches = { + cachedMiss: { PATH: `:${missing}`, cwd: empty, cacheGroup: "miss", expected: null }, + afterMiss: { PATH: `:${missing}`, cwd, cacheGroup: "miss", expected: inCwd }, + cachedHit: { PATH: `:${missing}`, cwd, cacheGroup: "hit", expected: inCwd }, + afterHit: { PATH: `:${missing}`, cwd: empty, cacheGroup: "hit", expected: null }, + cachedListed: { + PATH: `:${listed}`, + cwd: empty, + cacheGroup: "listed", + expected: inListed, + }, + beforeListed: { PATH: `:${listed}`, cwd, cacheGroup: "listed", expected: inCwd }, + cachedLocal: { PATH: `:${listed}`, cwd, cacheGroup: "local", expected: inCwd }, + afterLocal: { PATH: `:${listed}`, cwd: empty, cacheGroup: "local", expected: inListed }, + }; + const entries = [ + ...Object.entries(cachedSearches), + ...Object.entries(searches).map( + ([label, row]) => [label, { ...row, cwd, cacheGroup: label }] as const, + ), + ]; const stdout = NodeChildProcess.execFileSync( process.execPath, [ @@ -581,7 +607,18 @@ console.log(JSON.stringify(resolved)); resolveInChild, path.join(import.meta.dirname, "shell.ts"), command, - JSON.stringify(Object.fromEntries(entries.map(([label, row]) => [label, row.PATH]))), + JSON.stringify( + Object.fromEntries( + entries.map(([label, row]) => [ + label, + { + PATH: row.PATH, + cwd: row.cwd, + cacheGroup: row.cacheGroup, + }, + ]), + ), + ), ], { cwd, encoding: "utf8", env: {} }, ); @@ -589,14 +626,22 @@ console.log(JSON.stringify(resolved)); JSON.parse(stdout); // A relative result is relative to the child's cwd. - const anchored = (found: string | null) => - found === null ? null : path.resolve(cwd, found); + const anchored = (found: string | null, directory: string) => + found === null ? null : path.resolve(directory, found); + const directories = Object.fromEntries(entries.map(([label, row]) => [label, row.cwd])); expect( Object.fromEntries( - Object.entries(resolved).map(([label, found]) => [ - label, - { effect: anchored(found.effect), spawn: anchored(found.spawn) }, - ]), + Object.entries(resolved).map(([label, found]) => { + const directory = directories[label]; + if (directory === undefined) throw new Error(`Unexpected resolver case: ${label}`); + return [ + label, + { + effect: anchored(found.effect, directory), + spawn: anchored(found.spawn, directory), + }, + ]; + }), ), ).toEqual( Object.fromEntries( diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index e1b5a47b33c23..cc4e185a396a5 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -627,16 +627,6 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat return null; } - const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join( - COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR, - ); - const cache = yield* CommandResolutionCache; - const nowNanos = yield* Clock.monotonicTimeNanos; - const cached = cache.get(cacheKey); - if (cached !== undefined && cached.expiresAtNanos > nowNanos) { - return cached.resolvedPath; - } - // Keep case variants: Windows can make PATH directories case-sensitive. const pathEntries: string[] = []; const seenPathEntries = new Set(); @@ -649,12 +639,31 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat pathEntries.push(pathEntry); } + const cwd = + platform !== "win32" && pathEntries.some((entry) => !path.isAbsolute(entry)) + ? process.cwd() + : undefined; + const cacheKey = [ + platform, + pathValue, + windowsPathExtensions.join(";"), + command, + ...(cwd === undefined ? [] : [cwd]), + ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); + const cache = yield* CommandResolutionCache; + const nowNanos = yield* Clock.monotonicTimeNanos; + const cached = cache.get(cacheKey); + if (cached !== undefined && cached.expiresAtNanos > nowNanos) { + return cached.resolvedPath; + } + const listings = yield* PathDirectoryListings; for (const pathEntry of pathEntries) { - let listing = listings && (yield* Cache.get(listings, pathEntry)); - if (listings && listing?.names && listing.modified !== (yield* directoryMtime(pathEntry))) { - yield* Cache.invalidate(listings, pathEntry); - listing = yield* Cache.get(listings, pathEntry); + const directory = cwd === undefined ? pathEntry : path.resolve(cwd, pathEntry); + let listing = listings && (yield* Cache.get(listings, directory)); + if (listings && listing?.names && listing.modified !== (yield* directoryMtime(directory))) { + yield* Cache.invalidate(listings, directory); + listing = yield* Cache.get(listings, directory); } for (const candidate of commandCandidates) { if (listing?.names && !listing.names.has(candidate.toLowerCase())) continue; From 560d9ccbf4ad65653a7490179b1b47db9fb82851 Mon Sep 17 00:00:00 2001 From: Behzad Motaghi Date: Sat, 31 Oct 2026 13:05:48 +0100 Subject: [PATCH 3/4] no-mistakes(review): Scope macOS Git caches to relative PATH cwd --- packages/shared/src/shell.test.ts | 53 ++++++++++++++++++++++++++++--- packages/shared/src/shell.ts | 20 +++++++++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 6d8eff1a6063f..be3abb0f1b261 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -522,13 +522,20 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { // Candidates are checked against the process cwd, so the lookup runs in a child with a temp cwd. const resolveInChild = ` import { createRequire } from "node:module"; -const [shellPath, command, rawPaths] = process.argv.slice(1); +const [shellPath, command, rawPaths, rawGitPaths, gitShim, xcodeGit] = process.argv.slice(1); const requireFromShell = createRequire(shellPath); const Effect = await import(requireFromShell.resolve("effect/Effect")); const { layer } = await import(requireFromShell.resolve("@effect/platform-node/NodeServices")); const HostProcess = await import(shellPath.replace(/shell\\.ts$/, "HostProcess.ts")); const Shell = await import(shellPath); const resolved = {}; +const resolvedGit = {}; +const defaultResolver = Shell.SpawnExecutableResolution.defaultValue(); +const resolveExecutable = (...args) => { + const found = defaultResolver(...args); + return found === gitShim ? "/usr/bin/git" : found; +}; +const locateXcodeGit = () => xcodeGit; await Effect.runPromise(Effect.gen(function* () { const caches = new Map(); for (const [label, { PATH, cwd, cacheGroup }] of Object.entries(JSON.parse(rawPaths))) { @@ -543,8 +550,19 @@ await Effect.runPromise(Effect.gen(function* () { spawn: Shell.SpawnExecutableResolution.defaultValue()(command, process.platform, { PATH }) ?? null, }; } + for (const [label, { PATH, cwd, cacheGroup }] of Object.entries(JSON.parse(rawGitPaths))) { + process.chdir(cwd); + let cache = caches.get(cacheGroup); + if (!cache) caches.set(cacheGroup, cache = new Map()); + resolvedGit[label] = yield* Shell.resolveSpawnCommand("git", ["status"], { env: { PATH } }).pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(Shell.SpawnExecutableResolution, resolveExecutable), + Effect.provideService(Shell.XcodeGitLocation, locateXcodeGit), + Effect.provideService(Shell.CommandResolutionCache, cache), + ); + } }).pipe(Shell.withPathDirectoryListings, Effect.provide(layer))); -console.log(JSON.stringify(resolved)); +console.log(JSON.stringify({ resolved, resolvedGit })); `; it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( @@ -559,8 +577,13 @@ console.log(JSON.stringify(resolved)); const command = "t3-empty-path-cli"; const inCwd = path.join(cwd, command); const inListed = path.join(listed, command); + const gitShim = path.join(listed, "git"); + const xcodeGit = path.join(listed, "xcode-git"); yield* fs.writeFileString(inCwd, "#!/bin/sh\n", { mode: 0o755 }); yield* fs.writeFileString(inListed, "#!/bin/sh\n", { mode: 0o755 }); + yield* fs.writeFileString(path.join(cwd, "git"), "#!/bin/sh\n", { mode: 0o755 }); + yield* fs.writeFileString(gitShim, "#!/bin/sh\n", { mode: 0o755 }); + yield* fs.writeFileString(xcodeGit, "#!/bin/sh\n", { mode: 0o755 }); yield* fs.utimes(cwd, 4_102_444_800, 4_102_444_800); yield* fs.utimes(empty, 4_102_444_800, 4_102_444_800); const missing = path.join(cwd, "missing"); @@ -599,6 +622,15 @@ console.log(JSON.stringify(resolved)); ([label, row]) => [label, { ...row, cwd, cacheGroup: label }] as const, ), ]; + const gitEntries = ["", "."].flatMap((entry) => { + const PATH = `${entry}:${listed}`; + return [ + { PATH, cwd: empty, cacheGroup: `xcode-first-${entry}`, expected: xcodeGit }, + { PATH, cwd, cacheGroup: `xcode-first-${entry}`, expected: "git" }, + { PATH, cwd, cacheGroup: `local-first-${entry}`, expected: "git" }, + { PATH, cwd: empty, cacheGroup: `local-first-${entry}`, expected: xcodeGit }, + ]; + }); const stdout = NodeChildProcess.execFileSync( process.execPath, [ @@ -619,11 +651,16 @@ console.log(JSON.stringify(resolved)); ]), ), ), + JSON.stringify(gitEntries), + gitShim, + xcodeGit, ], { cwd, encoding: "utf8", env: {} }, ); - const resolved: Record = - JSON.parse(stdout); + const { resolved, resolvedGit }: { + resolved: Record; + resolvedGit: Record; + } = JSON.parse(stdout); // A relative result is relative to the child's cwd. const anchored = (found: string | null, directory: string) => @@ -648,6 +685,14 @@ console.log(JSON.stringify(resolved)); entries.map(([label, row]) => [label, { effect: row.expected, spawn: row.expected }]), ), ); + expect(resolvedGit).toEqual( + Object.fromEntries( + gitEntries.map((row, index) => [ + String(index), + { command: row.expected, args: ["status"], shell: false }, + ]), + ), + ); }), ); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index cc4e185a396a5..0170232f2104c 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -449,6 +449,16 @@ function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { return readEnvPath(env) ?? ""; } +function pathSearchCwd(pathValue: string, platform: NodeJS.Platform): string | undefined { + if (platform === "win32" || pathValue.length === 0) return undefined; + return pathValue.split(POSIX_PATH_DELIMITER).some((entry) => { + const directory = entry === "" ? "." : stripWrappingQuotes(entry.trim()); + return directory.length > 0 && !NodePath.posix.isAbsolute(directory); + }) + ? process.cwd() + : undefined; +} + function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { const rawValue = env.PATHEXT; const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; @@ -639,10 +649,7 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat pathEntries.push(pathEntry); } - const cwd = - platform !== "win32" && pathEntries.some((entry) => !path.isAbsolute(entry)) - ? process.cwd() - : undefined; + const cwd = pathSearchCwd(pathValue, platform); const cacheKey = [ platform, pathValue, @@ -796,12 +803,15 @@ const resolveMacosGit = Effect.fnUntraced(function* ( ) { const locateXcodeGit = yield* XcodeGitLocation; const cache = yield* CommandResolutionCache; + const pathValue = resolvePathEnvironmentVariable(env); + const cwd = pathSearchCwd(pathValue, "darwin"); const cacheKey = [ "xcode-git", String(spawnResolverCacheId(resolveExecutable)), String(spawnResolverCacheId(locateXcodeGit)), - resolvePathEnvironmentVariable(env), + pathValue, env.DEVELOPER_DIR ?? "", + ...(cwd === undefined ? [] : [cwd]), ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); const nowNanos = yield* Clock.currentTimeNanos; const cached = cache.get(cacheKey); From f371165d16612e1e11b06bf6b0fc7532f7ad422b Mon Sep 17 00:00:00 2001 From: Behzad Motaghi Date: Sat, 31 Oct 2026 13:12:48 +0100 Subject: [PATCH 4/4] no-mistakes(lint): Format shell regression test destructuring --- packages/shared/src/shell.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index be3abb0f1b261..60cbe5ca93a09 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -657,7 +657,10 @@ console.log(JSON.stringify({ resolved, resolvedGit })); ], { cwd, encoding: "utf8", env: {} }, ); - const { resolved, resolvedGit }: { + const { + resolved, + resolvedGit, + }: { resolved: Record; resolvedGit: Record; } = JSON.parse(stdout);