Skip to content

Commit ae78e6a

Browse files
frankieyanclaude
andauthored
fix: handle shell-escaped file paths (#29)
* fix: handle shell-escaped file paths File paths containing shell metacharacters (like $) get backslash-escaped by git/CI tools, but normalizeFilePaths() didn't unescape them, causing validateFilesExist() to fail with "File not found" errors. This is common with React Router/Remix dynamic route files like users.$id.tsx which become users.\$id.tsx when passed through shell variables in CI. Added filePath.replace(/\\(.)/g, '$1') in normalizeFilePaths() to strip backslash escapes before checking if files exist. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: preserve Windows paths by only unescaping Unix-style paths Only apply shell escape unescaping to paths containing forward slashes. Paths using only backslashes (Windows native paths) are preserved to avoid corrupting path separators. This handles: - Git Bash on Windows (uses forward slashes with backslash escaping) - Windows CI (uses forward slashes from git) - Native Windows cmd/PowerShell (uses backslashes, no shell escaping) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: only unescape non-alphanumeric characters Restrict the unescape regex to /\\([^a-zA-Z0-9])/g to preserve mixed paths like src/utils\file.ts which are valid on Windows. Shells typically escape special characters ($, space, !), not letters or digits. Windows separators are usually followed by alphanumeric directory names, so this change prevents corruption while still unescaping shell metacharacters. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a29b654 commit ae78e6a

File tree

4 files changed

+129
-11
lines changed

4 files changed

+129
-11
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* A dynamic route component (e.g., React Router or Remix)
3+
* The $param in the filename indicates a URL parameter
4+
*/
5+
export function RouteParam({ id }: { id: string }) {
6+
return <div>Route param: {id}</div>
7+
}

src/index.integration.test.mts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe('CLI', () => {
3636
it('runs check on all files when no flag provided', () => {
3737
const output = runCLI()
3838

39-
expect(output).toContain('🔍 Checking all 4 source files for React Compiler errors…')
39+
expect(output).toContain('🔍 Checking all 5 source files for React Compiler errors…')
4040
expect(output).toContain('⚠️ Found 4 React Compiler issues across 2 files')
4141
expect(() => JSON.parse(readFileSync(recordsPath, 'utf8'))).toThrow()
4242
})
@@ -60,11 +60,19 @@ describe('CLI', () => {
6060
expect(output).toContain('File not found: src/nonexistent-file.tsx')
6161
})
6262

63+
it('handles shell-escaped file paths with $ character', () => {
64+
// Simulate what CI tools do when passing filenames with $ through shell variables
65+
const output = runCLI(['--check-files', 'src/route.\\$param.tsx'])
66+
67+
expect(output).toContain('🔍 Checking 1 files for React Compiler errors…')
68+
expect(output).not.toContain('File not found')
69+
})
70+
6371
it('accepts --overwrite flag', () => {
6472
const output = runCLI(['--overwrite'])
6573

6674
expect(output).toContain(
67-
'🔍 Checking all 4 source files for React Compiler errors and recreating records…',
75+
'🔍 Checking all 5 source files for React Compiler errors and recreating records…',
6876
)
6977
expect(output).toContain(
7078
'✅ Records file completed. Found 4 total React Compiler issues across 2 files',
@@ -136,8 +144,8 @@ describe('CLI', () => {
136144

137145
const output = runCLI()
138146

139-
// Should only find .tsx files (2 instead of 4)
140-
expect(output).toContain('🔍 Checking all 2 source files for React Compiler errors…')
147+
// Should only find .tsx files (3 instead of 5)
148+
expect(output).toContain('🔍 Checking all 3 source files for React Compiler errors…')
141149
})
142150

143151
it('uses custom recordsFile from config', () => {

src/source-files.mts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,30 @@ function getGitPrefix() {
2929
}
3030

3131
/**
32-
* Normalizes file paths by converting absolute paths to cwd-relative
33-
* and stripping the git prefix when present.
32+
* Normalizes file paths by unescaping shell-escaped characters, converting
33+
* absolute paths to cwd-relative, and stripping the git prefix when present.
3434
*
3535
* When running from a package subdirectory in a monorepo, file paths from git
3636
* (e.g., lint-staged) are relative to the repo root. This function converts
3737
* those paths to cwd-relative paths.
3838
*
39+
* Shell escapes (e.g., \$ → $) are only applied to paths containing forward
40+
* slashes, which indicates Unix-style paths. Paths using only backslashes
41+
* (Windows native paths) are preserved to avoid corrupting path separators.
42+
*
3943
* @example
4044
* // When cwd is apps/frontend/ (prefix is "apps/frontend/")
4145
* normalizeFilePaths(["apps/frontend/src/file.tsx"]) // => ["src/file.tsx"]
4246
*
4347
* // Absolute paths are converted to cwd-relative
4448
* normalizeFilePaths(["/Users/frankie/project/src/file.tsx"]) // => ["src/file.tsx"]
4549
*
50+
* // Shell-escaped characters are unescaped (Unix-style paths)
51+
* normalizeFilePaths(["src/route.\\$id.tsx"]) // => ["src/route.$id.tsx"]
52+
*
53+
* // Windows-style paths are preserved (no forward slashes)
54+
* normalizeFilePaths(["src\\utils\\file.ts"]) // => ["src\\utils\\file.ts"]
55+
*
4656
* // Paths that don't start with prefix are unchanged
4757
* normalizeFilePaths(["src/file.tsx"]) // => ["src/file.tsx"]
4858
*/
@@ -51,17 +61,27 @@ function normalizeFilePaths(filePaths: string[]) {
5161
const cwd = process.cwd()
5262

5363
return filePaths.map((filePath) => {
64+
// Only unescape shell-escaped characters for Unix-style paths (containing /).
65+
// Windows paths use \ as separator, so we preserve them to avoid corruption.
66+
// This handles Git Bash on Windows and Windows CI which use forward slashes.
67+
// We only unescape non-alphanumeric characters since shells don't escape letters/digits,
68+
// but Windows separators are typically followed by alphanumeric directory names.
69+
// This preserves mixed paths like src/utils\file.ts (valid on Windows).
70+
const normalized = filePath.includes('/')
71+
? filePath.replace(/\\([^a-zA-Z0-9])/g, '$1')
72+
: filePath
73+
5474
// Handle absolute paths by converting to cwd-relative
55-
if (filePath.startsWith('/')) {
56-
return relative(cwd, filePath)
75+
if (normalized.startsWith('/')) {
76+
return relative(cwd, normalized)
5777
}
5878

5979
// Handle monorepo prefix stripping
60-
if (prefix && filePath.startsWith(prefix)) {
61-
return filePath.slice(prefix.length)
80+
if (prefix && normalized.startsWith(prefix)) {
81+
return normalized.slice(prefix.length)
6282
}
6383

64-
return filePath
84+
return normalized
6585
})
6686
}
6787

src/source-files.test.mts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,89 @@ describe('normalizeFilePaths', () => {
127127

128128
expect(result).toEqual(['src/App.tsx', 'src/local.ts'])
129129
})
130+
131+
it('unescapes shell-escaped $ characters', () => {
132+
vi.mocked(execSync).mockReturnValue('\n')
133+
134+
const result = normalizeFilePaths(['src/route.\\$id.tsx', 'src/draft.\\$slug.tsx'])
135+
136+
expect(result).toEqual(['src/route.$id.tsx', 'src/draft.$slug.tsx'])
137+
})
138+
139+
it('unescapes shell-escaped spaces', () => {
140+
vi.mocked(execSync).mockReturnValue('\n')
141+
142+
const result = normalizeFilePaths(['src/file\\ with\\ spaces.tsx'])
143+
144+
expect(result).toEqual(['src/file with spaces.tsx'])
145+
})
146+
147+
it('unescapes multiple different escape characters', () => {
148+
vi.mocked(execSync).mockReturnValue('\n')
149+
150+
const result = normalizeFilePaths(['src/route.\\$id\\ (copy).tsx'])
151+
152+
expect(result).toEqual(['src/route.$id (copy).tsx'])
153+
})
154+
155+
it('handles paths with no escapes unchanged', () => {
156+
vi.mocked(execSync).mockReturnValue('\n')
157+
158+
const result = normalizeFilePaths(['src/normal-file.tsx'])
159+
160+
expect(result).toEqual(['src/normal-file.tsx'])
161+
})
162+
163+
it('unescapes before stripping prefix', () => {
164+
vi.mocked(execSync).mockReturnValue('apps/frontend/\n')
165+
166+
const result = normalizeFilePaths(['apps/frontend/src/route.\\$id.tsx'])
167+
168+
expect(result).toEqual(['src/route.$id.tsx'])
169+
})
170+
171+
it('preserves Windows-style paths with backslash separators', () => {
172+
vi.mocked(execSync).mockReturnValue('\n')
173+
174+
const result = normalizeFilePaths(['src\\utils\\file.ts', 'src\\components\\Button.tsx'])
175+
176+
expect(result).toEqual(['src\\utils\\file.ts', 'src\\components\\Button.tsx'])
177+
})
178+
179+
it('preserves Windows paths with $ in filename', () => {
180+
vi.mocked(execSync).mockReturnValue('\n')
181+
182+
const result = normalizeFilePaths(['src\\$files.ts', 'src\\routes\\$id.tsx'])
183+
184+
expect(result).toEqual(['src\\$files.ts', 'src\\routes\\$id.tsx'])
185+
})
186+
187+
it('does not unescape bare filenames without forward slashes', () => {
188+
vi.mocked(execSync).mockReturnValue('\n')
189+
190+
// Bare filenames without / are ambiguous, so we preserve them
191+
const result = normalizeFilePaths(['\\$id.tsx', 'file.tsx'])
192+
193+
expect(result).toEqual(['\\$id.tsx', 'file.tsx'])
194+
})
195+
196+
it('preserves mixed paths with both forward and back slashes', () => {
197+
vi.mocked(execSync).mockReturnValue('\n')
198+
199+
// Mixed paths are valid on Windows - backslash separators should be preserved
200+
const result = normalizeFilePaths(['src/utils\\file.ts', 'src/components\\Button.tsx'])
201+
202+
expect(result).toEqual(['src/utils\\file.ts', 'src/components\\Button.tsx'])
203+
})
204+
205+
it('unescapes special chars in mixed paths while preserving backslash separators', () => {
206+
vi.mocked(execSync).mockReturnValue('\n')
207+
208+
// Should unescape \$ (non-alphanumeric) but preserve \s in utils\subdir (alphanumeric)
209+
const result = normalizeFilePaths(['src/utils\\subdir/route.\\$id.tsx'])
210+
211+
expect(result).toEqual(['src/utils\\subdir/route.$id.tsx'])
212+
})
130213
})
131214

132215
describe('filterByGlob', () => {

0 commit comments

Comments
 (0)