resolution.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import { describe, expect, test } from 'bun:test';
  2. import path from 'node:path';
  3. import {
  4. applyHits,
  5. deriveNewContent,
  6. locateChunk,
  7. readFileLines,
  8. resolveChunkStart,
  9. resolveUpdateChunks,
  10. } from './resolution';
  11. import { createTempDir, DEFAULT_OPTIONS, writeFixture } from './test-helpers';
  12. import type { PatchChunk } from './types';
  13. describe('apply-patch/resolution', () => {
  14. test('readFileLines removes the final synthetic empty line', async () => {
  15. const root = await createTempDir();
  16. const file = path.join(root, 'sample.txt');
  17. await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
  18. expect(await readFileLines(file)).toEqual(['alpha', 'beta']);
  19. });
  20. test('resolveChunkStart uses change_context as an anchor when present', () => {
  21. const chunk: PatchChunk = {
  22. old_lines: [],
  23. new_lines: ['middle'],
  24. change_context: 'anchor',
  25. };
  26. expect(resolveChunkStart(['top', 'anchor', 'bottom'], chunk, 0)).toBe(2);
  27. });
  28. test('locateChunk rescues prefix/suffix and preserves new_lines', () => {
  29. const chunk: PatchChunk = {
  30. old_lines: [
  31. 'const title = "Hola";',
  32. 'old-value',
  33. 'const footer = "Fin";',
  34. ],
  35. new_lines: [
  36. 'const title = “Hola”;',
  37. 'new-value',
  38. 'const footer = “Fin”;',
  39. ],
  40. };
  41. const resolved = locateChunk(
  42. ['top', 'const title = “Hola”;', 'stale-value', 'const footer = “Fin”;'],
  43. 'sample.txt',
  44. chunk,
  45. 0,
  46. DEFAULT_OPTIONS,
  47. );
  48. expect(resolved.rewritten).toBe(true);
  49. expect(resolved.canonical_old_lines).toEqual([
  50. 'const title = “Hola”;',
  51. 'stale-value',
  52. 'const footer = “Fin”;',
  53. ]);
  54. expect(resolved.canonical_new_lines).toEqual(chunk.new_lines);
  55. });
  56. test('locateChunk canonicalizes a tolerant unicode match', () => {
  57. const chunk: PatchChunk = {
  58. old_lines: ['const title = "Hola";'],
  59. new_lines: ['const title = "Hola mundo";'],
  60. };
  61. const resolved = locateChunk(
  62. ['const title = “Hola”;'],
  63. 'sample.txt',
  64. chunk,
  65. 0,
  66. DEFAULT_OPTIONS,
  67. );
  68. expect(resolved.rewritten).toBe(true);
  69. expect(resolved.matchComparator).toBe('unicode');
  70. expect(resolved.canonical_old_lines).toEqual(['const title = “Hola”;']);
  71. expect(resolved.canonical_new_lines).toEqual([
  72. 'const title = "Hola mundo";',
  73. ]);
  74. });
  75. test('locateChunk canonicalizes a tolerant trim-end match', () => {
  76. const chunk: PatchChunk = {
  77. old_lines: ['alpha'],
  78. new_lines: ['omega'],
  79. };
  80. const resolved = locateChunk(
  81. ['alpha '],
  82. 'sample.txt',
  83. chunk,
  84. 0,
  85. DEFAULT_OPTIONS,
  86. );
  87. expect(resolved.rewritten).toBe(true);
  88. expect(resolved.matchComparator).toBe('trim-end');
  89. expect(resolved.canonical_old_lines).toEqual(['alpha ']);
  90. expect(resolved.canonical_new_lines).toEqual(['omega']);
  91. });
  92. test('locateChunk no longer rescues a trim-only stale patch', () => {
  93. const chunk: PatchChunk = {
  94. old_lines: ['alpha'],
  95. new_lines: ['omega'],
  96. };
  97. expect(() =>
  98. locateChunk([' alpha '], 'sample.txt', chunk, 0, DEFAULT_OPTIONS),
  99. ).toThrow('Failed to find expected lines');
  100. });
  101. test('locateChunk no longer canonicalizes a dangerous indented case', () => {
  102. const chunk: PatchChunk = {
  103. old_lines: ['enabled: false'],
  104. new_lines: ['enabled: true'],
  105. };
  106. expect(() =>
  107. locateChunk(
  108. ['root:', ' child:', ' enabled: false', 'done: true'],
  109. 'sample.yml',
  110. chunk,
  111. 0,
  112. DEFAULT_OPTIONS,
  113. ),
  114. ).toThrow('Failed to find expected lines');
  115. });
  116. test('locateChunk preserves a real final blank line when it exists in the file', () => {
  117. const chunk: PatchChunk = {
  118. old_lines: ['alpha', ''],
  119. new_lines: ['omega', ''],
  120. };
  121. const resolved = locateChunk(
  122. ['alpha', ''],
  123. 'sample.txt',
  124. chunk,
  125. 0,
  126. DEFAULT_OPTIONS,
  127. );
  128. expect(resolved.canonical_old_lines).toEqual(['alpha', '']);
  129. expect(resolved.canonical_new_lines).toEqual(['omega', '']);
  130. });
  131. test('locateChunk fails if the patch adds a non-existent final blank line', () => {
  132. const chunk: PatchChunk = {
  133. old_lines: ['alpha', ''],
  134. new_lines: ['omega', ''],
  135. };
  136. expect(() =>
  137. locateChunk(['alpha'], 'sample.txt', chunk, 0, DEFAULT_OPTIONS),
  138. ).toThrow('Failed to find expected lines');
  139. });
  140. test('deriveNewContent resolves EOF updates', async () => {
  141. const root = await createTempDir();
  142. const file = path.join(root, 'sample.txt');
  143. await writeFixture(root, 'sample.txt', 'alpha\nbeta');
  144. expect(
  145. await deriveNewContent(
  146. file,
  147. [
  148. {
  149. old_lines: ['beta'],
  150. new_lines: ['omega'],
  151. is_end_of_file: true,
  152. },
  153. ],
  154. DEFAULT_OPTIONS,
  155. ),
  156. ).toBe('alpha\nomega');
  157. });
  158. test('deriveNewContent preserves CRLF while rebuilding content', async () => {
  159. const root = await createTempDir();
  160. const file = path.join(root, 'sample.txt');
  161. await writeFixture(root, 'sample.txt', 'alpha\r\nbeta\r\ngamma\r\n');
  162. expect(
  163. await deriveNewContent(
  164. file,
  165. [
  166. {
  167. old_lines: ['alpha', 'beta', 'gamma'],
  168. new_lines: ['alpha', 'BETA', 'gamma'],
  169. },
  170. ],
  171. DEFAULT_OPTIONS,
  172. ),
  173. ).toBe('alpha\r\nBETA\r\ngamma\r\n');
  174. });
  175. test('deriveNewContent inserts an anchored block without moving it to EOF', async () => {
  176. const root = await createTempDir();
  177. const file = path.join(root, 'sample.txt');
  178. await writeFixture(root, 'sample.txt', 'top\nanchor\nbottom\n');
  179. expect(
  180. await deriveNewContent(
  181. file,
  182. [
  183. {
  184. old_lines: [],
  185. new_lines: ['middle'],
  186. change_context: 'anchor',
  187. },
  188. ],
  189. DEFAULT_OPTIONS,
  190. ),
  191. ).toBe('top\nanchor\nmiddle\nbottom\n');
  192. });
  193. test('deriveNewContent supports pure insertion at EOF with a single anchor', async () => {
  194. const root = await createTempDir();
  195. const file = path.join(root, 'sample.txt');
  196. await writeFixture(root, 'sample.txt', 'top\nanchor\n');
  197. expect(
  198. await deriveNewContent(
  199. file,
  200. [
  201. {
  202. old_lines: [],
  203. new_lines: ['middle'],
  204. change_context: 'anchor',
  205. },
  206. ],
  207. DEFAULT_OPTIONS,
  208. ),
  209. ).toBe('top\nanchor\nmiddle\n');
  210. });
  211. test('resolveUpdateChunks canonicalizes EOF insertion with a tolerant anchor', async () => {
  212. const root = await createTempDir();
  213. const file = path.join(root, 'sample.txt');
  214. await writeFixture(root, 'sample.txt', 'top\n“anchor”\n');
  215. const { resolved } = await resolveUpdateChunks(
  216. file,
  217. [
  218. {
  219. old_lines: [],
  220. new_lines: ['middle'],
  221. change_context: '"anchor"',
  222. },
  223. ],
  224. DEFAULT_OPTIONS,
  225. );
  226. expect(resolved[0]).toMatchObject({
  227. canonical_change_context: '“anchor”',
  228. rewritten: true,
  229. strategy: 'anchor',
  230. matchComparator: 'unicode',
  231. });
  232. });
  233. test('resolveUpdateChunks canonicalizes non-EOF insertion with a trim-end anchor', async () => {
  234. const root = await createTempDir();
  235. const file = path.join(root, 'sample.txt');
  236. await writeFixture(root, 'sample.txt', 'top\nanchor \nbottom\n');
  237. const { resolved } = await resolveUpdateChunks(
  238. file,
  239. [
  240. {
  241. old_lines: [],
  242. new_lines: ['middle'],
  243. change_context: 'anchor',
  244. },
  245. ],
  246. DEFAULT_OPTIONS,
  247. );
  248. expect(resolved[0]).toMatchObject({
  249. canonical_change_context: 'anchor ',
  250. rewritten: true,
  251. strategy: 'anchor',
  252. matchComparator: 'trim-end',
  253. });
  254. });
  255. test('deriveNewContent fails if a pure insertion cannot find its anchor', async () => {
  256. const root = await createTempDir();
  257. const file = path.join(root, 'sample.txt');
  258. await writeFixture(root, 'sample.txt', 'top\nbottom\n');
  259. await expect(
  260. deriveNewContent(
  261. file,
  262. [
  263. {
  264. old_lines: [],
  265. new_lines: ['middle'],
  266. change_context: 'anchor',
  267. },
  268. ],
  269. DEFAULT_OPTIONS,
  270. ),
  271. ).rejects.toThrow('Failed to find insertion anchor');
  272. });
  273. test('deriveNewContent fails if a pure insertion has an ambiguous anchor', async () => {
  274. const root = await createTempDir();
  275. const file = path.join(root, 'sample.txt');
  276. await writeFixture(
  277. root,
  278. 'sample.txt',
  279. 'top\nanchor\none\nsplit\nanchor\ntwo\n',
  280. );
  281. await expect(
  282. deriveNewContent(
  283. file,
  284. [
  285. {
  286. old_lines: [],
  287. new_lines: ['middle'],
  288. change_context: 'anchor',
  289. },
  290. ],
  291. DEFAULT_OPTIONS,
  292. ),
  293. ).rejects.toThrow('Insertion anchor was ambiguous');
  294. });
  295. test('deriveNewContent fails if a tolerant insertion anchor is ambiguous', async () => {
  296. const root = await createTempDir();
  297. const file = path.join(root, 'sample.txt');
  298. await writeFixture(root, 'sample.txt', 'top\n“anchor”\n"anchor"\n');
  299. await expect(
  300. deriveNewContent(
  301. file,
  302. [
  303. {
  304. old_lines: [],
  305. new_lines: ['middle'],
  306. change_context: '"anchor"',
  307. },
  308. ],
  309. DEFAULT_OPTIONS,
  310. ),
  311. ).rejects.toThrow('Insertion anchor was ambiguous');
  312. });
  313. test('deriveNewContent fails if a later chunk remains ambiguous', async () => {
  314. const root = await createTempDir();
  315. const file = path.join(root, 'sample.txt');
  316. await writeFixture(
  317. root,
  318. 'sample.txt',
  319. 'alpha\none\nomega\nsplit\nleft\nstale-one\nright\ngap\nleft\nstale-two\nright\n',
  320. );
  321. await expect(
  322. deriveNewContent(
  323. file,
  324. [
  325. {
  326. old_lines: ['one'],
  327. new_lines: ['ONE'],
  328. },
  329. {
  330. old_lines: ['left', 'old', 'right'],
  331. new_lines: ['left', 'new', 'right'],
  332. },
  333. ],
  334. DEFAULT_OPTIONS,
  335. ),
  336. ).rejects.toThrow('ambiguous');
  337. });
  338. test('deriveNewContent rescues a stale EOF and preserves the final update', async () => {
  339. const root = await createTempDir();
  340. const file = path.join(root, 'sample.txt');
  341. await writeFixture(root, 'sample.txt', 'alpha\nstale\nomega');
  342. expect(
  343. await deriveNewContent(
  344. file,
  345. [
  346. {
  347. old_lines: ['alpha', 'old', 'omega'],
  348. new_lines: ['alpha', 'new', 'omega'],
  349. is_end_of_file: true,
  350. },
  351. ],
  352. DEFAULT_OPTIONS,
  353. ),
  354. ).toBe('alpha\nnew\nomega');
  355. });
  356. test('applyHits preserves the final newline', () => {
  357. expect(
  358. applyHits(['start', 'end'], [{ start: 0, del: 1, add: ['next'] }]),
  359. ).toBe('next\nend\n');
  360. });
  361. test('applyHits can preserve a file without a final newline', () => {
  362. expect(
  363. applyHits(
  364. ['start', 'end'],
  365. [{ start: 0, del: 1, add: ['next'] }],
  366. '\n',
  367. false,
  368. ),
  369. ).toBe('next\nend');
  370. });
  371. });