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:
@@ -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';
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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];
|
||||
|
||||
12
src/init.cts
12
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<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>`;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
148
tests/shell-command-projection-path-sep.test.cjs
Normal file
148
tests/shell-command-projection-path-sep.test.cjs
Normal 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('\\'));
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user