From aad8d90dd182c43b1c06e670c00d02233e9c160c Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 3 Jul 2026 13:25:38 -0400 Subject: [PATCH] refactor(core): move path resolve into fs service (#35201) --- packages/core/src/fs-util.ts | 10 ++++++++++ packages/core/src/instruction-context.ts | 24 ++++++++++++----------- packages/core/src/project/copy.ts | 2 +- packages/core/src/session/instructions.ts | 2 +- packages/core/src/tool/read.ts | 6 +++--- packages/core/src/tool/shell.ts | 14 ++++++++----- 6 files changed, 37 insertions(+), 21 deletions(-) diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index ff71477d70..124ac6d702 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -38,6 +38,7 @@ export namespace FSUtil { readonly ensureDir: (path: string) => Effect.Effect readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect readonly readDirectoryEntries: (path: string) => Effect.Effect + readonly resolve: (path: string) => Effect.Effect readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect @@ -89,6 +90,14 @@ export namespace FSUtil { }) }) + const resolve = Effect.fn("FileSystem.resolve")(function* (path: string) { + const resolved = pathResolve(windowsPath(path)) + return yield* fs.realPath(resolved).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(resolved)), + Effect.orDie, + ) + }) + const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) { const text = yield* fs.readFileString(path) return yield* Effect.try({ @@ -187,6 +196,7 @@ export namespace FSUtil { isDir, isFile, readDirectoryEntries, + resolve, readJson, writeJson, ensureDir, diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index 94d9e787ce..66d9e14c01 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -43,22 +43,24 @@ const layer = Layer.effect( }) const observe = Effect.fn("InstructionContext.observe")(function* () { - const start = FSUtil.resolve(location.directory) - const stop = FSUtil.resolve(location.project.directory) + const start = yield* fs.resolve(location.directory) + const stop = yield* fs.resolve(location.project.directory) const fromProject = relative(stop, start) const insideProject = fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) const discovered = new Set( - (Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject - ? [] - : yield* fs.up({ - targets: ["AGENTS.md"], - start, - stop, - }) - ).map(FSUtil.resolve), + yield* Effect.forEach( + Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject + ? [] + : yield* fs.up({ + targets: ["AGENTS.md"], + start, + stop, + }), + fs.resolve, + ), ) - const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered]) + const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered]) const files = yield* Effect.forEach( paths, (path) => diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index b42df4045c..0980ef44f7 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -139,7 +139,7 @@ const layer = Layer.effect( }) const canonical = Effect.fnUntraced(function* (input: AbsolutePath) { - const resolved = AbsolutePath.make(FSUtil.resolve(input)) + const resolved = AbsolutePath.make(yield* fs.resolve(input)) if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input }) return resolved }) diff --git a/packages/core/src/session/instructions.ts b/packages/core/src/session/instructions.ts index 3e2eb27147..913f5c7334 100644 --- a/packages/core/src/session/instructions.ts +++ b/packages/core/src/session/instructions.ts @@ -35,7 +35,7 @@ const layer = Layer.effect( // Resolved once for the Location layer; the synthetic text and dedup ledger keep // absolute paths, but the human-facing description shows paths relative to the project // root so opening a subdirectory still describes paths from the project root. - const root = FSUtil.resolve(location.project.directory) + const root = yield* fs.resolve(location.project.directory) // Same-turn parallel reads settle concurrently, so an in-memory claim guards each // Session/path pair before any filesystem work. The durable history check below covers // paths injected in earlier turns after this Location layer was reopened. diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 028456a4e1..16e229d97d 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -97,8 +97,8 @@ export const Plugin = { // skipped, and discovery failures never fail the read. yield* Effect.gen(function* () { if (target.externalDirectory !== undefined) return - const resolved = FSUtil.resolve(target.canonical) - const root = FSUtil.resolve(location.directory) + const resolved = yield* fs.resolve(target.canonical) + const root = yield* fs.resolve(location.directory) // up() searches its stop directory, so the Location-root AGENTS.md (already // supplied by the core/instructions baseline) is dropped by the dirname filter. const discovered = yield* fs.up({ @@ -106,7 +106,7 @@ export const Plugin = { start: type === "directory" ? resolved : dirname(resolved), stop: root, }) - const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root) + const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter((file) => dirname(file) !== root) if (candidates.length === 0) return yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) }).pipe( diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index e86f960ba5..cb7edb3113 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -80,17 +80,21 @@ const modelOutput = (output: Output): string | undefined => { const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2") -const externalCommandDirectories = (command: string, cwd: string) => { +const externalCommandDirectories = Effect.fn("ShellTool.externalCommandDirectories")(function* ( + fs: FSUtil.Interface, + command: string, + cwd: string, +) { const directories = new Set() for (const token of shellTokens(command)) { const value = unquote(token).replace(/[;,|&]+$/, "") if (!path.isAbsolute(value)) continue - const resolved = FSUtil.resolve(value) + const resolved = yield* fs.resolve(value) if (FSUtil.contains(cwd, resolved)) continue - directories.add(FSUtil.resolve(path.dirname(resolved))) + directories.add(yield* fs.resolve(path.dirname(resolved))) } return [...directories] -} +}) export const Plugin = { id: "core-shell-tool", @@ -168,7 +172,7 @@ export const Plugin = { agent: context.agent, source, }) - const warnings = externalCommandDirectories(input.command, target.canonical).map( + const warnings = (yield* externalCommandDirectories(fsUtil, input.command, target.canonical)).map( (directory) => `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`, )