diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index e0ae61224f..9677a40ae9 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -6,6 +6,7 @@ import { // Pure utility functions formatSize, normalizeLineEndings, + stripTrailingWhitespace, createUnifiedDiff, // Security & validation functions validatePath, @@ -308,6 +309,55 @@ describe('Lib Functions', () => { expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file.txt', 'new content', { encoding: "utf-8", flag: 'wx' }); }); + + it('strips trailing whitespace from each line before writing', async () => { + mockFs.writeFile.mockResolvedValueOnce(undefined); + + await writeFileContent('/test/file.txt', 'line1 \nline2\t\nline3'); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + '/test/file.txt', + 'line1\nline2\nline3', + { encoding: "utf-8", flag: 'wx' } + ); + }); + + it('strips trailing whitespace on the EEXIST/atomic-rename fallback path too', async () => { + const eexistError = Object.assign(new Error('exists'), { code: 'EEXIST' }); + mockFs.writeFile.mockRejectedValueOnce(eexistError); + mockFs.writeFile.mockResolvedValueOnce(undefined); + mockFs.rename.mockResolvedValueOnce(undefined); + + await writeFileContent('/test/file.txt', 'line1 \nline2 '); + + expect(mockFs.writeFile).toHaveBeenLastCalledWith( + expect.stringContaining('/test/file.txt.'), + 'line1\nline2', + 'utf-8' + ); + }); + }); + + describe('stripTrailingWhitespace', () => { + it('removes trailing spaces and tabs from each line', () => { + const input = 'line1 \nline2\t\t\nline3'; + expect(stripTrailingWhitespace(input)).toBe('line1\nline2\nline3'); + }); + + it('leaves leading whitespace untouched', () => { + const input = ' indented line \n\tmixed indent\t'; + expect(stripTrailingWhitespace(input)).toBe(' indented line\n\tmixed indent'); + }); + + it('does not change content with no trailing whitespace', () => { + const input = 'line1\nline2\nline3'; + expect(stripTrailingWhitespace(input)).toBe(input); + }); + + it('preserves blank lines and trailing newline', () => { + const input = 'line1 \n\nline3 \n'; + expect(stripTrailingWhitespace(input)).toBe('line1\n\nline3\n'); + }); }); }); @@ -510,6 +560,60 @@ describe('Lib Functions', () => { ); }); + it('strips trailing whitespace from the new text of an edit', async () => { + const edits = [ + { oldText: 'line2', newText: 'modified line2 \t' } + ]; + + mockFs.rename.mockResolvedValueOnce(undefined); + + await applyFileEdits('/test/file.txt', edits, false); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), + 'line1\nmodified line2\nline3\n', + 'utf-8' + ); + }); + + it('does not touch trailing whitespace on lines outside the edit', async () => { + // line3 has trailing spaces but is never targeted by an edit + mockFs.readFile.mockResolvedValue('line1\nline2\nline3 \n'); + + const edits = [ + { oldText: 'line1', newText: 'first line' } + ]; + + mockFs.rename.mockResolvedValueOnce(undefined); + + await applyFileEdits('/test/file.txt', edits, false); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), + 'first line\nline2\nline3 \n', + 'utf-8' + ); + }); + + it('strips trailing whitespace from multi-line replacement text', async () => { + const edits = [ + { + oldText: 'line1\nline2', + newText: 'first line \nsecond line\t' + } + ]; + + mockFs.rename.mockResolvedValueOnce(undefined); + + await applyFileEdits('/test/file.txt', edits, false); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), + 'first line\nsecond line\nline3\n', + 'utf-8' + ); + }); + it('throws error for non-matching edits', async () => { const edits = [ { oldText: 'nonexistent line', newText: 'replacement' } diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index ce4af9f38a..b9c4f4a0fc 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -57,6 +57,17 @@ export function normalizeLineEndings(text: string): string { return text.replace(/\r\n/g, '\n'); } +// Strips trailing horizontal whitespace (spaces/tabs) from every line. +// Applied only to content that is actually being written, so callers +// that pass already-clean content see no behavioral change, and edits +// never touch whitespace on lines outside the edit itself. +export function stripTrailingWhitespace(text: string): string { + return text + .split('\n') + .map(line => line.replace(/[ \t]+$/, '')) + .join('\n'); +} + export function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string { // Ensure consistent line endings for diff const normalizedOriginal = normalizeLineEndings(originalContent); @@ -159,10 +170,14 @@ export async function readFileContent(filePath: string, encoding: string = 'utf- } export async function writeFileContent(filePath: string, content: string): Promise { + // Strip trailing whitespace from every line before writing. This is the + // content the caller is asking us to write, so cleaning it here avoids + // the trailing-whitespace/lint churn reported in #1590. + const cleanedContent = stripTrailingWhitespace(content); try { // Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists, // preventing writes through pre-existing symlinks - await fs.writeFile(filePath, content, { encoding: "utf-8", flag: 'wx' }); + await fs.writeFile(filePath, cleanedContent, { encoding: "utf-8", flag: 'wx' }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') { // Security: Use atomic rename to prevent race conditions where symlinks @@ -170,7 +185,7 @@ export async function writeFileContent(filePath: string, content: string): Promi // replace the target file atomically and don't follow symlinks. const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`; try { - await fs.writeFile(tempPath, content, 'utf-8'); + await fs.writeFile(tempPath, cleanedContent, 'utf-8'); await fs.rename(tempPath, filePath); } catch (renameError) { try { @@ -203,7 +218,10 @@ export async function applyFileEdits( let modifiedContent = content; for (const edit of edits) { const normalizedOld = normalizeLineEndings(edit.oldText); - const normalizedNew = normalizeLineEndings(edit.newText); + // Strip trailing whitespace from the incoming replacement text only - + // this is the content the edit is introducing, so it's safe to clean + // without touching whitespace on unrelated lines elsewhere in the file. + const normalizedNew = stripTrailingWhitespace(normalizeLineEndings(edit.newText)); // If exact match exists, use it if (modifiedContent.includes(normalizedOld)) {