@misterhuydo/sentinel 1.6.11 → 1.6.13

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 (48) hide show
  1. package/.cairn/.hint-lock +1 -1
  2. package/.cairn/session.json +2 -2
  3. package/package.json +1 -1
  4. package/python/sentinel/__init__.py +1 -1
  5. package/python/sentinel/__pycache__/fix_engine.cpython-311.pyc +0 -0
  6. package/python/sentinel/__pycache__/git_manager.cpython-311.pyc +0 -0
  7. package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
  8. package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
  9. package/python/sentinel/fix_engine.py +10 -0
  10. package/python/sentinel/git_manager.py +246 -7
  11. package/python/sentinel/main.py +5 -1
  12. package/python/sentinel/sentinel_boss.py +21 -0
  13. package/python/tests/test_patch_repair.py +240 -0
  14. package/python/scripts/__pycache__/fix_ask_codebase_context.cpython-311.pyc +0 -0
  15. package/python/scripts/__pycache__/fix_ask_codebase_stdin.cpython-311.pyc +0 -0
  16. package/python/scripts/__pycache__/fix_chain_slack.cpython-311.pyc +0 -0
  17. package/python/scripts/__pycache__/fix_fstring.cpython-311.pyc +0 -0
  18. package/python/scripts/__pycache__/fix_knowledge_cache.cpython-311.pyc +0 -0
  19. package/python/scripts/__pycache__/fix_knowledge_cache_staleness.cpython-311.pyc +0 -0
  20. package/python/scripts/__pycache__/fix_merge_confirm.cpython-311.pyc +0 -0
  21. package/python/scripts/__pycache__/fix_permission_messages.cpython-311.pyc +0 -0
  22. package/python/scripts/__pycache__/fix_pr_check_head_detect.cpython-311.pyc +0 -0
  23. package/python/scripts/__pycache__/fix_pr_msg_newlines.cpython-311.pyc +0 -0
  24. package/python/scripts/__pycache__/fix_pr_tracking_boss.cpython-311.pyc +0 -0
  25. package/python/scripts/__pycache__/fix_pr_tracking_db.cpython-311.pyc +0 -0
  26. package/python/scripts/__pycache__/fix_pr_tracking_main.cpython-311.pyc +0 -0
  27. package/python/scripts/__pycache__/fix_project_isolation.cpython-311.pyc +0 -0
  28. package/python/scripts/__pycache__/fix_system_prompt.cpython-311.pyc +0 -0
  29. package/python/scripts/__pycache__/fix_two_bugs.cpython-311.pyc +0 -0
  30. package/python/scripts/__pycache__/patch_chain_release.cpython-311.pyc +0 -0
  31. package/python/sentinel/__pycache__/__init__.cpython-311.pyc +0 -0
  32. package/python/sentinel/__pycache__/cairn_client.cpython-311.pyc +0 -0
  33. package/python/sentinel/__pycache__/cicd_trigger.cpython-311.pyc +0 -0
  34. package/python/sentinel/__pycache__/config_loader.cpython-311.pyc +0 -0
  35. package/python/sentinel/__pycache__/dependency_manager.cpython-311.pyc +0 -0
  36. package/python/sentinel/__pycache__/dev_watcher.cpython-311.pyc +0 -0
  37. package/python/sentinel/__pycache__/health_checker.cpython-311.pyc +0 -0
  38. package/python/sentinel/__pycache__/issue_watcher.cpython-311.pyc +0 -0
  39. package/python/sentinel/__pycache__/log_fetcher.cpython-311.pyc +0 -0
  40. package/python/sentinel/__pycache__/log_parser.cpython-311.pyc +0 -0
  41. package/python/sentinel/__pycache__/log_syncer.cpython-311.pyc +0 -0
  42. package/python/sentinel/__pycache__/notify.cpython-311.pyc +0 -0
  43. package/python/sentinel/__pycache__/repo_router.cpython-311.pyc +0 -0
  44. package/python/sentinel/__pycache__/repo_task_engine.cpython-311.pyc +0 -0
  45. package/python/sentinel/__pycache__/reporter.cpython-311.pyc +0 -0
  46. package/python/sentinel/__pycache__/sentinel_dev.cpython-311.pyc +0 -0
  47. package/python/sentinel/__pycache__/slack_bot.cpython-311.pyc +0 -0
  48. package/python/sentinel/__pycache__/state_store.cpython-311.pyc +0 -0
package/.cairn/.hint-lock CHANGED
@@ -1 +1 @@
1
- 2026-04-27T13:09:20.340Z
1
+ 2026-04-27T17:46:15.506Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-04-27T12:15:40.415Z",
3
- "checkpoint_at": "2026-04-27T12:15:40.417Z",
2
+ "message": "Auto-checkpoint at 2026-04-27T17:47:43.806Z",
3
+ "checkpoint_at": "2026-04-27T17:47:43.808Z",
4
4
  "active_files": [
5
5
  "J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js",
6
6
  "J:\\Projects\\Sentinel\\cli\\lib\\test.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.6.11",
3
+ "version": "1.6.13",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -1 +1 @@
1
- __version__ = "1.6.11"
1
+ __version__ = "1.6.13"
@@ -206,6 +206,16 @@ def _build_prompt(
206
206
  " AFTER. Sentinel will merge in this order.",
207
207
  "7. Do not explain. Output only the patch (and the header if multi-repo).",
208
208
  "",
209
+ "HUNK CONTEXT — do NOT extend context to EOF",
210
+ " End each hunk's context immediately after your last `+`/`-` change.",
211
+ " Specifically: do NOT include the class-closing `}` (or any trailing",
212
+ " blank/whitespace-only lines that lead to it) as context. Many files",
213
+ " have stray blank lines or trailing tabs between the last method's",
214
+ " closing brace and the file's final brace; if you anchor context on",
215
+ " those, your line counts won't match and `git apply` will reject the",
216
+ " patch. Keep hunks tight: 3 lines of context before/after the change",
217
+ " is plenty.",
218
+ "",
209
219
  "ESCALATION SIGNALS",
210
220
  "8. Only if you truly cannot produce a safe fix — e.g. the root cause requires a",
211
221
  " DB schema change, infrastructure update, business logic decision, or is inside",
@@ -104,6 +104,215 @@ def parse_multi_repo_patch(combined_patch: str) -> dict:
104
104
  return {"affected_repos": ordered, "patches": patches, "summary": summary}
105
105
 
106
106
 
107
+ # ── Patch rewriter: splice missing intermediate context back in ───────────────
108
+ #
109
+ # LLM-generated diffs sometimes drop blank or whitespace-only lines from a
110
+ # hunk's context — most often the trailing whitespace between a method's
111
+ # closing brace and the file's class-closing brace. `git apply` requires
112
+ # context to match the file *contiguously*, so a missing line breaks the
113
+ # whole hunk. `--ignore-whitespace` ignores whitespace within a line; it does
114
+ # NOT let context skip an entire line. The rewriter reads the actual file
115
+ # and splices the skipped lines back as ' ' context.
116
+
117
+ _HUNK_HEADER_RE = re.compile(
118
+ r"^@@ -(?P<old_start>\d+)(?:,(?P<old_count>\d+))? \+(?P<new_start>\d+)(?:,(?P<new_count>\d+))? @@"
119
+ )
120
+ _PATCH_FILE_HEADER_RE = re.compile(r"^\+\+\+ b/(.+?)\s*$", re.MULTILINE)
121
+
122
+
123
+ def _line_match(file_line: str, patch_body: str) -> bool:
124
+ """Compare ignoring trailing whitespace and CR (LF mismatch tolerant)."""
125
+ return file_line.rstrip() == patch_body.rstrip()
126
+
127
+
128
+ def _strip_eol(s: str) -> str:
129
+ if s.endswith("\r\n"):
130
+ return s[:-2]
131
+ if s.endswith("\n") or s.endswith("\r"):
132
+ return s[:-1]
133
+ return s
134
+
135
+
136
+ def _repair_hunk_body(
137
+ file_lines: list[str],
138
+ anchor: int,
139
+ body_lines: list[str],
140
+ window: int = 8,
141
+ ) -> tuple[list[str], int]:
142
+ """Walk the hunk body against the actual file and splice missing context.
143
+
144
+ `body_lines` are full patch lines (with leading op char + newline).
145
+ `anchor` is the 1-based line number from `@@ -N`. Returns
146
+ (repaired_body_lines, repaired_anchor). Lines for which no match is
147
+ found within `window` are left alone — git apply will surface those.
148
+ """
149
+ # Detect the EOL style used by the body so spliced context lines match.
150
+ eol = "\n"
151
+ for ln in body_lines:
152
+ if ln.endswith("\r\n"):
153
+ eol = "\r\n"; break
154
+ if ln.endswith("\n"):
155
+ eol = "\n"; break
156
+
157
+ # Calibrate the anchor: search for the first ' '/'-' line within +/- window
158
+ # of the declared start. Most LLMs get the line number close-but-not-exact.
159
+ first_body: str | None = None
160
+ for ln in body_lines:
161
+ if not ln or ln in ("\n", "\r\n"):
162
+ first_body = ""; break
163
+ op = ln[0]
164
+ if op in (" ", "-"):
165
+ first_body = _strip_eol(ln[1:])
166
+ break
167
+ if op == "+":
168
+ continue
169
+ pos = max(0, anchor - 1)
170
+ if first_body is not None:
171
+ candidates: list[int] = []
172
+ for offset in range(-window, window + 1):
173
+ p = (anchor - 1) + offset
174
+ if 0 <= p < len(file_lines) and _line_match(file_lines[p], first_body):
175
+ candidates.append(p)
176
+ if candidates:
177
+ pos = min(candidates, key=lambda p: abs(p - (anchor - 1)))
178
+
179
+ new_anchor = pos + 1
180
+
181
+ out: list[str] = []
182
+ for ln in body_lines:
183
+ if not ln:
184
+ continue
185
+ if ln in ("\n", "\r\n"):
186
+ # Bare newline = blank context (treat as " ")
187
+ op, body = " ", ""
188
+ else:
189
+ op = ln[0]
190
+ body = _strip_eol(ln[1:])
191
+ if op == "+":
192
+ out.append(ln)
193
+ continue
194
+ if op == "\\": # ""
195
+ out.append(ln)
196
+ continue
197
+ # ' ' or '-': must match the next file line.
198
+ if pos < len(file_lines) and _line_match(file_lines[pos], body):
199
+ out.append(ln)
200
+ pos += 1
201
+ continue
202
+ # Look ahead — if the patch's context line shows up within `window`
203
+ # lines, splice the skipped file lines in as ' ' context.
204
+ found = -1
205
+ for k in range(1, window + 1):
206
+ if pos + k < len(file_lines) and _line_match(file_lines[pos + k], body):
207
+ found = pos + k
208
+ break
209
+ if found >= 0:
210
+ for k in range(pos, found):
211
+ out.append(" " + file_lines[k] + eol)
212
+ out.append(ln)
213
+ pos = found + 1
214
+ else:
215
+ out.append(ln)
216
+ return out, new_anchor
217
+
218
+
219
+ def _rewrite_hunk_header(header_line: str, new_anchor: int) -> str:
220
+ """Replace the old_start in `@@ -N,M +X,Y @@ ...` with new_anchor.
221
+
222
+ Preserves the optional `,M` count and everything after the second `@@`.
223
+ """
224
+ return re.sub(
225
+ r"^(@@ -)\d+(,?\d*)( \+\d+,?\d* @@.*)$",
226
+ lambda m: f"{m.group(1)}{new_anchor}{m.group(2)}{m.group(3)}",
227
+ header_line,
228
+ count=1,
229
+ )
230
+
231
+
232
+ def repair_patch_against_files(patch_text: str, repo_root) -> str:
233
+ """Rewrite a patch in place against actual file content.
234
+
235
+ Reads each `+++ b/<path>` target from `repo_root` and walks every hunk's
236
+ context, splicing in any intermediate file lines the LLM dropped. Works
237
+ on patches whose paths are already stripped of any `repos/<name>/`
238
+ prefix (i.e. the per-repo sub-patch).
239
+
240
+ If a target file is missing or unreadable, the chunk is passed through
241
+ untouched. If a hunk's context can't be matched even with lookahead,
242
+ that hunk is also passed through — git apply will produce its normal
243
+ error. The rewriter is best-effort: it never makes a patch worse.
244
+ """
245
+ if not patch_text:
246
+ return patch_text
247
+ repo_root = Path(repo_root)
248
+
249
+ chunks = re.split(r"^(?=diff --git )", patch_text, flags=re.MULTILINE)
250
+ out_parts: list[str] = []
251
+ for chunk in chunks:
252
+ if not chunk.startswith("diff --git "):
253
+ out_parts.append(chunk)
254
+ continue
255
+ m = _PATCH_FILE_HEADER_RE.search(chunk)
256
+ if not m:
257
+ out_parts.append(chunk)
258
+ continue
259
+ relpath = m.group(1).strip()
260
+ # Strip a/ b/ prefix tolerance: git uses a/b but some tools strip them
261
+ if relpath.startswith("b/"):
262
+ relpath = relpath[2:]
263
+ fpath = repo_root / relpath
264
+ if not fpath.is_file():
265
+ out_parts.append(chunk)
266
+ continue
267
+ try:
268
+ text = fpath.read_text(encoding="utf-8", errors="replace")
269
+ except Exception:
270
+ out_parts.append(chunk)
271
+ continue
272
+ file_lines = text.splitlines() # Strips line endings; matching is rstrip-tolerant
273
+ out_parts.append(_rewrite_chunk_hunks(chunk, file_lines))
274
+ return "".join(out_parts)
275
+
276
+
277
+ def _rewrite_chunk_hunks(chunk: str, file_lines: list[str]) -> str:
278
+ """Walk hunks within a single-file chunk and repair each body."""
279
+ lines = chunk.splitlines(keepends=True)
280
+ out: list[str] = []
281
+ i = 0
282
+ while i < len(lines):
283
+ line = lines[i]
284
+ bare = _strip_eol(line)
285
+ m = _HUNK_HEADER_RE.match(bare)
286
+ if not m:
287
+ out.append(line)
288
+ i += 1
289
+ continue
290
+ anchor = int(m.group("old_start"))
291
+ # Collect the body until the next hunk header or section break.
292
+ body: list[str] = []
293
+ j = i + 1
294
+ while j < len(lines):
295
+ nxt = lines[j]
296
+ nxt_bare = _strip_eol(nxt)
297
+ if _HUNK_HEADER_RE.match(nxt_bare):
298
+ break
299
+ if nxt_bare.startswith(("diff --git ", "--- ", "+++ ", "index ", "Binary ")):
300
+ break
301
+ body.append(nxt)
302
+ j += 1
303
+ new_body, new_anchor = _repair_hunk_body(file_lines, anchor, body)
304
+ new_header = _rewrite_hunk_header(bare, new_anchor)
305
+ # Preserve trailing newline of the original header line.
306
+ if line.endswith("\r\n"):
307
+ new_header += "\r\n"
308
+ elif line.endswith("\n"):
309
+ new_header += "\n"
310
+ out.append(new_header)
311
+ out.extend(new_body)
312
+ i = j
313
+ return "".join(out)
314
+
315
+
107
316
  class MissingToolError(Exception):
108
317
  """Raised when a required build tool (e.g. mvn, gradle) is not installed."""
109
318
  def __init__(self, tool: str):
@@ -268,12 +477,22 @@ def apply_and_commit(
268
477
  logger.error("git pull failed for %s:\n%s", repo.repo_name, r.stderr)
269
478
  return "failed", ""
270
479
 
271
- r = _git(["apply", "--check", "--recount", "--ignore-whitespace", str(patch_path)], cwd=local_path, env=env)
480
+ r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
272
481
  if r.returncode != 0:
273
- logger.error("Patch dry-run failed for %s:\n%s", event.fingerprint, r.stderr)
274
- return "failed", ""
482
+ try:
483
+ original = patch_path.read_text(encoding="utf-8", errors="replace")
484
+ repaired = repair_patch_against_files(original, local_path)
485
+ except Exception as _re:
486
+ logger.warning("patch repair failed for %s: %s", repo.repo_name, _re)
487
+ repaired = None
488
+ if repaired and repaired != original:
489
+ patch_path.write_text(repaired, encoding="utf-8")
490
+ r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
491
+ if r.returncode != 0:
492
+ logger.error("Patch dry-run failed for %s:\n%s", event.fingerprint, r.stderr)
493
+ return "failed", ""
275
494
 
276
- r = _git(["apply", "--recount", "--ignore-whitespace", str(patch_path)], cwd=local_path, env=env)
495
+ r = _git(["apply", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
277
496
  if r.returncode != 0:
278
497
  logger.error("git apply failed for %s:\n%s", event.fingerprint, r.stderr)
279
498
  return "failed", ""
@@ -382,10 +601,30 @@ def apply_and_commit_multi(
382
601
  dry_run_failures.append(f"{name}: git pull failed: {r.stderr.strip()[:200]}")
383
602
  continue
384
603
  # Dry-run
385
- r = _git(["apply", "--check", "--recount", "--ignore-whitespace", str(sub_path)],
604
+ r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
386
605
  cwd=repo.local_path, env=env)
387
606
  if r.returncode != 0:
388
- dry_run_failures.append(f"{name}: dry-run failed: {r.stderr.strip()[:200]}")
607
+ # Try repairing the patch by splicing in any intermediate file lines
608
+ # the LLM dropped from hunk context. Only retry if the rewrite
609
+ # actually changed the patch — otherwise we'd be looping.
610
+ try:
611
+ original = sub_path.read_text(encoding="utf-8", errors="replace")
612
+ repaired = repair_patch_against_files(original, repo.local_path)
613
+ except Exception as _re:
614
+ logger.warning("patch repair failed for %s: %s", name, _re)
615
+ repaired = None
616
+ if repaired and repaired != original:
617
+ sub_path.write_text(repaired, encoding="utf-8")
618
+ r = _git(
619
+ ["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
620
+ cwd=repo.local_path, env=env,
621
+ )
622
+ if r.returncode == 0:
623
+ logger.info("Patch repair succeeded for %s — spliced missing context", name)
624
+ else:
625
+ dry_run_failures.append(f"{name}: dry-run failed: {r.stderr.strip()[:200]}")
626
+ else:
627
+ dry_run_failures.append(f"{name}: dry-run failed: {r.stderr.strip()[:200]}")
389
628
 
390
629
  if dry_run_failures:
391
630
  reason = "; ".join(dry_run_failures)
@@ -414,7 +653,7 @@ def apply_and_commit_multi(
414
653
  "reason": "", "sub_patch_path": sub_path,
415
654
  }
416
655
 
417
- r = _git(["apply", "--recount", "--ignore-whitespace", str(sub_path)],
656
+ r = _git(["apply", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
418
657
  cwd=repo.local_path, env=env)
419
658
  if r.returncode != 0:
420
659
  entry["reason"] = f"apply failed: {r.stderr.strip()[:200]}"
@@ -563,7 +563,11 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
563
563
  return
564
564
 
565
565
  if store.fix_attempted_recently(event.fingerprint, hours=24):
566
- logger.debug("Issue already processed recently: %s", event.source)
566
+ logger.info(
567
+ "Issue %s skipped — fingerprint %s attempted in last 24h "
568
+ "(use Boss `retry_issue` to clear the prior row and re-attempt)",
569
+ event.source, event.fingerprint,
570
+ )
567
571
  mark_done(event.issue_file)
568
572
  return
569
573
 
@@ -2632,6 +2632,27 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
2632
2632
  except Exception as _e:
2633
2633
  logger.debug("retry_issue: state_store guard failed (non-fatal): %s", _e)
2634
2634
 
2635
+ # Clear the 24h dedupe so _handle_issue actually re-runs the fix.
2636
+ # fix_attempted_recently() ignores status='skipped', so flipping any
2637
+ # recent 'failed' row for this fingerprint is enough.
2638
+ try:
2639
+ with store._conn() as _c:
2640
+ _n = _c.execute(
2641
+ "UPDATE fixes SET status='skipped' "
2642
+ "WHERE fingerprint=? AND status='failed' "
2643
+ "AND timestamp >= datetime('now', '-24 hours')",
2644
+ (_fp,),
2645
+ ).rowcount
2646
+ _c.commit()
2647
+ if _n:
2648
+ logger.info(
2649
+ "Boss retry_issue: cleared %d prior failed row(s) for fingerprint %s "
2650
+ "so the retry won't be deduped",
2651
+ _n, _fp,
2652
+ )
2653
+ except Exception as _e:
2654
+ logger.debug("retry_issue: clearing prior failed rows failed (non-fatal): %s", _e)
2655
+
2635
2656
  # Re-submit as a fresh issue file
2636
2657
  issues_dir = project_dir / "issues"
2637
2658
  issues_dir.mkdir(exist_ok=True)
@@ -0,0 +1,240 @@
1
+ """
2
+ test_patch_repair.py — Unit tests for repair_patch_against_files().
3
+
4
+ LLM-generated diffs sometimes drop blank/whitespace-only lines from a hunk's
5
+ trailing context — the most common case is a Java/Kotlin source file where
6
+ `send()`'s closing `}` is on line N, and the class-closing `}` is on line N+4
7
+ because there are stray blank lines between them. Claude's patch jumps from
8
+ ` }` (1-tab method close) directly to `}` (column-0 class close), without
9
+ the intermediate blanks. `git apply --check --recount --ignore-whitespace`
10
+ rejects that because context must match the file contiguously.
11
+
12
+ The rewriter reads the actual file from disk, finds the missing intermediate
13
+ lines, and splices them in as ' ' context. After repair, `git apply --check
14
+ --recount` succeeds.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import shutil
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+ import pytest
23
+
24
+ from sentinel.git_manager import (
25
+ _line_match,
26
+ _repair_hunk_body,
27
+ _strip_eol,
28
+ repair_patch_against_files,
29
+ )
30
+
31
+
32
+ # ── pure-function tests for the low-level helpers ────────────────────────────
33
+
34
+
35
+ def test_line_match_strips_cr_and_trailing_whitespace():
36
+ assert _line_match("hello", "hello")
37
+ assert _line_match("hello ", "hello")
38
+ assert _line_match("hello\r", "hello")
39
+ assert _line_match("\t}\r", "\t}")
40
+ assert not _line_match("hello", "world")
41
+
42
+
43
+ def test_strip_eol_handles_crlf_lf_cr_and_none():
44
+ assert _strip_eol("foo\r\n") == "foo"
45
+ assert _strip_eol("foo\n") == "foo"
46
+ assert _strip_eol("foo\r") == "foo"
47
+ assert _strip_eol("foo") == "foo"
48
+
49
+
50
+ def test_repair_hunk_body_splices_missing_blank_lines():
51
+ """The d59ac840 case: hunk goes from method-close to class-close, skipping
52
+ 3 blank/whitespace lines that exist in the file."""
53
+ file_lines = [
54
+ "public class Foo {", # 1
55
+ "\tvoid send() {", # 2
56
+ "\t\tdoStuff();", # 3
57
+ "\t}", # 4 ← method close
58
+ "\t", # 5 ← stray tab
59
+ "", # 6 ← blank
60
+ "", # 7 ← blank
61
+ "}", # 8 ← class close
62
+ ]
63
+ body = [
64
+ " \tvoid send() {\n",
65
+ " \t\tdoStuff();\n",
66
+ " \t}\n",
67
+ " }\n", # ← context jumps from L4 method-close to L8 class-close
68
+ ]
69
+ new_body, new_anchor = _repair_hunk_body(file_lines, anchor=2, body_lines=body)
70
+ assert new_anchor == 2
71
+ # The trailing context " }" must be preceded by 3 spliced ' ' context lines
72
+ bodies = [ln for ln in new_body]
73
+ assert bodies[-1] == " }\n"
74
+ assert bodies[-2] == " \n"
75
+ assert bodies[-3] == " \n"
76
+ assert bodies[-4] == " \t\n"
77
+ # Earlier context unchanged
78
+ assert " \tvoid send() {\n" in bodies
79
+ assert " \t}\n" in bodies
80
+
81
+
82
+ def test_repair_hunk_body_preserves_crlf_eol_when_splicing():
83
+ file_lines = ["a", "", "", "b"]
84
+ body = [
85
+ " a\r\n",
86
+ " b\r\n", # ← skips the 2 blank lines
87
+ ]
88
+ new_body, _ = _repair_hunk_body(file_lines, anchor=1, body_lines=body)
89
+ # Spliced lines should also use CRLF
90
+ assert new_body[1] == " \r\n"
91
+ assert new_body[2] == " \r\n"
92
+ assert new_body[3] == " b\r\n"
93
+
94
+
95
+ def test_repair_hunk_body_calibrates_offset_anchor():
96
+ """If the LLM gets the hunk's start line number wrong by a few lines,
97
+ the calibration step finds the real start within the search window."""
98
+ file_lines = [
99
+ "header", # 1
100
+ "", # 2
101
+ "", # 3
102
+ "void send() {", # 4 ← real start
103
+ " body();", # 5
104
+ "}", # 6
105
+ ]
106
+ body = [
107
+ " void send() {\n",
108
+ " }\n", # skips body() — different test case
109
+ ]
110
+ # LLM said the hunk starts at line 1 (off by 3)
111
+ new_body, new_anchor = _repair_hunk_body(file_lines, anchor=1, body_lines=body)
112
+ assert new_anchor == 4 # calibrated to the real start
113
+
114
+
115
+ def test_repair_hunk_body_leaves_unmatchable_lines_alone():
116
+ """If a context line genuinely doesn't appear in the file, the rewriter
117
+ passes it through and lets git apply produce its real error."""
118
+ file_lines = ["a", "b", "c"]
119
+ body = [
120
+ " a\n",
121
+ " ZZZ-NOT-IN-FILE\n",
122
+ " c\n",
123
+ ]
124
+ new_body, _ = _repair_hunk_body(file_lines, anchor=1, body_lines=body)
125
+ # The unmatchable line is preserved, no infinite splicing
126
+ assert " ZZZ-NOT-IN-FILE\n" in new_body
127
+
128
+
129
+ # ── integration tests against real `git apply --check` ───────────────────────
130
+
131
+
132
+ def _git_available() -> bool:
133
+ return shutil.which("git") is not None
134
+
135
+
136
+ @pytest.mark.skipif(not _git_available(), reason="git binary not available")
137
+ def test_repair_makes_d59ac840_patch_apply(tmp_path: Path):
138
+ """Reproduce the production failure: a Java file with stray blank lines
139
+ between method-close and class-close, plus a Claude-style patch that
140
+ drops them. After repair, `git apply --check` must succeed."""
141
+ repo = tmp_path / "repo"
142
+ repo.mkdir()
143
+ subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
144
+ subprocess.run(["git", "config", "user.email", "test@test"], cwd=repo, check=True)
145
+ subprocess.run(["git", "config", "user.name", "test"], cwd=repo, check=True)
146
+
147
+ src = repo / "src" / "Foo.java"
148
+ src.parent.mkdir(parents=True)
149
+ # Real-world layout: tab + 3 blank lines before class close
150
+ src.write_text(
151
+ "public class Foo {\n"
152
+ "\tvoid send() {\n"
153
+ "\t\tlog.warn(\"old\");\n"
154
+ "\t}\n"
155
+ "\t\n"
156
+ "\n"
157
+ "\n"
158
+ "}\n",
159
+ encoding="utf-8",
160
+ )
161
+ subprocess.run(["git", "add", "."], cwd=repo, check=True)
162
+ subprocess.run(["git", "commit", "-qm", "init"], cwd=repo, check=True)
163
+
164
+ # Claude-style patch: trailing context skips the 3 intermediate lines.
165
+ patch = (
166
+ "diff --git a/src/Foo.java b/src/Foo.java\n"
167
+ "--- a/src/Foo.java\n"
168
+ "+++ b/src/Foo.java\n"
169
+ "@@ -2,4 +2,5 @@\n"
170
+ " \tvoid send() {\n"
171
+ "-\t\tlog.warn(\"old\");\n"
172
+ "+\t\tlog.error(\"new\");\n"
173
+ "+\t\tnotify();\n"
174
+ " \t}\n"
175
+ " }\n"
176
+ )
177
+ patch_path = tmp_path / "claude.diff"
178
+ patch_path.write_text(patch, encoding="utf-8")
179
+
180
+ # Sanity: vanilla git apply --check should fail
181
+ r = subprocess.run(
182
+ ["git", "apply", "--check", "--recount", "--ignore-whitespace", str(patch_path)],
183
+ cwd=repo, capture_output=True, text=True,
184
+ )
185
+ assert r.returncode != 0, "test setup wrong — patch was supposed to fail vanilla apply"
186
+
187
+ # Repair and retry
188
+ repaired = repair_patch_against_files(patch, repo)
189
+ assert repaired != patch
190
+ repaired_path = tmp_path / "repaired.diff"
191
+ repaired_path.write_text(repaired, encoding="utf-8")
192
+ r = subprocess.run(
193
+ ["git", "apply", "--check", "--recount", "--ignore-whitespace", str(repaired_path)],
194
+ cwd=repo, capture_output=True, text=True,
195
+ )
196
+ assert r.returncode == 0, f"repaired patch should apply but got: {r.stderr}"
197
+
198
+
199
+ @pytest.mark.skipif(not _git_available(), reason="git binary not available")
200
+ def test_repair_is_noop_when_patch_already_matches(tmp_path: Path):
201
+ """If a patch has the right context, the rewriter must return it
202
+ unchanged. We don't want to introduce gratuitous diffs."""
203
+ repo = tmp_path / "repo"
204
+ repo.mkdir()
205
+ src = repo / "Foo.java"
206
+ src.write_text("a\nb\nc\n", encoding="utf-8")
207
+
208
+ patch = (
209
+ "diff --git a/Foo.java b/Foo.java\n"
210
+ "--- a/Foo.java\n"
211
+ "+++ b/Foo.java\n"
212
+ "@@ -1,3 +1,3 @@\n"
213
+ " a\n"
214
+ "-b\n"
215
+ "+B\n"
216
+ " c\n"
217
+ )
218
+ repaired = repair_patch_against_files(patch, repo)
219
+ # Anchor recalibration may rewrite the header but body should be identical
220
+ assert " a\n-b\n+B\n c\n" in repaired
221
+
222
+
223
+ def test_repair_passes_through_when_target_file_missing(tmp_path: Path):
224
+ """If `+++ b/<path>` points to a file that doesn't exist, the rewriter
225
+ must leave the chunk alone — git apply will produce its own error."""
226
+ patch = (
227
+ "diff --git a/missing.java b/missing.java\n"
228
+ "--- a/missing.java\n"
229
+ "+++ b/missing.java\n"
230
+ "@@ -1,1 +1,1 @@\n"
231
+ "-x\n"
232
+ "+y\n"
233
+ )
234
+ repaired = repair_patch_against_files(patch, tmp_path)
235
+ assert repaired == patch
236
+
237
+
238
+ def test_repair_handles_empty_input():
239
+ assert repair_patch_against_files("", "/anywhere") == ""
240
+ assert repair_patch_against_files(None, "/anywhere") in (None, "") # type: ignore[arg-type]