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

@@ -67,9 +67,10 @@ function createRuntimeArtifactInstallPlan(args) {
// to kind.stage() for agents kind entries with a converter (convertedAgentsKind).
// NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop.
const os = _require('node:os');
const { posixNormalize } = _require('./shell-command-projection.cjs');
const homedirFn = homedir ?? (() => os.homedir());
const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
const homeDir = homedirFn().replace(/\\/g, '/');
const resolvedTarget = posixNormalize(path.resolve(layout.configDir));
const homeDir = posixNormalize(homedirFn());
const isGlobal = scope === 'global';
const isOpencode = layout.runtime === 'opencode';
const isWindowsHost = (platform ?? process.platform) === 'win32';

View File

@@ -38,7 +38,7 @@ const { evaluatePredicate } = gatePredicateEval;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import apiCoverageMod = require('./api-coverage.cjs');
const { detectApiIntegration, validateCoverageMatrix } = apiCoverageMod;
import { execTool } from './shell-command-projection.cjs';
import { execTool, posixNormalize } from './shell-command-projection.cjs';
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -1035,7 +1035,7 @@ function cmdApiCoverageVerifyPre(projectDir: string, args: string[], raw: boolea
// .planning/phases/ (or a milestone archive). The raw arg is never used as a
// path, so `..`, absolute paths, and arbitrary directories cannot reach a
// file read. Mirrors cmdVerifySchemaDrift's token-match approach.
let token = phaseArg.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '';
let token = posixNormalize(phaseArg).split('/').filter(Boolean).pop() || '';
// A token like ".." or "." carries no phase identity → unresolvable.
if (token === '.' || token === '..') token = '';

View File

@@ -24,12 +24,18 @@ const { comparePhaseNum } = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { findContextMdIn } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import shellCommandProjection = require('./shell-command-projection.cjs');
// ─── Path helpers ────────────────────────────────────────────────────────────
/** Normalize a relative path to always use forward slashes (cross-platform). */
/**
* Normalize a relative path to always use forward slashes (cross-platform).
* Delegates to the single separator seam in shell-command-projection so there is
* exactly one implementation of native→POSIX conversion across the codebase.
*/
function toPosixPath(p: string): string {
return p.split(path.sep).join('/');
return shellCommandProjection.toPosixPath(p);
}
/**

View File

@@ -35,7 +35,7 @@
'use strict';
import fs from 'node:fs';
import { platformWriteSync } from './shell-command-projection.cjs';
import { platformWriteSync, posixNormalize } from './shell-command-projection.cjs';
import { formatGsdSlash } from './runtime-slash.cjs';
// ─── Constants ───────────────────────────────────────────────────────────────
@@ -79,7 +79,7 @@ type DriftCategory = 'barrel' | 'migration' | 'route' | 'new_dir';
*/
function classifyFile(file: unknown): DriftCategory | null {
if (typeof file !== 'string' || !file) return null;
const norm = file.replace(/\\/g, '/');
const norm = posixNormalize(file);
if (MIGRATION_RES.some((r) => r.test(norm))) return 'migration';
if (ROUTE_RES.some((r) => r.test(norm))) return 'route';
if (BARREL_RE.test(norm)) return 'barrel';
@@ -95,7 +95,7 @@ function classifyFile(file: unknown): DriftCategory | null {
* check `structureMd.includes('src/lib')` holds.
*/
function isPathMapped(file: string, structureMd: string): boolean {
const norm = file.replace(/\\/g, '/');
const norm = posixNormalize(file);
const parts = norm.split('/');
// Check prefixes from longest to shortest; any hit means "mapped".
for (let i = parts.length - 1; i >= 1; i--) {
@@ -191,7 +191,7 @@ function detectDrift(input: unknown): DetectDriftResult | SkippedResult {
const seen = new Map<string, string>();
for (const rawFile of added) {
const file = rawFile.replace(/\\/g, '/');
const file = posixNormalize(rawFile);
const specific = classifyFile(file);
let category: string | null = specific;
if (!category) {
@@ -318,7 +318,7 @@ function chooseAffectedPaths(paths: string[]): string[] {
const out = new Set<string>();
for (const raw of paths || []) {
if (typeof raw !== 'string' || !raw) continue;
const file = raw.replace(/\\/g, '/');
const file = posixNormalize(raw);
const parts = file.split('/');
if (parts.length === 0) continue;
const top = parts[0];

View File

@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execGit, platformWriteSync, platformReadSync } from './shell-command-projection.cjs';
import { execGit, platformWriteSync, platformReadSync, toNativePath, posixNormalize } from './shell-command-projection.cjs';
import { realClock } from './clock.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
import io = require('./io.cjs');
@@ -282,7 +282,7 @@ function getInitGitState(cwd: string): GitState {
}
resolved = path.resolve(resolved);
if (process.platform === 'win32') {
return resolved.replace(/\//g, '\\').toLowerCase();
return toNativePath(resolved).toLowerCase();
}
return resolved;
};
@@ -293,7 +293,7 @@ function getInitGitState(cwd: string): GitState {
try {
const prefixResult = execGit(['rev-parse', '--show-prefix'], { cwd, timeout: 5000 }) as unknown as Record<string, unknown>;
if (prefixResult['exitCode'] === 0) {
const prefix = (typeof prefixResult['stdout'] === 'string' ? prefixResult['stdout'] : '').trim().replace(/\\/g, '/');
const prefix = posixNormalize((typeof prefixResult['stdout'] === 'string' ? prefixResult['stdout'] : '').trim());
inNestedSubdir = prefix.length > 0 && prefix !== '.' && prefix !== './';
resolvedByGitPrefix = true;
}
@@ -309,7 +309,7 @@ function getInitGitState(cwd: string): GitState {
inNestedSubdir = false;
} else {
const rel = path.relative(rootNorm, cwdNorm);
const relNorm = process.platform === 'win32' ? rel.replace(/\//g, '\\') : rel;
const relNorm = toNativePath(rel);
inNestedSubdir =
relNorm !== '' &&
relNorm !== '.' &&
@@ -323,7 +323,7 @@ function getInitGitState(cwd: string): GitState {
}
if (inNestedSubdir && typeof worktreeRoot === 'string') {
const toComparableRaw = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase();
const toComparableRaw = (p: string) => posixNormalize(p).replace(/\/+$/g, '').toLowerCase();
if (toComparableRaw(worktreeRoot) === toComparableRaw(String(cwd))) {
inNestedSubdir = false;
}
@@ -2226,7 +2226,7 @@ function buildAgentSkillsBlock(
if (entry.kind === 'directive') {
return `- Load the \`${entry.name}\` skill via the Skill tool before proceeding (plugin-provided).`;
}
return `- @${String(entry.ref).replace(/\\/g, '/')}`;
return `- @${posixNormalize(String(entry.ref))}`;
}).join('\n');
return `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`;
}

View File

@@ -27,6 +27,7 @@ import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs');
import runtimeNamePolicy = require('./runtime-name-policy.cjs');
import installProfiles = require('./install-profiles.cjs');
import { posixNormalize } from './shell-command-projection.cjs';
const { processAttribution } = runtimeArtifactConversion;
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
@@ -970,8 +971,8 @@ function installOpencodeFamilyArtifacts(
isGlobal,
isOpencode: behaviors.skipHomePrefixSubstitution === true,
isWindowsHost: process.platform === 'win32',
resolvedTarget: path.resolve(configDir).replace(/\\/g, '/'),
homeDir: os.homedir().replace(/\\/g, '/'),
resolvedTarget: posixNormalize(path.resolve(configDir)),
homeDir: posixNormalize(os.homedir()),
});
const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, 'command');

View File

@@ -8,6 +8,7 @@
*/
import path from 'node:path';
import { posixNormalize } from './shell-command-projection.cjs';
/** An unvalidated migration record supplied by the caller. */
export type MigrationRecord = Record<string, unknown>;
@@ -64,7 +65,7 @@ function requireActionEvidence(action: MigrationAction, field: string, migration
function validateSafeRelPath(relPath: string, migration: MigrationRecord, actionType: string): void {
const source = actionSource(migration, { relPath });
const normalized = relPath.replace(/\\/g, '/');
const normalized = posixNormalize(relPath);
if (path.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
throw new Error(`migration action ${actionType} relPath must stay inside configDir: ${source}`);
}

View File

@@ -16,7 +16,7 @@ import {
type MigrationRecord,
type MigrationAction,
} from './installer-migration-authoring.cjs';
import { platformWriteSync, retryRenameSync } from './shell-command-projection.cjs';
import { platformWriteSync, retryRenameSync, posixNormalize } from './shell-command-projection.cjs';
import { realClock, type Clock } from './clock.cjs';
const MANIFEST_NAME = 'gsd-file-manifest.json';
@@ -139,7 +139,7 @@ function normalizeRelPath(relPath: string): string {
if (typeof relPath !== 'string' || relPath.trim() === '') {
throw new Error('migration action relPath must be a non-empty string');
}
const normalized = relPath.replace(/\\/g, '/');
const normalized = posixNormalize(relPath);
if (path.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
throw new Error(`migration action relPath must stay inside configDir: ${relPath}`);
}

View File

@@ -24,6 +24,7 @@ const { readGsdCommandNames, transformContentToHyphen } = commandRoster;
import runtimeNamePolicy = require('./runtime-name-policy.cjs');
const { getDirName } = runtimeNamePolicy;
import capabilityRegistry = require('./capability-registry.cjs');
import { posixNormalize } from './shell-command-projection.cjs';
// #1383: resolve GSD's version WITHOUT a top-level
// `require('../../../package.json')`. That require ran at module load on every
@@ -2349,8 +2350,8 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
// Without this, path.join on Windows produces a backslash prefix that
// leaks into markdown content and breaks cross-platform substring checks.
// See DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT in CONTEXT.md.
const posixTarget = String(resolvedTarget).replace(/\\/g, '/');
const posixHome = homeDir ? String(homeDir).replace(/\\/g, '/') : homeDir;
const posixTarget = posixNormalize(String(resolvedTarget));
const posixHome = homeDir ? posixNormalize(String(homeDir)) : homeDir;
if (isGlobal && posixTarget.startsWith(posixHome) && !isOpencode) {
return '$HOME' + posixTarget.slice(posixHome.length) + '/';
}
@@ -2696,8 +2697,8 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
} = opts;
if (!fs.existsSync(stagedDir)) return;
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
const homeDir = homedir().replace(/\\/g, '/');
const resolvedTarget = posixNormalize(path.resolve(configDir));
const homeDir = posixNormalize(homedir());
const isGlobal = scope === 'global';
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
const isWindowsHost = platform === 'win32';
@@ -2734,8 +2735,8 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
} = opts;
if (!fs.existsSync(stagedDir)) return stagedDir;
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
const homeDir = homedir().replace(/\\/g, '/');
const resolvedTarget = posixNormalize(path.resolve(configDir));
const homeDir = posixNormalize(homedir());
const isGlobal = scope === 'global';
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
const isWindowsHost = platform === 'win32';

View File

@@ -177,9 +177,10 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
// to kind.stage() for agents kind entries with a converter (convertedAgentsKind).
// NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop.
const os = _require('node:os') as typeof import('node:os');
const { posixNormalize } = _require('./shell-command-projection.cjs') as { posixNormalize: (p: string) => string };
const homedirFn: () => string = homedir ?? (() => os.homedir());
const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
const homeDir = homedirFn().replace(/\\/g, '/');
const resolvedTarget = posixNormalize(path.resolve(layout.configDir));
const homeDir = posixNormalize(homedirFn());
const isGlobal = scope === 'global';
const isOpencode = layout.runtime === 'opencode';
const isWindowsHost = (platform ?? process.platform) === 'win32';

View File

@@ -30,6 +30,7 @@ import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs');
const conversionExports = runtimeArtifactConversion as Record<string, unknown> & {
readGsdCommandNames?: () => string[];
};
import { posixNormalize } from './shell-command-projection.cjs';
// In .cts (CommonJS output) files, `require` is available as a global.
const _require: NodeRequire = require;
@@ -279,7 +280,7 @@ function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string):
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const agentPath = path.join(stagedAgents, entry.name);
subagents.push({
path: path.join('agents', entry.name).replace(/\\/g, '/'),
path: posixNormalize(path.join('agents', entry.name)),
content: fs.readFileSync(agentPath, 'utf8'),
});
}

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,
};
}

View File

@@ -8,6 +8,8 @@
* This module does not read the filesystem directly.
*/
import { posixNormalize } from './shell-command-projection.cjs';
// ─── ORM Patterns ───────────────────────────────────────────────────────────
export interface SchemaPattern {
@@ -82,7 +84,7 @@ export function detectSchemaFiles(files: string[]): DetectSchemaFilesResult {
const matches: string[] = [];
const orms = new Set<string>();
for (const rawFile of files) {
const file = rawFile.replace(/\\/g, '/');
const file = posixNormalize(rawFile);
for (const { pattern, orm } of SCHEMA_PATTERNS) {
if (pattern.test(file)) {
matches.push(rawFile);

View File

@@ -18,6 +18,43 @@ import fs from 'node:fs';
// at load time and become un-mockable.
import childProcess from 'node:child_process';
/**
* Convert a filesystem path to POSIX form (forward slashes) by translating the
* platform-native separator. Single seam for native→POSIX conversion.
*
* Prefer this over `p.replace(/\\/g, '/')`: the regex form hardcodes both
* separators and corrupts POSIX paths containing a literal backslash (a legal
* filename character). Splitting on `path.sep` only ever touches real
* separators — a no-op on POSIX, `\`→`/` on Windows.
*/
export function toPosixPath(p: string): string {
return p.split(path.sep).join(path.posix.sep);
}
/**
* Convert a filesystem path to the platform-native separator form. No-op on
* POSIX; `/`→`\` on Windows. Prefer this over a
* `process.platform === 'win32' ? p.replace(/\//g, '\\') : p` ternary.
*/
export function toNativePath(p: string): string {
return p.split(path.posix.sep).join(path.sep);
}
/**
* Normalize ALL backslashes to forward slashes, unconditionally and independent
* of the running OS. Use this when emitting a path into a POSIX/bash target
* (which may differ from the running platform — e.g. generating a Windows config
* on a Linux runner) or when parsing input whose separators are unpredictable.
*
* Contrast `toPosixPath`, which is running-OS-relative (splits on `path.sep`) and
* is for *this machine's* filesystem paths. Do NOT use `toPosixPath` for
* target-platform projection — on a Linux runner it would not convert a
* Windows-target path's backslashes.
*/
export function posixNormalize(p: string): string {
return p.replace(/\\/g, '/');
}
/**
* Return true when a managed hook command must be prefixed with PowerShell's
* call operator so a quoted executable token is invokable by the target
@@ -95,7 +132,7 @@ export function buildLocalShellHookCommand({ localPrefix, hookFile, bashRunner,
export function formatManagedHookScriptToken(scriptPath: string, opts: { platform?: string } = {}): string | null {
const platform = opts.platform || process.platform;
if (platform !== 'win32') return null;
return JSON.stringify(scriptPath.replace(/\\/g, '/'));
return JSON.stringify(posixNormalize(scriptPath));
}
export function projectLocalHookPrefix({ runtime: _runtime = 'claude', dirName, hookPathStyle }: { runtime?: string; dirName?: string | null; hookPathStyle?: string | null }): string | undefined | null {
@@ -114,8 +151,8 @@ export function projectLocalHookPrefix({ runtime: _runtime = 'claude', dirName,
}
export function projectPortableHookBaseDir({ configDir, homeDir }: { configDir?: string | null; homeDir?: string | null }): string {
const normalizedConfigDir = String(configDir || '').replace(/\\/g, '/');
const normalizedHome = String(homeDir || '').replace(/\\/g, '/');
const normalizedConfigDir = posixNormalize(String(configDir || ''));
const normalizedHome = posixNormalize(String(homeDir || ''));
if (!normalizedConfigDir || !normalizedHome) return normalizedConfigDir;
return normalizedConfigDir.startsWith(normalizedHome)
? '$HOME' + normalizedConfigDir.slice(normalizedHome.length)
@@ -145,7 +182,7 @@ export function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime
platform?: string;
}): string | null {
if (!absoluteRunner || !scriptPath) return null;
const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
return projectShellCommandText({
runnerToken: absoluteRunner,
argTokens: [JSON.stringify(normalizedScriptPath)],
@@ -245,15 +282,15 @@ export function isManagedHookCommand(commandText: unknown, opts: { surface?: str
if (Array.isArray(opts.args) && opts.args.length > 0) {
for (const arg of opts.args) {
if (typeof arg !== 'string') continue;
const argBasename = arg.replace(/\\/g, '/').split('/').pop() || '';
const argBasename = posixNormalize(arg).split('/').pop() || '';
if (isManagedHookBasename(argBasename, { surface })) return true;
}
}
const normalizedCommand = commandText.replace(/\\/g, '/');
const normalizedCommand = posixNormalize(commandText);
if (typeof opts.configDir === 'string' && opts.configDir.length > 0) {
const normalizedHooksDir = `${path.join(opts.configDir, 'hooks').replace(/\\/g, '/')}/`;
const normalizedHooksDir = `${posixNormalize(path.join(opts.configDir, 'hooks'))}/`;
if (!normalizedCommand.includes(normalizedHooksDir)) return false;
}
@@ -295,7 +332,7 @@ export function projectLegacySettingsHookCommand({
platform?: string;
}): string | null {
if (!absoluteRunner || !scriptPath) return null;
const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
// #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored
// quoted prefix (local installs) is already a valid shell token — only the
// variable is quoted, the rest is bare. JSON.stringify-ing it on Windows
@@ -378,7 +415,7 @@ export function projectPathActionProjection({
let shellActions: ShellAction[];
if (isWin32) {
const psTargetDir = escapePowerShellSingleQuoted(targetDir);
const bashTargetDir = escapeSingleQuotedShellLiteral(String(targetDir).replace(/\\/g, '/'));
const bashTargetDir = escapeSingleQuotedShellLiteral(posixNormalize(String(targetDir)));
shellActions = [
{
label: 'PowerShell',

View File

@@ -31,7 +31,7 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { platformWriteSync } from './shell-command-projection.cjs';
import { platformWriteSync, posixNormalize } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installProfiles = require('./install-profiles.cjs');
const {
@@ -354,8 +354,8 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
// surface-path agents lack path-prefix rewrites and Co-Authored-By trailers,
// diverging from a fresh install.
const _homedirFn: () => string = opts?.homedir ?? (() => os.homedir());
const _resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
const _homeDir = _homedirFn().replace(/\\/g, '/');
const _resolvedTarget = posixNormalize(path.resolve(layout.configDir));
const _homeDir = posixNormalize(_homedirFn());
const _isGlobal = (layout.scope ?? 'global') === 'global';
const _isOpencode = layout.runtime === 'opencode';
const _isWindowsHost = (opts?.platform ?? process.platform) === 'win32';

View File

@@ -22,7 +22,7 @@ import stateMod = require('./state.cjs');
import modelProfilesMod = require('./model-profiles.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
import planScanMod = require('./plan-scan.cjs');
import { execGit, platformReadSync as safeReadFile, platformWriteSync } from './shell-command-projection.cjs';
import { execGit, platformReadSync as safeReadFile, platformWriteSync, posixNormalize } from './shell-command-projection.cjs';
import { PACKAGE_NAME } from './package-identity.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { detectSchemaFiles, checkSchemaDrift } from './schema-detect.cjs';
@@ -1157,7 +1157,7 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void {
for (const dir of dirs) {
const phasePath = path.join(phaseRoot, dir);
const phaseLabel = path.relative(planBase, phasePath).replace(/\\/g, '/');
const phaseLabel = posixNormalize(path.relative(planBase, phasePath));
const phaseFiles = fs.readdirSync(phasePath);
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md')).sort();

View File

@@ -10,7 +10,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { execGit as execGitSeam } from './shell-command-projection.cjs';
import { execGit as execGitSeam, posixNormalize } from './shell-command-projection.cjs';
// Default timeout for worktree-related git subprocess calls.
// 10 s is generous enough for normal git operations on large repos while still
@@ -588,7 +588,7 @@ function rescueSummaryArtifacts(
// relPath is the path relative to the worktree root (e.g. ".planning/q1-SUMMARY.md")
// Normalize to forward slashes so the Set comparison against `git status --porcelain`
// output works on Windows too (git always emits forward slashes in porcelain output).
const relPath = absPath.slice(worktreePath.length).replace(/^[/\\]/, '').replace(/\\/g, '/');
const relPath = posixNormalize(absPath.slice(worktreePath.length).replace(/^[/\\]/, ''));
// #706: skip rescue when the SUMMARY is already committed on the branch.
// Use `git cat-file -e HEAD:<relPath>` (not `ls-files --error-unmatch`) so

View File

@@ -0,0 +1,148 @@
'use strict';
// Focused unit tests for the path-separator helpers on shell-command-projection
// (toPosixPath / toNativePath / posixNormalize). These are drop-in replacements
// for open-coded `.replace(/\\/g, '/')` and
// `process.platform === 'win32' ? x.replace(/\//g, '\\') : x`
// call sites — see shell-command-projection.cts for the platform-relative contract.
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fc = require('fast-check');
const { toPosixPath, toNativePath, posixNormalize } = require('../gsd-core/bin/lib/shell-command-projection.cjs');
describe('toPosixPath', () => {
test('plain relative path: already-POSIX segments pass through unchanged', () => {
assert.equal(toPosixPath('a/b/c'), 'a/b/c');
});
test('plain relative path built with the native separator normalizes to POSIX segments', () => {
const native = path.join('alpha', 'beta', 'gamma');
assert.equal(toPosixPath(native), ['alpha', 'beta', 'gamma'].join('/'));
});
test('absolute path: segment list matches the native path split on path.sep', () => {
const abs = path.resolve('alpha', 'beta');
assert.equal(toPosixPath(abs), abs.split(path.sep).join('/'));
});
test('idempotency: applying twice equals applying once', () => {
const native = path.join('alpha', 'beta', 'gamma');
const once = toPosixPath(native);
const twice = toPosixPath(once);
assert.equal(once, twice);
});
test('empty string in, empty string out', () => {
assert.equal(toPosixPath(''), '');
});
test('does not corrupt a POSIX-style input containing a literal backslash-free path', () => {
// A string that already uses only '/' as its separator and contains no
// occurrence of path.sep-as-backslash must survive unchanged regardless of
// the host platform's path.sep (no-op on POSIX; nothing to split on Windows
// either, since there is no backslash present).
const alreadyPosix = 'already/posix/style/path';
assert.equal(toPosixPath(alreadyPosix), alreadyPosix);
});
test('property: toPosixPath output never contains path.sep when path.sep differs from "/"', () => {
fc.assert(
fc.property(fc.array(fc.stringMatching(/^[a-zA-Z0-9_-]+$/), { minLength: 1, maxLength: 5 }), (segments) => {
const native = segments.join(path.sep);
const posix = toPosixPath(native);
if (path.sep !== '/') {
assert.ok(!posix.includes(path.sep));
}
assert.equal(posix, segments.join('/'));
}),
);
});
});
describe('toNativePath', () => {
test('plain relative path: already-native segments pass through unchanged', () => {
const native = path.join('alpha', 'beta', 'gamma');
assert.equal(toNativePath(native), native);
});
test('POSIX-style relative path converts to native segments', () => {
const posix = 'alpha/beta/gamma';
assert.equal(toNativePath(posix), ['alpha', 'beta', 'gamma'].join(path.sep));
});
test('absolute path: round-trips back to the original POSIX form via split/join', () => {
const posixAbs = path.posix.resolve('/', 'alpha', 'beta');
const native = toNativePath(posixAbs);
assert.equal(native.split(path.sep).join('/'), posixAbs);
});
test('idempotency: applying twice equals applying once', () => {
const posix = 'alpha/beta/gamma';
const once = toNativePath(posix);
const twice = toNativePath(once);
assert.equal(once, twice);
});
test('empty string in, empty string out', () => {
assert.equal(toNativePath(''), '');
});
test('does not corrupt an already-native-style input containing no POSIX separator', () => {
// A string that already uses only path.sep as its separator (and contains
// no '/' occurrence) must survive unchanged: no-op on POSIX (path.sep is
// '/', so it IS the separator being normalized to); on Windows there is no
// '/' present to split on, so the single-element join reproduces the input.
const alreadyNative = ['already', 'native', 'style', 'path'].join(path.sep);
assert.equal(toNativePath(alreadyNative), alreadyNative);
});
test('property: toPosixPath and toNativePath round-trip a POSIX-style path through both conversions', () => {
fc.assert(
fc.property(fc.array(fc.stringMatching(/^[a-zA-Z0-9_-]+$/), { minLength: 1, maxLength: 5 }), (segments) => {
const posix = segments.join('/');
assert.equal(toPosixPath(toNativePath(posix)), posix);
}),
);
});
});
describe('posixNormalize', () => {
test('unconditional conversion: literal backslashes always become forward slashes, regardless of host platform', () => {
// Unlike toPosixPath (which splits on path.sep — a no-op on POSIX hosts),
// posixNormalize must convert backslashes even when running on a POSIX
// host, because the input represents a TARGET platform's path, not this
// machine's filesystem path.
assert.equal(posixNormalize('alpha\\beta\\gamma'), 'alpha/beta/gamma');
});
test('mixed separators: only backslashes are converted, forward slashes are left alone', () => {
assert.equal(posixNormalize('alpha\\beta/gamma\\delta'), 'alpha/beta/gamma/delta');
});
test('already-POSIX input with no backslash passes through unchanged', () => {
const alreadyPosix = 'already/posix/style/path';
assert.equal(posixNormalize(alreadyPosix), alreadyPosix);
});
test('idempotency: applying twice equals applying once', () => {
const once = posixNormalize('alpha\\beta\\gamma');
const twice = posixNormalize(once);
assert.equal(once, twice);
});
test('empty string in, empty string out', () => {
assert.equal(posixNormalize(''), '');
});
test('property: posixNormalize output never contains a backslash', () => {
fc.assert(
fc.property(fc.string(), (input) => {
const normalized = posixNormalize(input);
assert.ok(!normalized.includes('\\'));
}),
);
});
});