fix(coding-agent): Clean up Path Handling (#4873)

This commit is contained in:
Armin Ronacher
2026-05-22 11:25:15 +02:00
committed by GitHub
parent bf56a86e1e
commit c100620bf4
23 changed files with 363 additions and 214 deletions

View File

@@ -1,7 +1,24 @@
import { realpathSync } from "node:fs";
import { isAbsolute, relative, resolve as resolvePath, sep } from "node:path";
import { homedir } from "node:os";
import { isAbsolute, join, resolve as nodeResolvePath, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnProcessSync } from "./child-process.ts";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
export interface PathInputOptions {
/** Trim leading/trailing whitespace before normalization. */
trim?: boolean;
/** Expand leading `~` to a home directory. Defaults to true. */
expandTilde?: boolean;
/** Home directory used for `~` expansion. Defaults to `os.homedir()`. */
homeDir?: string;
/** Strip a leading `@`, used for CLI @file paths. */
stripAtPrefix?: boolean;
/** Normalize unicode space variants to regular spaces. */
normalizeUnicodeSpaces?: boolean;
}
/**
* Resolve a path to its canonical (real) form, following symlinks.
* Falls back to the raw path if resolution fails (e.g. the target does
@@ -18,12 +35,12 @@ export function canonicalizePath(path: string): string {
/**
* Returns true if the value is NOT a package source (npm:, git:, etc.)
* or a URL protocol. Bare names and relative paths without ./ prefix
* or a remote URL protocol. Bare names, relative paths, and file: URLs
* are considered local.
*/
export function isLocalPath(value: string): boolean {
const trimmed = value.trim();
// Known non-local prefixes
// Known non-local prefixes. file: URLs are local paths and are intentionally resolved by resolvePath().
if (
trimmed.startsWith("npm:") ||
trimmed.startsWith("git:") ||
@@ -37,13 +54,39 @@ export function isLocalPath(value: string): boolean {
return true;
}
function resolveAgainstCwd(filePath: string, cwd: string): string {
return isAbsolute(filePath) ? resolvePath(filePath) : resolvePath(cwd, filePath);
export function normalizePath(input: string, options: PathInputOptions = {}): string {
let normalized = options.trim ? input.trim() : input;
if (options.normalizeUnicodeSpaces) {
normalized = normalized.replace(UNICODE_SPACES, " ");
}
if (options.stripAtPrefix && normalized.startsWith("@")) {
normalized = normalized.slice(1);
}
if (options.expandTilde ?? true) {
const home = options.homeDir ?? homedir();
if (normalized === "~") return home;
if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
return join(home, normalized.slice(2));
}
}
if (/^file:\/\//.test(normalized)) {
return fileURLToPath(normalized);
}
return normalized;
}
export function resolvePath(input: string, baseDir: string = process.cwd(), options: PathInputOptions = {}): string {
const normalized = normalizePath(input, options);
const normalizedBaseDir = normalizePath(baseDir);
return isAbsolute(normalized) ? nodeResolvePath(normalized) : nodeResolvePath(normalizedBaseDir, normalized);
}
export function getCwdRelativePath(filePath: string, cwd: string): string | undefined {
const resolvedCwd = resolvePath(cwd);
const resolvedPath = resolveAgainstCwd(filePath, resolvedCwd);
const resolvedPath = resolvePath(filePath, resolvedCwd);
const relativePath = relative(resolvedCwd, resolvedPath);
const isInsideCwd =
relativePath === "" ||
@@ -53,7 +96,7 @@ export function getCwdRelativePath(filePath: string, cwd: string): string | unde
}
export function formatPathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string {
const absolutePath = resolveAgainstCwd(filePath, cwd);
const absolutePath = resolvePath(filePath, cwd);
return (getCwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join("/");
}