refactor(#2246): centralize cross-platform path-separator handling (toPosixPath / toNativePath / posixNormalize) (#2247)

Replace every open-coded separator translation across the installer/hooks
source with named, tested seams in shell-command-projection.cts (the platform
seam), removing all hardcoded `/`+`\` from path handling:

- toPosixPath(p)   — this machine's native path → POSIX (running-OS relative;
                     for local filesystem paths).
- toNativePath(p)  — POSIX → native (collapses the win32 `/\//g,'\\'` ternary).
- posixNormalize(p)— unconditional `\`→`/`, OS-independent; for emitting paths
                     to a POSIX/bash TARGET (which may differ from the running
                     OS) and for parsing mixed-separator input.

core-utils.toPosixPath now delegates to the seam, so its 20+ existing consumers
resolve to one implementation; no duplicate helper.

- ~47 sites across runtime-hooks-surface, runtime-artifact-conversion,
  runtime-artifact-install-plan, drift, init, worktree-safety,
  installer-migrations, installer-migration-authoring, install-engine, surface,
  verify, runtime-artifact-layout, schema-detect, check-command-router.
- Closes the latent POSIX-literal-backslash corruption class (the regex form
  corrupts a POSIX path containing a literal backslash; split(path.sep) does not).
- New unit + fast-check property tests for all three helpers.

Closes #2246

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-13 15:36:17 -04:00
committed by GitHub
parent 3592697bed
commit e0f969af6a
18 changed files with 259 additions and 60 deletions

View File

@@ -310,7 +310,7 @@ function normalizeNodePath(execPath: string, opts?: NodeNormOpts): string {
const env = (opts && opts.env) || process.env;
const existsSync = (opts && opts.existsSync) || fs.existsSync;
const normalizedForMatch = execPath.replace(/\\/g, '/');
const normalizedForMatch = shellCmdProjection.posixNormalize(execPath);
if (/\/fnm_multishells\/[0-9]+_[0-9]+\/node(\.exe)?$/i.test(normalizedForMatch)) {
const candidates: string[] = [];
if (env.FNM_DIR) {
@@ -363,7 +363,7 @@ function resolveNodeRunner(opts?: NodeNormOpts): string | null {
const execPath = typeof process.execPath === 'string' ? process.execPath : '';
if (!execPath) return null;
const stablePath = normalizeNodePath(execPath, opts);
return JSON.stringify(stablePath.replace(/\\/g, '/'));
return JSON.stringify(shellCmdProjection.posixNormalize(stablePath));
}
interface BashRunnerOpts {
@@ -389,7 +389,7 @@ function resolveBashRunner(opts?: BashRunnerOpts): string | null {
for (const candidate of candidates) {
if (candidate && exists(candidate)) {
return JSON.stringify(candidate.replace(/\\/g, '/'));
return JSON.stringify(shellCmdProjection.posixNormalize(candidate));
}
}
return null;
@@ -449,7 +449,7 @@ function rewriteLegacyManagedNodeHookCommands(settings: Settings, absoluteRunner
scriptPath = m[2] || m[3] || m[4] || '';
} else {
_runnerToken = m[1];
const runnerPath = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
const runnerPath = shellCmdProjection.posixNormalize(m[2] || m[3] || m[4] || '');
const stableRunner = normalizeNodePath(runnerPath);
if (stableRunner === runnerPath && platform !== 'win32') continue;
scriptToken = m[5];
@@ -694,10 +694,10 @@ function buildCodexHookWindowsShimIR(scriptAbsPath: string, absoluteRunnerToken:
} catch {
interpreter = absoluteRunnerToken;
}
const targetAbs = scriptAbsPath.replace(/\\/g, '/');
const targetAbs = shellCmdProjection.posixNormalize(scriptAbsPath);
const scriptQuoted = JSON.stringify(targetAbs);
const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/'));
const hookCommand = JSON.stringify(shellCmdProjection.posixNormalize(cmdPath));
const runnerQuoted = JSON.stringify(interpreter);
return {
invocation: { interpreter, target: scriptAbsPath },
@@ -726,7 +726,7 @@ function ensureCodexHooksJsonSessionStart(targetDir: string, opts: EnsureCodexSe
const hooksJsonPath = path.join(targetDir, 'hooks.json');
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
const scriptPath = shellCmdProjection.posixNormalize(path.resolve(targetDir, 'hooks', 'gsd-check-update.js'));
const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
let managedCommand: string | undefined;
@@ -757,7 +757,7 @@ function ensureCodexHooksJsonSessionStart(targetDir: string, opts: EnsureCodexSe
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
const commandWindows = platform === 'win32'
? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
? JSON.stringify(shellCmdProjection.posixNormalize(cmdShimPath))
: undefined;
return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
@@ -778,7 +778,7 @@ function ensureCodexHooksJsonEvent(targetDir: string, eventName: string, opts: E
const hooksJsonPath = path.join(targetDir, 'hooks.json');
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
const scriptPath = shellCmdProjection.posixNormalize(path.resolve(targetDir, 'hooks', 'gsd-context-monitor.js'));
let managedCommand: string | undefined;
if (platform === 'win32') {
@@ -846,7 +846,7 @@ function buildHookCommand(configDir: string, hookName: string, opts?: BuildHookC
});
return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
}
return JSON.stringify(configDir.replace(/\\/g, '/') + '/hooks/' + hookName);
return JSON.stringify(shellCmdProjection.posixNormalize(configDir) + '/hooks/' + hookName);
}
const nodeRunner = resolveNodeRunner();
@@ -866,7 +866,7 @@ function buildHookCommand(configDir: string, hookName: string, opts?: BuildHookC
});
}
const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
const hooksPath = shellCmdProjection.posixNormalize(configDir) + '/hooks/' + hookName;
return projectManagedHookCommand({
absoluteRunner: runner,
scriptPath: hooksPath,
@@ -1027,7 +1027,7 @@ function writeClineArtifacts(targetDir: string, isGlobalInstall: boolean): strin
function buildCursorHookEntry(scriptPath: string): Record<string, unknown> {
return {
type: 'command',
command: scriptPath.replace(/\\/g, '/'),
command: shellCmdProjection.posixNormalize(scriptPath),
[GSD_CURSOR_HOOK_MARKER]: true,
};
}