@abelfubu/dv 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/ansi-html.d.ts +42 -0
  2. package/dist/ansi-html.d.ts.map +1 -0
  3. package/dist/ansi-html.js +327 -0
  4. package/dist/ansi-output.d.ts +22 -0
  5. package/dist/ansi-output.d.ts.map +1 -0
  6. package/dist/ansi-output.js +154 -0
  7. package/dist/balance-delimiters.d.ts +25 -0
  8. package/dist/balance-delimiters.d.ts.map +1 -0
  9. package/dist/balance-delimiters.js +539 -0
  10. package/dist/balance-delimiters.test.d.ts +2 -0
  11. package/dist/balance-delimiters.test.d.ts.map +1 -0
  12. package/dist/balance-delimiters.test.js +1029 -0
  13. package/dist/cli-copy-notification.test.d.ts +2 -0
  14. package/dist/cli-copy-notification.test.d.ts.map +1 -0
  15. package/dist/cli-copy-notification.test.js +80 -0
  16. package/dist/cli-scroll.test.d.ts +2 -0
  17. package/dist/cli-scroll.test.d.ts.map +1 -0
  18. package/dist/cli-scroll.test.js +283 -0
  19. package/dist/cli.d.ts +9 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +976 -0
  22. package/dist/clipboard.d.ts +16 -0
  23. package/dist/clipboard.d.ts.map +1 -0
  24. package/dist/clipboard.js +128 -0
  25. package/dist/components/diff-view.d.ts +32 -0
  26. package/dist/components/diff-view.d.ts.map +1 -0
  27. package/dist/components/diff-view.js +123 -0
  28. package/dist/components/diff-view.test.d.ts +5 -0
  29. package/dist/components/diff-view.test.d.ts.map +1 -0
  30. package/dist/components/diff-view.test.js +312 -0
  31. package/dist/components/directory-tree-view.d.ts +33 -0
  32. package/dist/components/directory-tree-view.d.ts.map +1 -0
  33. package/dist/components/directory-tree-view.js +262 -0
  34. package/dist/components/index.d.ts +4 -0
  35. package/dist/components/index.d.ts.map +1 -0
  36. package/dist/components/index.js +5 -0
  37. package/dist/components/toast.d.ts +21 -0
  38. package/dist/components/toast.d.ts.map +1 -0
  39. package/dist/components/toast.js +47 -0
  40. package/dist/diff-cursor-utils.d.ts +20 -0
  41. package/dist/diff-cursor-utils.d.ts.map +1 -0
  42. package/dist/diff-cursor-utils.js +105 -0
  43. package/dist/diff-cursor-utils.test.d.ts +2 -0
  44. package/dist/diff-cursor-utils.test.d.ts.map +1 -0
  45. package/dist/diff-cursor-utils.test.js +40 -0
  46. package/dist/diff-surface-copy.d.ts +23 -0
  47. package/dist/diff-surface-copy.d.ts.map +1 -0
  48. package/dist/diff-surface-copy.js +64 -0
  49. package/dist/diff-surface-copy.test.d.ts +5 -0
  50. package/dist/diff-surface-copy.test.d.ts.map +1 -0
  51. package/dist/diff-surface-copy.test.js +142 -0
  52. package/dist/diff-utils.d.ts +196 -0
  53. package/dist/diff-utils.d.ts.map +1 -0
  54. package/dist/diff-utils.js +682 -0
  55. package/dist/diff-utils.test.d.ts +2 -0
  56. package/dist/diff-utils.test.d.ts.map +1 -0
  57. package/dist/diff-utils.test.js +727 -0
  58. package/dist/directory-tree.d.ts +72 -0
  59. package/dist/directory-tree.d.ts.map +1 -0
  60. package/dist/directory-tree.js +161 -0
  61. package/dist/directory-tree.test.d.ts +2 -0
  62. package/dist/directory-tree.test.d.ts.map +1 -0
  63. package/dist/directory-tree.test.js +383 -0
  64. package/dist/dropdown.d.ts +26 -0
  65. package/dist/dropdown.d.ts.map +1 -0
  66. package/dist/dropdown.js +172 -0
  67. package/dist/dropdown.test.d.ts +2 -0
  68. package/dist/dropdown.test.d.ts.map +1 -0
  69. package/dist/dropdown.test.js +106 -0
  70. package/dist/filter-submodule.e2e.test.d.ts +2 -0
  71. package/dist/filter-submodule.e2e.test.d.ts.map +1 -0
  72. package/dist/filter-submodule.e2e.test.js +109 -0
  73. package/dist/hooks/use-copy-selection.d.ts +29 -0
  74. package/dist/hooks/use-copy-selection.d.ts.map +1 -0
  75. package/dist/hooks/use-copy-selection.js +46 -0
  76. package/dist/kv-codec.d.ts +16 -0
  77. package/dist/kv-codec.d.ts.map +1 -0
  78. package/dist/kv-codec.js +36 -0
  79. package/dist/license.d.ts +14 -0
  80. package/dist/license.d.ts.map +1 -0
  81. package/dist/license.js +63 -0
  82. package/dist/logger.d.ts +9 -0
  83. package/dist/logger.d.ts.map +1 -0
  84. package/dist/logger.js +78 -0
  85. package/dist/monochrome.d.ts +34 -0
  86. package/dist/monochrome.d.ts.map +1 -0
  87. package/dist/monochrome.js +613 -0
  88. package/dist/monotone.d.ts +22 -0
  89. package/dist/monotone.d.ts.map +1 -0
  90. package/dist/monotone.js +185 -0
  91. package/dist/parsers-config.d.ts +19 -0
  92. package/dist/parsers-config.d.ts.map +1 -0
  93. package/dist/parsers-config.js +271 -0
  94. package/dist/patch-terminal-dimensions.d.ts +2 -0
  95. package/dist/patch-terminal-dimensions.d.ts.map +1 -0
  96. package/dist/patch-terminal-dimensions.js +45 -0
  97. package/dist/stdin-pager.test.d.ts +2 -0
  98. package/dist/stdin-pager.test.d.ts.map +1 -0
  99. package/dist/stdin-pager.test.js +497 -0
  100. package/dist/store.d.ts +16 -0
  101. package/dist/store.d.ts.map +1 -0
  102. package/dist/store.js +48 -0
  103. package/dist/themes/github.json +247 -0
  104. package/dist/themes.d.ts +59 -0
  105. package/dist/themes.d.ts.map +1 -0
  106. package/dist/themes.js +248 -0
  107. package/dist/tree-icons.d.ts +4 -0
  108. package/dist/tree-icons.d.ts.map +1 -0
  109. package/dist/tree-icons.js +18 -0
  110. package/dist/utils.d.ts +2 -0
  111. package/dist/utils.d.ts.map +1 -0
  112. package/dist/utils.js +13 -0
  113. package/dist/web-utils.d.ts +56 -0
  114. package/dist/web-utils.d.ts.map +1 -0
  115. package/dist/web-utils.js +363 -0
  116. package/package.json +37 -0
  117. package/public/jetbrains-mono-nerd.ttf +0 -0
  118. package/public/jetbrains-mono-nerd.woff2 +0 -0
@@ -0,0 +1,1029 @@
1
+ // Tests for delimiter balancing: tokenizer pass (countDelimiter) and fix pass (balanceDelimiters).
2
+ import { describe, expect, it } from "bun:test";
3
+ import { parsePatch } from "diff";
4
+ import { countDelimiter, balanceDelimiters } from "./balance-delimiters.js";
5
+ // ============================================================================
6
+ // countDelimiter — tokenizer pass
7
+ // ============================================================================
8
+ describe("countDelimiter", () => {
9
+ describe("backticks (JS/TS/Go)", () => {
10
+ it("counts backticks in plain code", () => {
11
+ expect(countDelimiter("const x = `hello`", "`")).toBe(2);
12
+ });
13
+ it("counts single backtick (unclosed template)", () => {
14
+ expect(countDelimiter("end of template`\nconst y = 1", "`")).toBe(1);
15
+ });
16
+ it("returns 0 for code without backticks", () => {
17
+ expect(countDelimiter("const x = 1\nconst y = 2", "`")).toBe(0);
18
+ });
19
+ it("skips escaped backticks", () => {
20
+ expect(countDelimiter("const x = `hello \\` world`", "`")).toBe(2);
21
+ });
22
+ it("handles nested template literals", () => {
23
+ expect(countDelimiter("`outer ${`inner`} rest`", "`")).toBe(4);
24
+ });
25
+ it("handles backticks inside regex patterns", () => {
26
+ expect(countDelimiter("const re = /\\`/g", "`")).toBe(0);
27
+ });
28
+ it("handles apostrophe inside template literal", () => {
29
+ expect(countDelimiter("const s = `it's fine`", "`")).toBe(2);
30
+ });
31
+ it("handles URL inside template literal (://)", () => {
32
+ expect(countDelimiter("const s = `https://example.com`", "`")).toBe(2);
33
+ });
34
+ it("handles protocol template literal", () => {
35
+ expect(countDelimiter("const url = `${protocol}://${host}:${port}${path}`", "`")).toBe(2);
36
+ });
37
+ it("ignores backticks inside quoted string fragments", () => {
38
+ expect(countDelimiter("const code = `${'`'}Hello ${'${'}name${'}'}${'`'}`", "`")).toBe(2);
39
+ });
40
+ it("handles empty string", () => {
41
+ expect(countDelimiter("", "`")).toBe(0);
42
+ });
43
+ it("handles escaped backslash before backtick", () => {
44
+ expect(countDelimiter("const x = `end\\\\\\``", "`")).toBe(2);
45
+ });
46
+ });
47
+ describe("triple double quotes (Python)", () => {
48
+ it("counts triple quotes in docstring", () => {
49
+ expect(countDelimiter('def foo():\n """docstring"""\n pass', '"""')).toBe(2);
50
+ });
51
+ it("counts single triple-quote (unclosed docstring)", () => {
52
+ expect(countDelimiter(' This is inside a docstring.\n """\n return x', '"""')).toBe(1);
53
+ });
54
+ it("returns 0 for code without triple quotes", () => {
55
+ expect(countDelimiter("x = 1\ny = 2", '"""')).toBe(0);
56
+ });
57
+ it("does not count single or double quotes as triple quotes", () => {
58
+ expect(countDelimiter('x = "hello"\ny = "world"', '"""')).toBe(0);
59
+ });
60
+ it("handles four quotes (triple + one)", () => {
61
+ // """" = one triple quote + one regular quote
62
+ expect(countDelimiter('x = """"', '"""')).toBe(1);
63
+ });
64
+ it("handles six quotes (two triple quotes)", () => {
65
+ expect(countDelimiter('x = """"""', '"""')).toBe(2);
66
+ });
67
+ it("skips escaped triple quotes", () => {
68
+ // \""" — backslash escapes the first quote, remaining "" is not a triple
69
+ expect(countDelimiter('x = \\"""', '"""')).toBe(0);
70
+ });
71
+ });
72
+ describe("triple single quotes (Python)", () => {
73
+ it("counts triple single quotes", () => {
74
+ expect(countDelimiter("x = '''hello'''", "'''")).toBe(2);
75
+ });
76
+ it("counts single triple-single-quote (unclosed)", () => {
77
+ expect(countDelimiter(" inside raw string\n '''\n return x", "'''")).toBe(1);
78
+ });
79
+ it("does not count single quotes as triple", () => {
80
+ expect(countDelimiter("x = 'hello'\ny = 'world'", "'''")).toBe(0);
81
+ });
82
+ });
83
+ describe("triple backticks (Markdown)", () => {
84
+ it("counts fenced code block markers", () => {
85
+ expect(countDelimiter("```ts\nconst x = 1\n```", "```")).toBe(2);
86
+ });
87
+ it("counts single fence marker (unclosed code block)", () => {
88
+ expect(countDelimiter("still inside fence\n```", "```")).toBe(1);
89
+ });
90
+ it("returns 0 for plain markdown without fences", () => {
91
+ expect(countDelimiter("# Title\n\nSome text with `inline` code", "```")).toBe(0);
92
+ });
93
+ });
94
+ });
95
+ // ============================================================================
96
+ // balanceDelimiters — fix pass
97
+ // ============================================================================
98
+ describe("balanceDelimiters", () => {
99
+ const makePatch = (hunkLines, filetype = "file.ts") => [
100
+ `--- ${filetype}`,
101
+ `+++ ${filetype}`,
102
+ "@@ -10,4 +10,4 @@ function foo() {",
103
+ ...hunkLines,
104
+ ].join("\n");
105
+ describe("typescript", () => {
106
+ it("returns patch unchanged when backticks are balanced", () => {
107
+ const patch = makePatch([
108
+ " const x = `hello`",
109
+ "-const y = `old`",
110
+ "+const y = `new`",
111
+ " const z = 1",
112
+ ]);
113
+ expect(balanceDelimiters(patch, "typescript")).toBe(patch);
114
+ });
115
+ it("prepends a synthetic opener when a hunk starts with a closing backtick", () => {
116
+ const patch = makePatch([
117
+ " end of template`",
118
+ " const x = 1",
119
+ "-const y = 2",
120
+ "+const y = 3",
121
+ ]);
122
+ const result = balanceDelimiters(patch, "typescript");
123
+ const lines = result.split("\n");
124
+ expect(lines[2]).toBe("@@ -10,4 +10,4 @@ function foo() {");
125
+ expect(lines[3]).toBe(" ` end of template`");
126
+ expect(lines[4]).toBe(" const x = 1");
127
+ });
128
+ it("returns patch unchanged for non-supported filetypes", () => {
129
+ const patch = makePatch([" end of template`", " const x = 1"]);
130
+ expect(balanceDelimiters(patch, "ruby")).toBe(patch);
131
+ expect(balanceDelimiters(patch, undefined)).toBe(patch);
132
+ });
133
+ it("handles multiple hunks independently", () => {
134
+ const patch = [
135
+ "--- file.ts",
136
+ "+++ file.ts",
137
+ "@@ -5,3 +5,3 @@",
138
+ " const x = `balanced`",
139
+ "-const a = 1",
140
+ "+const a = 2",
141
+ "@@ -20,3 +20,3 @@",
142
+ " closing`",
143
+ "-const b = 1",
144
+ "+const b = 2",
145
+ ].join("\n");
146
+ const result = balanceDelimiters(patch, "typescript");
147
+ const lines = result.split("\n");
148
+ expect(lines[3]).toBe(" const x = `balanced`");
149
+ const secondHunkIdx = lines.findIndex((l, i) => i > 2 && l.startsWith("@@"));
150
+ expect(lines[secondHunkIdx + 1]).toBe(" ` closing`");
151
+ });
152
+ it("keeps patch unchanged for URL template literal (regression)", () => {
153
+ const patch = makePatch([
154
+ " const url = `${protocol}://${host}:${port}${path}`",
155
+ " const x = 1",
156
+ "-const y = 2",
157
+ "+const y = 3",
158
+ ]);
159
+ expect(balanceDelimiters(patch, "typescript")).toBe(patch);
160
+ });
161
+ it("keeps patch unchanged for WebSocket URL template from real patch", () => {
162
+ const patch = [
163
+ "--- src/client.ts",
164
+ "+++ src/client.ts",
165
+ "@@ -267,10 +267,16 @@ export class TunnelClient {",
166
+ " const protocol = localHttps ? 'wss' : 'ws'",
167
+ " const url = `${protocol}://${localHost}:${localPort}${msg.path}`",
168
+ " ",
169
+ "- console.log(`WS OPEN ${msg.path} (${msg.connId})`)",
170
+ "+ // Forward WebSocket subprotocol if present (e.g. \"vite-hmr\")",
171
+ "+ const subprotocol = msg.headers['sec-websocket-protocol']",
172
+ "+ const protocols = subprotocol",
173
+ "+ ? subprotocol.split(',').map((p) => p.trim())",
174
+ "+ : undefined",
175
+ "+",
176
+ "+ console.log(`WS OPEN ${msg.path} (${msg.connId})${protocols ? ` protocols=${protocols}` : ''}`)",
177
+ " ",
178
+ " try {",
179
+ "- const localWs = new WebSocket(url)",
180
+ "+ const localWs = new WebSocket(url, protocols)",
181
+ " ",
182
+ " localWs.on('open', () => {",
183
+ " console.log(`WS CONNECTED ${msg.connId}`)",
184
+ ].join("\n");
185
+ expect(balanceDelimiters(patch, "typescript")).toBe(patch);
186
+ });
187
+ it("keeps patch unchanged for balanced template with apostrophe", () => {
188
+ const patch = makePatch([
189
+ " const s = `it's fine`",
190
+ " const x = 1",
191
+ "-const y = 2",
192
+ "+const y = 3",
193
+ ]);
194
+ expect(balanceDelimiters(patch, "typescript")).toBe(patch);
195
+ });
196
+ it("prepends a synthetic opener for the safe-mdx template literal hunk", () => {
197
+ const patch = [
198
+ "--- src/safe-mdx.test.tsx",
199
+ "+++ src/safe-mdx.test.tsx",
200
+ "@@ -3850,6 +3850,21 @@ test('scope with .map and arrow function callback works with generate', () => {",
201
+ " `",
202
+ " ",
203
+ " const { html, errors } = render(code, undefined, undefined, undefined, scope, { generate })",
204
+ " expect(errors).toMatchInlineSnapshot(`[]`)",
205
+ " expect(html).toMatchInlineSnapshot(`\"Alice, Bob, Charlie\"`)",
206
+ " })",
207
+ "+",
208
+ "+test('scope with template literal in expression', () => {",
209
+ "+ const scope = {",
210
+ "+ name: 'World',",
211
+ "+ count: 3,",
212
+ "+ }",
213
+ "+",
214
+ "+ const code = dedent`",
215
+ "+ {${'`'}Hello ${'${'}name${'}'}, you have ${'${'}count${'}'} items${'`'}}",
216
+ "+ `",
217
+ "+",
218
+ "+ const { html, errors } = render(code, undefined, undefined, undefined, scope)",
219
+ "+ expect(errors).toMatchInlineSnapshot(`[]`)",
220
+ "+ expect(html).toMatchInlineSnapshot(`\"Hello World, you have 3 items\"`)",
221
+ "+})",
222
+ ].join("\n");
223
+ const result = balanceDelimiters(patch, "typescript");
224
+ const lines = result.split("\n");
225
+ expect(lines[3]).toBe(" ` `");
226
+ expect(lines[17]).toBe("+ {${'`'}Hello ${'${'}name${'}'}, you have ${'${'}count${'}'} items${'`'}}");
227
+ expect(() => parsePatch(result)).not.toThrow();
228
+ });
229
+ it("keeps patch unchanged for regex literals with backticks", () => {
230
+ const patch = makePatch([
231
+ " const re = /`+/g",
232
+ " const x = 1",
233
+ "-const y = 2",
234
+ "+const y = 3",
235
+ ]);
236
+ expect(balanceDelimiters(patch, "typescript")).toBe(patch);
237
+ });
238
+ it("appends a synthetic closer when a hunk ends inside a template literal", () => {
239
+ const patch = makePatch([
240
+ " const x = `open template",
241
+ " const y = 1",
242
+ "-const z = 2",
243
+ "+const z = 3",
244
+ ]);
245
+ const result = balanceDelimiters(patch, "typescript");
246
+ const lines = result.split("\n");
247
+ expect(lines[3]).toBe(" const x = `open template");
248
+ expect(lines[lines.length - 1]).toBe("+const z = 3 `");
249
+ });
250
+ it("appends a synthetic block comment closer before the next hunk", () => {
251
+ const patch = [
252
+ "--- file.ts",
253
+ "+++ file.ts",
254
+ "@@ -1,1 +1,3 @@",
255
+ "+/**",
256
+ "+ * open comment",
257
+ " const x = 1",
258
+ "@@ -10,1 +11,1 @@",
259
+ "-const y = 1",
260
+ "+const y = 2",
261
+ ].join("\n");
262
+ const result = balanceDelimiters(patch, "typescript");
263
+ const lines = result.split("\n");
264
+ const secondHunkIdx = lines.findIndex((line, index) => index > 2 && line.startsWith("@@"));
265
+ expect(lines[2]).toBe("@@ -1,1 +1,3 @@");
266
+ expect(lines[5]).toBe(" const x = 1 */");
267
+ expect(secondHunkIdx).toBe(6);
268
+ expect(() => parsePatch(result)).not.toThrow();
269
+ });
270
+ it("appends a synthetic block comment closer at the end of a single hunk", () => {
271
+ const patch = [
272
+ "--- file.ts",
273
+ "+++ file.ts",
274
+ "@@ -20,1 +20,3 @@",
275
+ " interface DelimiterRule {",
276
+ "+/**",
277
+ "+ * Balance paired delimiters in a unified diff patch",
278
+ ].join("\n");
279
+ const result = balanceDelimiters(patch, "typescript");
280
+ const lines = result.split("\n");
281
+ expect(lines[2]).toBe("@@ -20,1 +20,3 @@");
282
+ expect(lines.at(-1)).toBe("+ * Balance paired delimiters in a unified diff patch */");
283
+ expect(() => parsePatch(result)).not.toThrow();
284
+ });
285
+ it("captures the real thread-session-runtime comment leak when a hunk has an earlier closer and later opener", () => {
286
+ const patch = [
287
+ "--- discord/src/session-handler/thread-session-runtime.ts",
288
+ "+++ discord/src/session-handler/thread-session-runtime.ts",
289
+ "@@ -442,12 +443,13 @@",
290
+ ' * "tool:pattern:action"). Parsed into PermissionRuleset entries by',
291
+ ' * parsePermissionRules() and appended after buildSessionPermissions()',
292
+ ' * so they win via opencode\'s findLast() evaluation. Only used on',
293
+ ' * session creation (first dispatch).',
294
+ ' */',
295
+ ' permissions?: string[]',
296
+ '+ injectionGuardPatterns?: string[]',
297
+ " sessionStartSource?: { scheduleKind: 'at' | 'cron'; scheduledTaskId?: number }",
298
+ ' /**',
299
+ ' * Lazy preprocessing callback. When set, the runtime serializes it via a',
300
+ ' * lightweight promise chain (preprocessChain) to resolve prompt/images/mode',
301
+ "@@ -2686,12 +2688,13 @@",
302
+ ' ',
303
+ ' // ── Ensure session ──────────────────────────────────────',
304
+ ' const sessionResult = await this.ensureSession({',
305
+ ' prompt: input.prompt,',
306
+ ' agent: input.agent,',
307
+ ' permissions: input.permissions,',
308
+ '+ injectionGuardPatterns: input.injectionGuardPatterns,',
309
+ ' sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,',
310
+ ' sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,',
311
+ ' })',
312
+ ].join("\n");
313
+ const result = balanceDelimiters(patch, "typescript");
314
+ expect(result).toMatchInlineSnapshot(`
315
+ "--- discord/src/session-handler/thread-session-runtime.ts
316
+ +++ discord/src/session-handler/thread-session-runtime.ts
317
+ @@ -442,12 +443,13 @@
318
+ * \"tool:pattern:action\"). Parsed into PermissionRuleset entries by
319
+ * parsePermissionRules() and appended after buildSessionPermissions()
320
+ * so they win via opencode's findLast() evaluation. Only used on
321
+ * session creation (first dispatch).
322
+ */
323
+ permissions?: string[]
324
+ + injectionGuardPatterns?: string[]
325
+ sessionStartSource?: { scheduleKind: 'at' | 'cron'; scheduledTaskId?: number }
326
+ /**
327
+ * Lazy preprocessing callback. When set, the runtime serializes it via a
328
+ * lightweight promise chain (preprocessChain) to resolve prompt/images/mode */
329
+ @@ -2686,12 +2688,13 @@
330
+
331
+ // ── Ensure session ──────────────────────────────────────
332
+ const sessionResult = await this.ensureSession({
333
+ prompt: input.prompt,
334
+ agent: input.agent,
335
+ permissions: input.permissions,
336
+ + injectionGuardPatterns: input.injectionGuardPatterns,
337
+ sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
338
+ sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
339
+ })"
340
+ `);
341
+ });
342
+ it("appends a synthetic closer when a hunk closes an earlier comment and reopens another one", () => {
343
+ const patch = [
344
+ "--- file.ts",
345
+ "+++ file.ts",
346
+ "@@ -1,3 +1,5 @@",
347
+ " still inside old comment",
348
+ " */",
349
+ " const x = 1",
350
+ "+ /**",
351
+ "+ * reopened comment",
352
+ ].join("\n");
353
+ const result = balanceDelimiters(patch, "typescript");
354
+ const lines = result.split("\n");
355
+ expect(lines[7]).toBe("+ * reopened comment */");
356
+ expect(() => parsePatch(result)).not.toThrow();
357
+ });
358
+ it("preserves no-newline markers", () => {
359
+ const patch = [
360
+ "--- file.ts",
361
+ "+++ file.ts",
362
+ "@@ -1,2 +1,2 @@",
363
+ "-const x = `old",
364
+ "+const x = `new",
365
+ "\",
366
+ ].join("\n");
367
+ const result = balanceDelimiters(patch, "typescript");
368
+ expect(result).toContain("\");
369
+ });
370
+ });
371
+ describe("python", () => {
372
+ const pyPatch = (hunkLines) => [
373
+ "--- file.py",
374
+ "+++ file.py",
375
+ "@@ -10,4 +10,4 @@ def foo():",
376
+ ...hunkLines,
377
+ ].join("\n");
378
+ it("returns patch unchanged when triple quotes are balanced", () => {
379
+ const patch = pyPatch([
380
+ ' """docstring"""',
381
+ "-x = 1",
382
+ "+x = 2",
383
+ " return x",
384
+ ]);
385
+ expect(balanceDelimiters(patch, "python")).toBe(patch);
386
+ });
387
+ it("prepends a synthetic opener when a hunk starts with a closing triple double-quote", () => {
388
+ const patch = pyPatch([
389
+ ' This is still inside the docstring.',
390
+ ' """',
391
+ "- return old_value",
392
+ "+ return new_value",
393
+ ]);
394
+ const result = balanceDelimiters(patch, "python");
395
+ const lines = result.split("\n");
396
+ expect(lines[3]).toBe(' """ This is still inside the docstring.');
397
+ expect(lines[4]).toBe(' """');
398
+ });
399
+ it("prepends a synthetic opener when a hunk starts with a closing triple single-quote", () => {
400
+ const patch = pyPatch([
401
+ " still inside raw string",
402
+ " '''",
403
+ "- x = 1",
404
+ "+ x = 2",
405
+ ]);
406
+ const result = balanceDelimiters(patch, "python");
407
+ const lines = result.split("\n");
408
+ expect(lines[3]).toBe(" ''' still inside raw string");
409
+ expect(lines[4]).toBe(" '''");
410
+ });
411
+ it("returns patch unchanged when regular quotes are present but balanced", () => {
412
+ const patch = pyPatch([
413
+ ' x = "hello"',
414
+ "-y = 'old'",
415
+ "+y = 'new'",
416
+ " return x",
417
+ ]);
418
+ expect(balanceDelimiters(patch, "python")).toBe(patch);
419
+ });
420
+ it("handles multiline docstring closing mid-hunk", () => {
421
+ const patch = pyPatch([
422
+ " Args:",
423
+ " x: the input value",
424
+ ' """',
425
+ "- return x + 1",
426
+ "+ return x + 2",
427
+ ]);
428
+ const result = balanceDelimiters(patch, "python");
429
+ const lines = result.split("\n");
430
+ expect(lines[3]).toBe(' """ Args:');
431
+ expect(lines[5]).toBe(' """');
432
+ });
433
+ it("does not modify when both triple-quote types are balanced", () => {
434
+ const patch = pyPatch([
435
+ ' """docstring"""',
436
+ " x = '''raw'''",
437
+ "-y = 1",
438
+ "+y = 2",
439
+ ]);
440
+ expect(balanceDelimiters(patch, "python")).toBe(patch);
441
+ });
442
+ it("appends a synthetic closer when a hunk ends inside a triple-quoted string", () => {
443
+ const patch = pyPatch([
444
+ ' """docstring starts here',
445
+ " value = 1",
446
+ "-return old_value",
447
+ "+return new_value",
448
+ ]);
449
+ const result = balanceDelimiters(patch, "python");
450
+ const lines = result.split("\n");
451
+ expect(lines[3]).toBe(' """docstring starts here');
452
+ expect(lines[lines.length - 1]).toBe('+return new_value """');
453
+ });
454
+ });
455
+ describe("go", () => {
456
+ const goPatch = (hunkLines) => [
457
+ "--- file.go",
458
+ "+++ file.go",
459
+ "@@ -10,4 +10,4 @@ func foo() {",
460
+ ...hunkLines,
461
+ ].join("\n");
462
+ it("returns patch unchanged when backticks are balanced", () => {
463
+ const patch = goPatch([
464
+ " x := `raw string`",
465
+ "-y := 1",
466
+ "+y := 2",
467
+ " return x",
468
+ ]);
469
+ expect(balanceDelimiters(patch, "go")).toBe(patch);
470
+ });
471
+ it("prepends a synthetic opener when a hunk starts with a closing backtick", () => {
472
+ const patch = goPatch([
473
+ " still inside raw string`",
474
+ " x := 1",
475
+ "-y := 2",
476
+ "+y := 3",
477
+ ]);
478
+ const result = balanceDelimiters(patch, "go");
479
+ const lines = result.split("\n");
480
+ expect(lines[3]).toBe(" ` still inside raw string`");
481
+ });
482
+ it("appends a synthetic block comment closer when a hunk leaves one open", () => {
483
+ const patch = [
484
+ "--- file.go",
485
+ "+++ file.go",
486
+ "@@ -10,2 +10,3 @@ func foo() {",
487
+ " /*",
488
+ " still inside comment",
489
+ "+x := 1",
490
+ ].join("\n");
491
+ const result = balanceDelimiters(patch, "go");
492
+ const lines = result.split("\n");
493
+ expect(lines[2]).toBe("@@ -10,2 +10,3 @@ func foo() {");
494
+ expect(lines.at(-1)).toBe("+x := 1 */");
495
+ expect(() => parsePatch(result)).not.toThrow();
496
+ });
497
+ });
498
+ describe("rust", () => {
499
+ it("appends a block comment closer to the last content line", () => {
500
+ const patch = [
501
+ "--- file.rs",
502
+ "+++ file.rs",
503
+ "@@ -10,1 +10,3 @@ fn demo() {",
504
+ "+/*",
505
+ "+ * open comment",
506
+ " let x = 1",
507
+ ].join("\n");
508
+ const result = balanceDelimiters(patch, "rust");
509
+ const lines = result.split("\n");
510
+ expect(lines[5]).toBe(" let x = 1 */");
511
+ expect(() => parsePatch(result)).not.toThrow();
512
+ });
513
+ });
514
+ describe("html", () => {
515
+ it("appends an HTML comment closer to isolate the next hunk", () => {
516
+ const patch = [
517
+ "--- file.html",
518
+ "+++ file.html",
519
+ "@@ -1,1 +1,3 @@",
520
+ "+<!--",
521
+ "+ open comment",
522
+ " <div>content</div>",
523
+ "@@ -10,1 +11,1 @@",
524
+ "-<span>old</span>",
525
+ "+<span>new</span>",
526
+ ].join("\n");
527
+ const result = balanceDelimiters(patch, "html");
528
+ const lines = result.split("\n");
529
+ const secondHunkIdx = lines.findIndex((line, index) => index > 2 && line.startsWith("@@"));
530
+ expect(lines[5]).toBe(" <div>content</div> -->");
531
+ expect(secondHunkIdx).toBe(6);
532
+ expect(() => parsePatch(result)).not.toThrow();
533
+ });
534
+ });
535
+ describe("markdown", () => {
536
+ const mdPatch = (hunkLines) => [
537
+ "--- file.md",
538
+ "+++ file.md",
539
+ "@@ -10,4 +10,4 @@",
540
+ ...hunkLines,
541
+ ].join("\n");
542
+ it("returns patch unchanged when code fences are balanced", () => {
543
+ const patch = mdPatch([
544
+ " ```ts",
545
+ " const x = 1",
546
+ " ```",
547
+ "+New paragraph",
548
+ ]);
549
+ expect(balanceDelimiters(patch, "markdown")).toBe(patch);
550
+ });
551
+ it("prepends synthetic opener when hunk starts inside a code block", () => {
552
+ const patch = mdPatch([
553
+ " inside fenced block",
554
+ " ```",
555
+ "-old line",
556
+ "+new line",
557
+ ]);
558
+ const result = balanceDelimiters(patch, "markdown");
559
+ const lines = result.split("\n");
560
+ // Synthetic ``` opener prepended inline to first content line
561
+ expect(lines[3]).toBe(" ``` inside fenced block");
562
+ expect(lines[4]).toBe(" ```");
563
+ });
564
+ it("does not modify when only inline code backticks are present", () => {
565
+ const patch = mdPatch([
566
+ " This has `inline` code",
567
+ "-old",
568
+ "+new",
569
+ ]);
570
+ expect(balanceDelimiters(patch, "markdown")).toBe(patch);
571
+ });
572
+ it("appends synthetic closer when hunk ends with unclosed opener", () => {
573
+ const patch = mdPatch([
574
+ " ```ts",
575
+ " const x = 1",
576
+ "-old line",
577
+ "+new line",
578
+ ]);
579
+ const result = balanceDelimiters(patch, "markdown");
580
+ const lines = result.split("\n");
581
+ // Original content untouched
582
+ expect(lines[3]).toBe(" ```ts");
583
+ expect(lines[4]).toBe(" const x = 1");
584
+ // Synthetic ``` closer appended inline to last content line
585
+ expect(lines[lines.length - 1]).toBe("+new line ```");
586
+ });
587
+ it("adds synthetic fences at both boundaries when even count but first=closer last=opener (6 tokens)", () => {
588
+ const patch = mdPatch([
589
+ " ```",
590
+ " ",
591
+ " ## Section",
592
+ " ",
593
+ " ```ts",
594
+ " const a = 1",
595
+ " ```",
596
+ " ",
597
+ " ```ts",
598
+ " const b = 2",
599
+ " ```",
600
+ " ",
601
+ "+```ts",
602
+ ]);
603
+ const result = balanceDelimiters(patch, "markdown");
604
+ const lines = result.split("\n");
605
+ // Synthetic opener prepended inline to first content line
606
+ expect(lines[3]).toBe(" ``` ```");
607
+ // Synthetic closer appended inline to last content line
608
+ expect(lines[lines.length - 1]).toBe("+```ts ```");
609
+ // middle fences stay untouched
610
+ expect(lines[7]).toBe(" ```ts");
611
+ expect(lines[9]).toBe(" ```");
612
+ expect(lines[11]).toBe(" ```ts");
613
+ expect(lines[13]).toBe(" ```");
614
+ });
615
+ it("adds synthetic fences at both boundaries with 4 tokens (bare, ```ts, bare, ```ts)", () => {
616
+ const patch = mdPatch([
617
+ " inside code block",
618
+ " ```",
619
+ " ",
620
+ " ```ts",
621
+ " const x = 1",
622
+ " ```",
623
+ " ",
624
+ "+```ts",
625
+ ]);
626
+ const result = balanceDelimiters(patch, "markdown");
627
+ const lines = result.split("\n");
628
+ // Synthetic opener prepended inline to first content line
629
+ expect(lines[3]).toBe(" ``` inside code block");
630
+ // Synthetic closer appended inline to last content line
631
+ expect(lines[lines.length - 1]).toBe("+```ts ```");
632
+ });
633
+ it("returns patch unchanged when 4 tokens are fully balanced (```ts, bare, ```ts, bare)", () => {
634
+ const patch = mdPatch([
635
+ " ```ts",
636
+ " const a = 1",
637
+ " ```",
638
+ " ",
639
+ " ```ts",
640
+ " const b = 2",
641
+ " ```",
642
+ "+New paragraph",
643
+ ]);
644
+ expect(balanceDelimiters(patch, "markdown")).toBe(patch);
645
+ });
646
+ it("adds synthetic fences at both boundaries when 2 tokens are bare-closer then opener", () => {
647
+ const patch = mdPatch([
648
+ " inside block",
649
+ " ```",
650
+ " ",
651
+ "+```ts",
652
+ ]);
653
+ const result = balanceDelimiters(patch, "markdown");
654
+ const lines = result.split("\n");
655
+ // Synthetic opener prepended inline to first content line
656
+ expect(lines[3]).toBe(" ``` inside block");
657
+ // Synthetic closer appended inline to last content line
658
+ expect(lines[lines.length - 1]).toBe("+```ts ```");
659
+ });
660
+ it("returns patch unchanged for 2 balanced tokens (```ts then bare)", () => {
661
+ const patch = mdPatch([
662
+ " ```ts",
663
+ " const x = 1",
664
+ " ```",
665
+ "+New paragraph",
666
+ ]);
667
+ expect(balanceDelimiters(patch, "markdown")).toBe(patch);
668
+ });
669
+ it("returns patch unchanged for two bare fences (open + close, no language)", () => {
670
+ const patch = mdPatch([
671
+ " ```",
672
+ " some code",
673
+ " ```",
674
+ "+New paragraph",
675
+ ]);
676
+ expect(balanceDelimiters(patch, "markdown")).toBe(patch);
677
+ });
678
+ it("ignores inline triple backticks in prose (not at start of line)", () => {
679
+ const patch = mdPatch([
680
+ " Use the ``` delimiter for code fences",
681
+ "-old",
682
+ "+new",
683
+ ]);
684
+ expect(balanceDelimiters(patch, "markdown")).toBe(patch);
685
+ });
686
+ it("treats fences with up to 3 spaces indent as valid", () => {
687
+ const patch = mdPatch([
688
+ " ```ts",
689
+ " const x = 1",
690
+ " ```",
691
+ "+New paragraph",
692
+ ]);
693
+ // 3 spaces + ``` = column 3, indent 3 = valid fence
694
+ expect(balanceDelimiters(patch, "markdown")).toBe(patch);
695
+ });
696
+ it("ignores fences indented more than 3 spaces (code indentation)", () => {
697
+ const patch = mdPatch([
698
+ " ```ts",
699
+ " const x = 1",
700
+ "-old",
701
+ "+new",
702
+ ]);
703
+ // 4+ spaces = not a fence, treated as code content → no escaping
704
+ expect(balanceDelimiters(patch, "markdown")).toBe(patch);
705
+ });
706
+ it("handles 4-backtick fence pair correctly", () => {
707
+ const patch = mdPatch([
708
+ " ````ts",
709
+ " const x = 1",
710
+ " ````",
711
+ "+New paragraph",
712
+ ]);
713
+ expect(balanceDelimiters(patch, "markdown")).toBe(patch);
714
+ });
715
+ it("recognizes closing fence with trailing spaces", () => {
716
+ const patch = mdPatch([
717
+ " ```ts",
718
+ " const x = 1",
719
+ " ``` ",
720
+ "+New paragraph",
721
+ ]);
722
+ expect(balanceDelimiters(patch, "markdown")).toBe(patch);
723
+ });
724
+ it("single bare fence with content on both sides treats as opener (appends closer)", () => {
725
+ // Ambiguous: bare ``` with content before AND after.
726
+ // Tie-break prefers depth=1 (prepend opener) since content exists before fence.
727
+ const patch = mdPatch([
728
+ " Intro paragraph",
729
+ " ```",
730
+ " code line",
731
+ "+new line",
732
+ ]);
733
+ const result = balanceDelimiters(patch, "markdown");
734
+ const lines = result.split("\n");
735
+ // Prepend opener on first content line (content before fence)
736
+ expect(lines[3]).toBe(" ``` Intro paragraph");
737
+ expect(lines[4]).toBe(" ```");
738
+ });
739
+ it("single bare fence with content only after treats as opener (appends closer)", () => {
740
+ const patch = mdPatch([
741
+ " ```",
742
+ " code line",
743
+ "-old",
744
+ "+new",
745
+ ]);
746
+ const result = balanceDelimiters(patch, "markdown");
747
+ const lines = result.split("\n");
748
+ // No content before fence → depth=0 → append closer
749
+ expect(lines[3]).toBe(" ```");
750
+ expect(lines[lines.length - 1]).toBe("+new ```");
751
+ });
752
+ it("prefers blank line for synthetic opener to avoid fake info string", () => {
753
+ const patch = mdPatch([
754
+ " ",
755
+ " inside code block",
756
+ " ```",
757
+ "-old line",
758
+ "+new line",
759
+ ]);
760
+ const result = balanceDelimiters(patch, "markdown");
761
+ const lines = result.split("\n");
762
+ // Blank line before fence is used for synthetic opener (no fake info string)
763
+ expect(lines[3]).toBe(" ``` ");
764
+ expect(lines[4]).toBe(" inside code block");
765
+ expect(lines[5]).toBe(" ```");
766
+ });
767
+ it("handles two hunks independently for markdown fences", () => {
768
+ const patch = [
769
+ "--- file.md",
770
+ "+++ file.md",
771
+ "@@ -5,4 +5,4 @@",
772
+ " ```ts",
773
+ " const x = 1",
774
+ " ```",
775
+ "+New paragraph",
776
+ "@@ -20,4 +20,4 @@",
777
+ " inside block",
778
+ " ```",
779
+ "-old line",
780
+ "+new line",
781
+ ].join("\n");
782
+ const result = balanceDelimiters(patch, "markdown");
783
+ const lines = result.split("\n");
784
+ // First hunk: balanced, no changes
785
+ expect(lines[3]).toBe(" ```ts");
786
+ expect(lines[5]).toBe(" ```");
787
+ // Second hunk: bare closer at boundary → synthetic opener prepended inline
788
+ const secondHunkIdx = lines.findIndex((l, i) => i > 2 && l.startsWith("@@"));
789
+ expect(lines[secondHunkIdx + 1]).toBe(" ``` inside block");
790
+ expect(lines[secondHunkIdx + 2]).toBe(" ```");
791
+ });
792
+ it("handles real README.md hunk with boundary fences (from critique.work patch)", () => {
793
+ // Real hunk from https://critique.work/v/daa808658ee537a745b80101ba3195ae.patch
794
+ // First hunk: starts inside a code block (bare ``` closer), ends with ```ts opener
795
+ const patch = [
796
+ "--- README.md",
797
+ "+++ README.md",
798
+ "@@ -741,12 +741,14 @@",
799
+ " }),",
800
+ " )",
801
+ " ```",
802
+ " ",
803
+ " ## Base Path",
804
+ " ",
805
+ "+For standalone API servers (without Vite), set the base path in the constructor:",
806
+ "+",
807
+ " ```ts",
808
+ " import { Spiceflow } from 'spiceflow'",
809
+ " ",
810
+ " const app = new Spiceflow({ basePath: '/api/v1' })",
811
+ " app.route({",
812
+ " method: 'GET',",
813
+ "@@ -754,12 +756,47 @@",
814
+ " handler() {",
815
+ " return 'Hello'",
816
+ " },",
817
+ " }) // Accessible at /api/v1/hello",
818
+ " ```",
819
+ " ",
820
+ "+### Base Path with Vite (RSC apps)",
821
+ "+",
822
+ "+When using Spiceflow as a full-stack RSC framework with Vite, configure the base path via Vite's `base` option instead of the constructor:",
823
+ "+",
824
+ "+```ts",
825
+ "+// vite.config.ts",
826
+ "+import { defineConfig } from 'vite'",
827
+ "+import { spiceflowPlugin } from 'spiceflow/vite'",
828
+ "+",
829
+ "+export default defineConfig({",
830
+ "+ base: '/my-app',",
831
+ "+ plugins: [spiceflowPlugin({ entry: 'src/main.tsx' })],",
832
+ "+})",
833
+ "+```",
834
+ "+",
835
+ "+The base path must be an absolute path starting with `/`. CDN URLs and relative paths are not supported.",
836
+ "+",
837
+ " ## Async Generators (Streaming)",
838
+ " ",
839
+ " Async generators will create a server sent event response.",
840
+ " ",
841
+ " ```ts",
842
+ " // server.ts",
843
+ ].join("\n");
844
+ const result = balanceDelimiters(patch, "markdown");
845
+ expect(result).toMatchInlineSnapshot(`
846
+ "--- README.md
847
+ +++ README.md
848
+ @@ -741,12 +741,14 @@
849
+ \`\`\` }),
850
+ )
851
+ \`\`\`
852
+
853
+ ## Base Path
854
+
855
+ +For standalone API servers (without Vite), set the base path in the constructor:
856
+ +
857
+ \`\`\`ts
858
+ import { Spiceflow } from 'spiceflow'
859
+
860
+ const app = new Spiceflow({ basePath: '/api/v1' })
861
+ app.route({
862
+ method: 'GET', \`\`\`
863
+ @@ -754,12 +756,47 @@
864
+ \`\`\` handler() {
865
+ return 'Hello'
866
+ },
867
+ }) // Accessible at /api/v1/hello
868
+ \`\`\`
869
+
870
+ +### Base Path with Vite (RSC apps)
871
+ +
872
+ +When using Spiceflow as a full-stack RSC framework with Vite, configure the base path via Vite's \`base\` option instead of the constructor:
873
+ +
874
+ +\`\`\`ts
875
+ +// vite.config.ts
876
+ +import { defineConfig } from 'vite'
877
+ +import { spiceflowPlugin } from 'spiceflow/vite'
878
+ +
879
+ +export default defineConfig({
880
+ + base: '/my-app',
881
+ + plugins: [spiceflowPlugin({ entry: 'src/main.tsx' })],
882
+ +})
883
+ +\`\`\`
884
+ +
885
+ +The base path must be an absolute path starting with \`/\`. CDN URLs and relative paths are not supported.
886
+ +
887
+ ## Async Generators (Streaming)
888
+
889
+ Async generators will create a server sent event response.
890
+
891
+ \`\`\`ts
892
+ // server.ts \`\`\`"
893
+ `);
894
+ });
895
+ });
896
+ describe("scala", () => {
897
+ const scalaPatch = (hunkLines) => [
898
+ "--- file.scala",
899
+ "+++ file.scala",
900
+ "@@ -10,4 +10,4 @@ object Main {",
901
+ ...hunkLines,
902
+ ].join("\n");
903
+ it("returns patch unchanged when triple quotes are balanced", () => {
904
+ const patch = scalaPatch([
905
+ ' val s = """multi',
906
+ ' line string"""',
907
+ "-val x = 1",
908
+ "+val x = 2",
909
+ ]);
910
+ expect(balanceDelimiters(patch, "scala")).toBe(patch);
911
+ });
912
+ it("prepends a synthetic opener when a hunk starts with a closing triple quote", () => {
913
+ const patch = scalaPatch([
914
+ " still inside string",
915
+ ' """.stripMargin',
916
+ "- val x = 1",
917
+ "+ val x = 2",
918
+ ]);
919
+ const result = balanceDelimiters(patch, "scala");
920
+ const lines = result.split("\n");
921
+ expect(lines[3]).toBe(' """ still inside string');
922
+ expect(lines[4]).toBe(' """.stripMargin');
923
+ });
924
+ });
925
+ describe("swift", () => {
926
+ const swiftPatch = (hunkLines) => [
927
+ "--- file.swift",
928
+ "+++ file.swift",
929
+ "@@ -10,4 +10,4 @@ func foo() {",
930
+ ...hunkLines,
931
+ ].join("\n");
932
+ it("returns patch unchanged when triple quotes are balanced", () => {
933
+ const patch = swiftPatch([
934
+ ' let s = """',
935
+ ' multi-line string',
936
+ ' """',
937
+ "-let x = 1",
938
+ ]);
939
+ expect(balanceDelimiters(patch, "swift")).toBe(patch);
940
+ });
941
+ it("prepends a synthetic opener when a hunk starts with a closing triple quote", () => {
942
+ const patch = swiftPatch([
943
+ " still inside multi-line string",
944
+ ' """',
945
+ "- let x = 1",
946
+ "+ let x = 2",
947
+ ]);
948
+ const result = balanceDelimiters(patch, "swift");
949
+ const lines = result.split("\n");
950
+ expect(lines[3]).toBe(' """ still inside multi-line string');
951
+ expect(lines[4]).toBe(' """');
952
+ });
953
+ });
954
+ describe("julia", () => {
955
+ const juliaPatch = (hunkLines) => [
956
+ "--- file.jl",
957
+ "+++ file.jl",
958
+ "@@ -10,4 +10,4 @@ function foo()",
959
+ ...hunkLines,
960
+ ].join("\n");
961
+ it("returns patch unchanged when triple quotes are balanced", () => {
962
+ const patch = juliaPatch([
963
+ ' s = """multi-line"""',
964
+ "-x = 1",
965
+ "+x = 2",
966
+ " return x",
967
+ ]);
968
+ expect(balanceDelimiters(patch, "julia")).toBe(patch);
969
+ });
970
+ it("prepends a synthetic opener when a hunk starts with a closing triple quote", () => {
971
+ const patch = juliaPatch([
972
+ " still inside string",
973
+ ' """',
974
+ "- x = 1",
975
+ "+ x = 2",
976
+ ]);
977
+ const result = balanceDelimiters(patch, "julia");
978
+ const lines = result.split("\n");
979
+ expect(lines[3]).toBe(' """ still inside string');
980
+ expect(lines[4]).toBe(' """');
981
+ });
982
+ });
983
+ describe("edge cases", () => {
984
+ it("returns unchanged when no hunks present", () => {
985
+ const patch = "--- file.ts\n+++ file.ts";
986
+ expect(balanceDelimiters(patch, "typescript")).toBe(patch);
987
+ });
988
+ it("handles empty hunk", () => {
989
+ const patch = "--- file.ts\n+++ file.ts\n@@ -1,0 +1,0 @@";
990
+ expect(balanceDelimiters(patch, "typescript")).toBe(patch);
991
+ });
992
+ it("does not modify hunk with only no-newline markers", () => {
993
+ const patch = [
994
+ "--- file.py",
995
+ "+++ file.py",
996
+ "@@ -1,0 +1,0 @@",
997
+ "\",
998
+ ].join("\n");
999
+ expect(balanceDelimiters(patch, "python")).toBe(patch);
1000
+ });
1001
+ it("appends a synthetic closer for an unmatched opener on an added line", () => {
1002
+ const patch = [
1003
+ "--- file.ts",
1004
+ "+++ file.ts",
1005
+ "@@ -0,0 +1,3 @@",
1006
+ "+const x = `open template",
1007
+ "+ content",
1008
+ "+ more",
1009
+ ].join("\n");
1010
+ const result = balanceDelimiters(patch, "typescript");
1011
+ const lines = result.split("\n");
1012
+ expect(lines[3]).toBe("+const x = `open template");
1013
+ expect(lines[lines.length - 1]).toBe("+ more `");
1014
+ });
1015
+ it("appends a synthetic closer for an unmatched opener on a removed line", () => {
1016
+ const patch = [
1017
+ "--- file.ts",
1018
+ "+++ file.ts",
1019
+ "@@ -5,2 +5,0 @@",
1020
+ "-const x = `old template",
1021
+ "- content",
1022
+ ].join("\n");
1023
+ const result = balanceDelimiters(patch, "typescript");
1024
+ const lines = result.split("\n");
1025
+ expect(lines[3]).toBe("-const x = `old template");
1026
+ expect(lines[lines.length - 1]).toBe("- content `");
1027
+ });
1028
+ });
1029
+ });