diff --git a/gsd-core/bin/lib/runtime-artifact-install-plan.cjs b/gsd-core/bin/lib/runtime-artifact-install-plan.cjs index 1f203c95f..c0d9c09a4 100644 --- a/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +++ b/gsd-core/bin/lib/runtime-artifact-install-plan.cjs @@ -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'; diff --git a/src/check-command-router.cts b/src/check-command-router.cts index 30f5dbf05..a180eb0cc 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -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 = ''; diff --git a/src/core-utils.cts b/src/core-utils.cts index ec6f18f5c..ea96ed380 100644 --- a/src/core-utils.cts +++ b/src/core-utils.cts @@ -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); } /** diff --git a/src/drift.cts b/src/drift.cts index 73e24d19d..e1a56837c 100644 --- a/src/drift.cts +++ b/src/drift.cts @@ -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(); 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(); 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]; diff --git a/src/init.cts b/src/init.cts index 0ec0da3f4..772d2fa71 100644 --- a/src/init.cts +++ b/src/init.cts @@ -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; 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 `\nRead these user-configured skills:\n${lines}\n`; } diff --git a/src/install-engine.cts b/src/install-engine.cts index 31a6f4357..4910a6938 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -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'); diff --git a/src/installer-migration-authoring.cts b/src/installer-migration-authoring.cts index a859363d7..ca47135a9 100644 --- a/src/installer-migration-authoring.cts +++ b/src/installer-migration-authoring.cts @@ -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; @@ -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}`); } diff --git a/src/installer-migrations.cts b/src/installer-migrations.cts index bbc82f355..2232401f2 100644 --- a/src/installer-migrations.cts +++ b/src/installer-migrations.cts @@ -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}`); } diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 79454dba8..76fadeabc 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -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'; diff --git a/src/runtime-artifact-install-plan.cts b/src/runtime-artifact-install-plan.cts index e2daf8d3e..e25a2c25f 100644 --- a/src/runtime-artifact-install-plan.cts +++ b/src/runtime-artifact-install-plan.cts @@ -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'; diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index b964f9018..28339f19c 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -30,6 +30,7 @@ import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs'); const conversionExports = runtimeArtifactConversion as Record & { 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'), }); } diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index a6955df00..a5bee923c 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -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 { return { type: 'command', - command: scriptPath.replace(/\\/g, '/'), + command: shellCmdProjection.posixNormalize(scriptPath), [GSD_CURSOR_HOOK_MARKER]: true, }; } diff --git a/src/schema-detect.cts b/src/schema-detect.cts index 12e770e77..ca590fa61 100644 --- a/src/schema-detect.cts +++ b/src/schema-detect.cts @@ -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(); 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); diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts index 14d5dbfc8..2f0d00cc6 100644 --- a/src/shell-command-projection.cts +++ b/src/shell-command-projection.cts @@ -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', diff --git a/src/surface.cts b/src/surface.cts index 63f536b52..436c123ec 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -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 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'; diff --git a/src/verify.cts b/src/verify.cts index a72621bd9..d3337151c 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -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(); diff --git a/src/worktree-safety.cts b/src/worktree-safety.cts index 5212d8ceb..47a37aafd 100644 --- a/src/worktree-safety.cts +++ b/src/worktree-safety.cts @@ -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:` (not `ls-files --error-unmatch`) so diff --git a/tests/shell-command-projection-path-sep.test.cjs b/tests/shell-command-projection-path-sep.test.cjs new file mode 100644 index 000000000..265fd7cf9 --- /dev/null +++ b/tests/shell-command-projection-path-sep.test.cjs @@ -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('\\')); + }), + ); + }); +});