Files
deepseek-harness/packages/fs/fs-patch/tests/apply.spec.ts
T

130 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Text fixtures adapted from OpenAI Codex rust-v0.158.0 seek_sequence.rs,
* file_update_tests.rs, and lib.rs (Apache-2.0; see ../LICENSE).
*/
import { describe, expect, it } from 'vitest'
import { applyHunks, parsePatch } from '../src/index.ts'
import type { HunkApplyFailure, PatchHunk } from '../src/index.ts'
function update(...body: string[]): PatchHunk[] {
const [operation] = parsePatch(['*** Begin Patch', '*** Update File: a', ...body, '*** End Patch'].join('\n'))
if (operation?.kind !== 'update') throw new Error('Expected one parsed update')
return operation.hunks
}
describe('applyHunks', () => {
it.each([
// Upstream: test_multiple_update_chunks_apply_to_single_file; test_unified_diff.
{ name: 'multiple hunks search the original text', before: 'foo\nbar\nbaz\nqux\n', body: ['@@', ' foo', '-bar', '+BAR', '@@', ' baz', '-qux', '+QUX'], after: 'foo\nBAR\nbaz\nQUX\n' },
// Upstream: test_update_file_hunk_interleaved_changes.
{ name: 'interleaved replacements and an EOF addition', before: 'a\nb\nc\nd\ne\nf\n', body: ['@@', ' a', '-b', '+B', '@@', ' c', ' d', '-e', '+E', '@@', ' f', '+g', '*** End of File'], after: 'a\nB\nc\nd\nE\nf\ng\n' },
// Upstream: test_pure_addition_chunk_followed_by_removal.
{ name: 'an append precedes a removal in patch order', before: 'line1\nline2\nline3\n', body: ['@@', '+after-context', '+second-line', '@@', ' line1', '-line2', '-line3', '+line2-replacement'], after: 'line1\nline2-replacement\nafter-context\nsecond-line\n' },
// Upstream: test_update_line_with_unicode_dash.
{ name: 'ASCII patch matches Unicode dashes', before: 'import asyncio # local import – avoids top‑level dep\n', body: ['-import asyncio # local import - avoids top-level dep', '+import asyncio # HELLO'], after: 'import asyncio # HELLO\n' },
// Upstream: test_unified_diff_first_line_replacement.
{ name: 'first line replacement', before: 'foo\nbar\nbaz\n', body: ['-foo', '+FOO', ' bar'], after: 'FOO\nbar\nbaz\n' },
// Upstream: test_unified_diff_last_line_replacement.
{ name: 'last line replacement', before: 'foo\nbar\nbaz\n', body: [' foo', ' bar', '-baz', '+BAZ'], after: 'foo\nbar\nBAZ\n' },
// Upstream: test_unified_diff_insert_at_eof.
{ name: 'pure insertion with an EOF marker', before: 'foo\nbar\nbaz\n', body: ['+quux', '*** End of File'], after: 'foo\nbar\nbaz\nquux\n' },
// Upstream: test_exact_match_finds_sequence.
{ name: 'exact sequence', before: 'foo\nbar\nbaz\n', body: ['-bar', '-baz', '+done'], after: 'foo\ndone\n' },
// Upstream: test_rstrip_match_ignores_trailing_whitespace.
{ name: 'trailing whitespace is ignored on both sides', before: 'foo \nbar\t\t\n', body: ['-foo\t', '-bar ', '+done'], after: 'done\n' },
// Upstream: test_trim_match_ignores_leading_and_trailing_whitespace.
{ name: 'leading and trailing whitespace is ignored', before: ' foo \n bar\t\n', body: ['- foo', '-bar', '+done'], after: 'done\n' },
{ name: 'exact match wins over an earlier trimmed match', before: 'x \nx\n', body: ['-x', '+X'], after: 'x \nX\n' },
{ name: 'trailing-only match wins over an earlier fully trimmed match', before: ' x\nx \n', body: ['-x', '+X'], after: ' x\nX\n' },
{ name: 'trimmed match wins over an earlier punctuation match', before: 'x–y\n x-y \n', body: ['-x-y', '+X'], after: 'x–y\nX\n' },
{ name: 'each hunk takes the first remaining repeated sequence', before: 'x\nx\nx\n', body: ['-x', '+first', '@@', '-x', '+second'], after: 'first\nsecond\nx\n' },
{ name: 'anchors advance beyond the matching line', before: 'fn a\nx\nfn b\nx\n', body: ['@@ fn b', '-x', '+X'], after: 'fn a\nx\nfn b\nX\n' },
{ name: 'repeated anchors use the advancing cursor', before: 'fn\nx\nfn\nx\n', body: ['@@ fn', '-x', '+first', '@@ fn', '-x', '+second'], after: 'fn\nfirst\nfn\nsecond\n' },
{ name: 'anchors also use punctuation tolerance', before: 'fn “a”\nx\n', body: ['@@ fn "a"', '-x', '+X'], after: 'fn “a”\nX\n' },
{ name: 'empty-file insertion', before: '', body: ['+hello'], after: 'hello\n' },
{ name: 'insertion after an unterminated final line', before: 'hello', body: ['+world'], after: 'hello\nworld\n' },
{ name: 'insertion before the final empty source line', before: 'hello\n\n', body: ['+world'], after: 'hello\nworld\n' },
{ name: 'insertion into a one-newline file', before: '\n', body: ['+hello'], after: 'hello\n' },
{ name: 'anchored pure insertion still appends at EOF', before: 'fn\nx\ny\n', body: ['@@ fn', '+last'], after: 'fn\nx\ny\nlast\n' },
{ name: 'multiple pure insertions retain patch order', before: 'x\n', body: ['+first', '@@', '+second'], after: 'x\nfirst\nsecond\n' },
{ name: 'pure deletion retains surrounding lines', before: 'a\nb\nc\n', body: ['-b'], after: 'a\nc\n' },
{ name: 'deletion to empty text', before: 'a\n', body: ['-a'], after: '' },
{ name: 'replacement adds a missing final newline', before: 'a', body: ['-a', '+b'], after: 'b\n' },
{ name: 'EOF prefers the tail over an earlier exact match', before: 'x\nx \n', body: ['-x', '+X', '*** End of File'], after: 'x\nX\n' },
{ name: 'EOF may search before the sequential cursor', before: 'x\n', body: ['@@ x', '-x', '+X', '*** End of File'], after: 'X\n' },
{ name: 'overlapping EOF hunks retain upstream replacement order', before: 'x\n', body: ['-x', '+first', '@@', '-x', '+second', '*** End of File'], after: 'first\n' },
{ name: 'trailing empty context retries without both sentinels', before: 'a\n', body: ['-a', '+b', ''], after: 'b\n' },
{ name: 'trailing empty old line can retry without trimming the insertion', before: 'a\n', body: ['-a', '-', '+b'], after: 'b\n' },
{ name: 'empty retry pattern matches the cursor', before: '', body: ['-', '+b'], after: 'b\n' },
{ name: 'empty retry after an anchor matches its cursor', before: 'a\n', body: ['@@ a', '-', '+b'], after: 'a\nb\n' },
{ name: 'explicit context is replaced from patch text in NormalizeToLf', before: ' context\r\nold\n', body: [' context', '-old', '+new'], after: 'context\nnew\n' },
{ name: 'CRLF input retains CR on untouched lines', before: 'a\r\nb\r\n', body: ['-a', '+A'], after: 'A\nb\r\n' },
{ name: 'appending does not normalize existing CRLF', before: 'a\r\n', body: ['+b'], after: 'a\r\nb\n' },
{ name: 'lone CR remains an ordinary character', before: 'a\rb\n', body: ['-a\rb', '+c'], after: 'c\n' },
{ name: 'non-ASCII content survives unchanged', before: '你好🙂\n', body: [' 你好🙂', '+é'], after: '你好🙂\né\n' },
{ name: 'long interior whitespace survives all failed earlier candidates', before: `${' '.repeat(32_000)}x–y\n`, body: ['-x-y', '+done'], after: 'done\n' },
])('$name', ({ before, body, after }) => {
const hunks = update(...body)
const originalHunks = JSON.stringify(hunks)
const result = applyHunks(before, hunks)
expect(result).toEqual({ ok: true, after })
expect(JSON.parse(JSON.stringify(result))).toEqual(result)
expect(JSON.stringify(hunks)).toBe(originalHunks)
})
it.each([
...'‐‑‒–—―−'.split('').map(character => ({ character, ascii: '-' })),
...'‘’‚‛'.split('').map(character => ({ character, ascii: "'" })),
...'“”„‟'.split('').map(character => ({ character, ascii: '"' })),
...'\u00A0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000'.split('').map(character => ({ character, ascii: ' ' })),
])('normalizes interior punctuation $character on either side', ({ character, ascii }) => {
expect(applyHunks(`a${character}b\n`, update(`-a${ascii}b`, '+done'))).toEqual({ ok: true, after: 'done\n' })
expect(applyHunks(`a${ascii}b\n`, update(`-a${character}b`, '+done'))).toEqual({ ok: true, after: 'done\n' })
})
it.each(['\u0085', '\u00A0', '\u2003', '\u3000'])('trims Rust White_Space %j', (space) => {
expect(applyHunks(`${space}x${space}\n`, update('-x', '+done'))).toEqual({ ok: true, after: 'done\n' })
})
it.each<{ name: string; before: string; body: string[]; failure: HunkApplyFailure }>([
// Upstream: test_pattern_longer_than_input_returns_none.
{ name: 'pattern longer than the file', before: 'one\n', body: ['-too', '-many', '-lines'], failure: { hunkLineNumber: 3, stage: 'oldLines', searchStart: 0, pattern: ['too', 'many', 'lines'] } },
{ name: 'anchor not found', before: 'a\n', body: ['@@ missing', '-a', '+b'], failure: { hunkLineNumber: 3, stage: 'context', searchStart: 0, pattern: ['missing'] } },
{ name: 'old content not found after an anchor', before: 'fn\na\n', body: ['@@ fn', '-missing', '+b'], failure: { hunkLineNumber: 3, stage: 'oldLines', searchStart: 1, pattern: ['missing'] } },
{ name: 'later context exists only before the previous hunk', before: 'fn\na\n', body: ['-a', '+b', '@@ fn', '+c'], failure: { hunkLineNumber: 5, stage: 'context', searchStart: 2, pattern: ['fn'] } },
{ name: 'later old lines exist only before the previous hunk', before: 'a\nb\n', body: ['-b', '+B', '@@', '-a', '+A'], failure: { hunkLineNumber: 5, stage: 'oldLines', searchStart: 2, pattern: ['a'] } },
{ name: 'inserted text is not a later search candidate', before: 'a\n', body: ['-a', '+b', '@@', '-b', '+c'], failure: { hunkLineNumber: 5, stage: 'oldLines', searchStart: 1, pattern: ['b'] } },
{ name: 'EOF does not fall back to an earlier match', before: 'x\ny\n', body: ['-x', '+X', '*** End of File'], failure: { hunkLineNumber: 3, stage: 'oldLines', searchStart: 0, pattern: ['x'] } },
{ name: 'failed sentinel retry reports the original pattern', before: 'a\n', body: ['-missing', '-', '+b', '+'], failure: { hunkLineNumber: 3, stage: 'oldLines', searchStart: 0, pattern: ['missing', ''] } },
{ name: 'BOM is not Rust whitespace', before: '\uFEFFa\n', body: ['-a', '+b'], failure: { hunkLineNumber: 3, stage: 'oldLines', searchStart: 0, pattern: ['a'] } },
{ name: 'combining marks are not Unicode-normalized', before: 'é\n', body: ['-é', '+b'], failure: { hunkLineNumber: 3, stage: 'oldLines', searchStart: 0, pattern: ['é'] } },
{ name: 'interior EN QUAD is outside the punctuation mapping', before: 'a\u2000b\n', body: ['-a b', '+b'], failure: { hunkLineNumber: 3, stage: 'oldLines', searchStart: 0, pattern: ['a b'] } },
{ name: 'lone CR does not split source lines', before: 'a\rb', body: ['-a', '-b', '+c'], failure: { hunkLineNumber: 3, stage: 'oldLines', searchStart: 0, pattern: ['a', 'b'] } },
])('$name', ({ before, body, failure }) => {
const hunks = update(...body)
const originalHunks = JSON.stringify(hunks)
const result = applyHunks(before, hunks)
expect(result).toEqual({ ok: false, failure })
expect(JSON.parse(JSON.stringify(result))).toEqual(result)
expect(JSON.stringify(hunks)).toBe(originalHunks)
})
it.each([
['', ''], ['a', 'a\n'], ['a\n', 'a\n'], ['a\n\n', 'a\n'],
])('empty hunk list applies NormalizeToLf reconstruction to %j', (before, after) => {
const operations = parsePatch('*** Begin Patch\n*** End Patch')
const hunks = operations.flatMap(operation => operation.kind === 'update' ? operation.hunks : [])
expect(applyHunks(before, hunks)).toEqual({ ok: true, after })
})
})
it('returns failure patterns independent of the parsed hunk arrays', () => {
const hunks = update('-missing', '+replacement')
const result = applyHunks('other\n', hunks)
expect(result).toEqual({ ok: false, failure: { hunkLineNumber: 3, stage: 'oldLines', searchStart: 0, pattern: ['missing'] } })
if (result.ok) throw new Error('Expected failure')
result.failure.pattern.push('caller annotation')
expect(hunks[0]?.oldLines).toEqual(['missing'])
})