cluxion-agentplugin-supercoder 0.2.5__tar.gz → 0.2.7__tar.gz

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 (65) hide show
  1. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/PKG-INFO +1 -1
  2. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/pyproject.toml +1 -1
  3. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/__init__.py +1 -1
  4. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/core/hash_patch.py +78 -23
  5. cluxion_agentplugin_supercoder-0.2.7/tests/test_hash_patch.py +201 -0
  6. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/uv.lock +1 -1
  7. cluxion_agentplugin_supercoder-0.2.5/tests/test_hash_patch.py +0 -82
  8. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/.github/workflows/ci.yml +0 -0
  9. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/.github/workflows/publish.yml +0 -0
  10. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/.gitignore +0 -0
  11. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/ARCHITECTURE.md +0 -0
  12. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/Docs/README.md +0 -0
  13. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/Docs/agent-surfaces.md +0 -0
  14. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/Docs/architecture.md +0 -0
  15. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/Docs/capabilities.md +0 -0
  16. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/Docs/design.md +0 -0
  17. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/Docs/installation.md +0 -0
  18. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/Docs/rust-architecture.md +0 -0
  19. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/Docs/tools.md +0 -0
  20. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/LICENSE +0 -0
  21. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/README.md +0 -0
  22. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/__init__.py +0 -0
  23. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/adapters/claude/.claude-plugin/plugin.json +0 -0
  24. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/adapters/claude/skills/supercoder/SKILL.md +0 -0
  25. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/adapters/codex/config-snippet.toml +0 -0
  26. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/adapters/hermes/README.md +0 -0
  27. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/plugin.yaml +0 -0
  28. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/rust/supercoder_index/Cargo.lock +0 -0
  29. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/rust/supercoder_index/Cargo.toml +0 -0
  30. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/rust/supercoder_index/pyproject.toml +0 -0
  31. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/rust/supercoder_index/src/lib.rs +0 -0
  32. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/rust/supercoder_index/src/main.rs +0 -0
  33. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/rust/supercoder_index/src/outline.rs +0 -0
  34. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/rust/supercoder_index/src/syntax.rs +0 -0
  35. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/scripts/repack_native_wheel.py +0 -0
  36. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/cli.py +0 -0
  37. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/core/cursor.py +0 -0
  38. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/core/line_budget.py +0 -0
  39. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/core/lint_gate.py +0 -0
  40. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/core/queue.py +0 -0
  41. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/core/repo_map.py +0 -0
  42. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/core/retry_loop.py +0 -0
  43. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/core/safety.py +0 -0
  44. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/core/syntax_gate.py +0 -0
  45. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/core/test_gate.py +0 -0
  46. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/doctor/__init__.py +0 -0
  47. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/doctor/catalog.json +0 -0
  48. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/doctor/framework.py +0 -0
  49. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/doctor/probes.py +0 -0
  50. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/plugin.py +0 -0
  51. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/runner.py +0 -0
  52. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/rust_bridge.py +0 -0
  53. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/src/cluxion_agentplugin_supercoder/schemas.py +0 -0
  54. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_cursor.py +0 -0
  55. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_doctor.py +0 -0
  56. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_line_budget.py +0 -0
  57. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_lint_gate.py +0 -0
  58. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_plugin.py +0 -0
  59. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_queue.py +0 -0
  60. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_repo_map.py +0 -0
  61. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_retry_loop.py +0 -0
  62. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_rust_bridge.py +0 -0
  63. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_safety.py +0 -0
  64. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_syntax_gate.py +0 -0
  65. {cluxion_agentplugin_supercoder-0.2.5 → cluxion_agentplugin_supercoder-0.2.7}/tests/test_test_gate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cluxion-agentplugin-supercoder
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: Universal agent coding harness plugin: cursor logic, safe patch, line budget, Rust index, test gates.
5
5
  Project-URL: Homepage, https://github.com/cluxion/cluxion-Agentplugin-supercoder
6
6
  Project-URL: Repository, https://github.com/cluxion/cluxion-Agentplugin-supercoder
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cluxion-agentplugin-supercoder"
7
- version = "0.2.5"
7
+ version = "0.2.7"
8
8
  description = "Universal agent coding harness plugin: cursor logic, safe patch, line budget, Rust index, test gates."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,5 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.5"
3
+ __version__ = "0.2.7"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -3,15 +3,40 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import hashlib
6
+ import os
7
+ import tempfile
8
+ from contextlib import contextmanager
6
9
  from dataclasses import dataclass
7
10
  from difflib import SequenceMatcher
8
11
  from pathlib import Path
9
12
 
13
+ try:
14
+ import fcntl
15
+ except ImportError:
16
+ fcntl = None # type: ignore[assignment]
17
+
10
18
  DEFAULT_FUZZY_THRESHOLD = 0.86
11
19
  MAX_CONTEXT_SCAN = 8
12
20
  MAX_LINE_DRIFT = 2
13
21
 
14
22
 
23
+ @contextmanager
24
+ def _exclusive_lock(path: Path):
25
+ """Exclusive advisory lock on target file (fcntl.flock). Graceful degrade on non-POSIX."""
26
+ if fcntl is None or not path.exists():
27
+ yield
28
+ return
29
+ fd = os.open(str(path), os.O_RDWR)
30
+ try:
31
+ fcntl.flock(fd, fcntl.LOCK_EX)
32
+ yield
33
+ finally:
34
+ try:
35
+ fcntl.flock(fd, fcntl.LOCK_UN)
36
+ finally:
37
+ os.close(fd)
38
+
39
+
15
40
  @dataclass(frozen=True, slots=True)
16
41
  class PatchResult:
17
42
  success: bool
@@ -44,28 +69,29 @@ def apply_patch(
44
69
  ) -> PatchResult:
45
70
  if not path.exists():
46
71
  return _failed(str(path), "missing_file", expected_file_hash, "file not found")
47
- text = path.read_text(encoding="utf-8")
48
- current_hash = file_hash(text)
49
- if expected_file_hash and current_hash != _normalize_hash(expected_file_hash):
50
- return _failed(str(path), "stale_file", expected_file_hash, "file changed since cursor was created")
51
- exact = _exact_spans(text, old_text)
52
- if exact:
53
- start, end = exact[0]
54
- return _commit(path, text, start, end, new_text, "exact", expected_file_hash or current_hash, current_hash, 1.0)
55
- fuzzy = _best_fuzzy_span(text, old_text)
56
- if fuzzy and fuzzy[3] >= fuzzy_threshold and not fuzzy[4]:
57
- return _commit(
58
- path,
59
- text,
60
- fuzzy[0],
61
- fuzzy[1],
62
- new_text,
63
- "fuzzy",
64
- expected_file_hash or current_hash,
65
- current_hash,
66
- fuzzy[3],
67
- )
68
- return _failed(str(path), "no_match", expected_file_hash or current_hash, "patch target not found")
72
+ with _exclusive_lock(path):
73
+ text = path.read_text(encoding="utf-8")
74
+ current_hash = file_hash(text)
75
+ if expected_file_hash and current_hash != _normalize_hash(expected_file_hash):
76
+ return _failed(str(path), "stale_file", expected_file_hash, "file changed since cursor was created")
77
+ exact = _exact_spans(text, old_text)
78
+ if exact:
79
+ start, end = exact[0]
80
+ return _commit(path, text, start, end, new_text, "exact", expected_file_hash or current_hash, current_hash, 1.0)
81
+ fuzzy = _best_fuzzy_span(text, old_text)
82
+ if fuzzy and fuzzy[3] >= fuzzy_threshold and not fuzzy[4]:
83
+ return _commit(
84
+ path,
85
+ text,
86
+ fuzzy[0],
87
+ fuzzy[1],
88
+ new_text,
89
+ "fuzzy",
90
+ expected_file_hash or current_hash,
91
+ current_hash,
92
+ fuzzy[3],
93
+ )
94
+ return _failed(str(path), "no_match", expected_file_hash or current_hash, "patch target not found")
69
95
 
70
96
 
71
97
  def _normalize_newlines(content: str) -> str:
@@ -114,19 +140,48 @@ def _candidate_spans(text: str, reference: str, line_drift: int) -> list[tuple[i
114
140
 
115
141
  def _best_fuzzy_span(text: str, reference: str) -> tuple[int, int, str, float, bool] | None:
116
142
  best: tuple[int, int, str, float] | None = None
143
+ best_lines: tuple[int, int] | None = None
117
144
  ambiguous = False
145
+ lines = text.splitlines(keepends=True)
146
+ offsets = [0]
147
+ for line in lines:
148
+ offsets.append(offsets[-1] + len(line))
118
149
  for start, end, block in _candidate_spans(text, reference, MAX_LINE_DRIFT):
150
+ # compute line range [start_line, end_line) for overlap test
151
+ start_line = 0
152
+ while start_line < len(offsets) - 1 and offsets[start_line + 1] <= start:
153
+ start_line += 1
154
+ end_line = start_line
155
+ while end_line < len(offsets) - 1 and offsets[end_line] < end:
156
+ end_line += 1
119
157
  score = SequenceMatcher(None, block, reference, autojunk=False).ratio()
120
158
  if best is None or score > best[3]:
121
159
  best = (start, end, block, score)
160
+ best_lines = (start_line, end_line)
122
161
  ambiguous = False
123
162
  elif score >= DEFAULT_FUZZY_THRESHOLD and best and abs(score - best[3]) < 0.015:
163
+ # only treat as ambiguous if a genuinely different (non-overlapping) location matches closely
164
+ if best_lines is not None and not (end_line <= best_lines[0] or start_line >= best_lines[1]):
165
+ continue # overlapping window on same location -> not real ambiguity
124
166
  ambiguous = True
125
167
  if best is None:
126
168
  return None
127
169
  return best[0], best[1], best[2], best[3], ambiguous
128
170
 
129
171
 
172
+ def _atomic_write(path: Path, content: str) -> None:
173
+ """Atomic replace via temp in same dir + fsync to prevent corruption on crash."""
174
+ dir_ = path.parent
175
+ with tempfile.NamedTemporaryFile(
176
+ mode="w", encoding="utf-8", dir=dir_, delete=False, suffix=".tmp"
177
+ ) as tmp:
178
+ tmp.write(content)
179
+ tmp.flush()
180
+ os.fsync(tmp.fileno())
181
+ tmp_path = Path(tmp.name)
182
+ os.replace(tmp_path, path)
183
+
184
+
130
185
  def _commit(
131
186
  path: Path,
132
187
  text: str,
@@ -139,7 +194,7 @@ def _commit(
139
194
  score: float,
140
195
  ) -> PatchResult:
141
196
  updated = f"{text[:start]}{new_content}{text[end:]}"
142
- path.write_text(updated, encoding="utf-8")
197
+ _atomic_write(path, updated)
143
198
  return PatchResult(True, str(path), strategy, "patch applied", expected, matched, round(score, 4), 1)
144
199
 
145
200
 
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ import contextlib
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from cluxion_agentplugin_supercoder.core.hash_patch import apply_patch, file_hash
12
+
13
+
14
+ def test_exact_patch(tmp_path: Path) -> None:
15
+ path = tmp_path / "a.py"
16
+ path.write_text("alpha\nbeta\ngamma\n", encoding="utf-8")
17
+ expected = file_hash(path.read_text(encoding="utf-8"))
18
+ result = apply_patch(path, old_text="beta\n", new_text="BETA\n", expected_file_hash=expected)
19
+ assert result.success is True
20
+ assert "BETA" in path.read_text(encoding="utf-8")
21
+
22
+
23
+ def test_stale_patch_blocked(tmp_path: Path) -> None:
24
+ path = tmp_path / "a.py"
25
+ path.write_text("one\n", encoding="utf-8")
26
+ result = apply_patch(path, old_text="one\n", new_text="two\n", expected_file_hash="0" * 64)
27
+ assert result.success is False
28
+ assert "changed" in result.message
29
+
30
+
31
+ def test_missing_file_fails_closed(tmp_path: Path) -> None:
32
+ result = apply_patch(tmp_path / "absent.py", old_text="x", new_text="y")
33
+ assert result.success is False
34
+ assert result.strategy == "missing_file"
35
+
36
+
37
+ def test_fuzzy_patch_tolerates_minor_drift(tmp_path: Path) -> None:
38
+ path = tmp_path / "a.py"
39
+ body = "def handler(request):\n value = compute(request)\n return value\n"
40
+ path.write_text(body, encoding="utf-8")
41
+ # old_text drifts from the file by one comment line the model forgot.
42
+ drifted = "def handler(request):\n value = compute(request) # cached\n return value\n"
43
+ result = apply_patch(path, old_text=drifted, new_text="def handler(request):\n return compute(request)\n")
44
+ assert result.success is True
45
+ assert result.strategy == "fuzzy"
46
+ assert 0.86 <= result.similarity < 1.0
47
+ assert path.read_text(encoding="utf-8") == "def handler(request):\n return compute(request)\n"
48
+
49
+
50
+ def test_low_similarity_is_no_match(tmp_path: Path) -> None:
51
+ path = tmp_path / "a.py"
52
+ path.write_text("alpha\nbeta\ngamma\n", encoding="utf-8")
53
+ result = apply_patch(path, old_text="completely different content\n", new_text="x\n")
54
+ assert result.success is False
55
+ assert result.strategy == "no_match"
56
+
57
+
58
+ def test_ambiguous_fuzzy_candidates_refuse_to_guess(tmp_path: Path) -> None:
59
+ # Two near-identical blocks: a fuzzy match could land on either, so the
60
+ # patch must fail instead of silently editing the wrong one.
61
+ path = tmp_path / "a.py"
62
+ block = "def f():\n return 1\n"
63
+ path.write_text(block + "\n" + block, encoding="utf-8")
64
+ near = "def f():\n return 2\n"
65
+ result = apply_patch(path, old_text=near, new_text="def f():\n return 3\n")
66
+ assert result.success is False
67
+ assert result.strategy == "no_match"
68
+
69
+
70
+ def test_exact_match_uses_first_occurrence(tmp_path: Path) -> None:
71
+ path = tmp_path / "a.py"
72
+ path.write_text("x = 1\ny = 2\nx = 1\n", encoding="utf-8")
73
+ result = apply_patch(path, old_text="x = 1\n", new_text="x = 9\n")
74
+ assert result.success is True
75
+ assert path.read_text(encoding="utf-8") == "x = 9\ny = 2\nx = 1\n"
76
+
77
+
78
+ def test_sha256_prefix_and_bad_hash_rejected(tmp_path: Path) -> None:
79
+ path = tmp_path / "a.py"
80
+ path.write_text("one\n", encoding="utf-8")
81
+ prefixed = "sha256:" + file_hash("one\n")
82
+ result = apply_patch(path, old_text="one\n", new_text="two\n", expected_file_hash=prefixed)
83
+ assert result.success is True
84
+ path.write_text("one\n", encoding="utf-8")
85
+ with pytest.raises(ValueError):
86
+ apply_patch(path, old_text="one\n", new_text="two\n", expected_file_hash="abc")
87
+
88
+
89
+ def test_fuzzy_short_2line_block_with_drift_applies(tmp_path: Path) -> None:
90
+ # regression: 2-line unique target with minor drift (trailing space + typo) must apply
91
+ # even though different window widths produce overlapping candidates
92
+ path = tmp_path / "multi.py"
93
+ body = """def foo():
94
+ x = 1
95
+ return x
96
+
97
+ def bar():
98
+ y = 2
99
+ return y
100
+ """
101
+ path.write_text(body, encoding="utf-8")
102
+ # drifted old_text: trailing space on first line of block, one-char typo on second
103
+ drifted = " y = 2 \n return z\n"
104
+ new = " y = 42\n return y\n"
105
+ result = apply_patch(path, old_text=drifted, new_text=new)
106
+ assert result.success is True
107
+ assert result.strategy == "fuzzy"
108
+ content = path.read_text(encoding="utf-8")
109
+ assert "y = 42" in content
110
+ assert "return y" in content
111
+ assert "def bar():" in content
112
+
113
+
114
+ def test_duplicate_blocks_still_ambiguous(tmp_path: Path) -> None:
115
+ # genuine duplicate (non-overlapping) must still refuse
116
+ path = tmp_path / "dups.py"
117
+ block = "def f():\n return 1\n"
118
+ path.write_text(block + "\n" + block, encoding="utf-8")
119
+ near = "def f():\n return 2\n"
120
+ result = apply_patch(path, old_text=near, new_text="def f():\n return 3\n")
121
+ assert result.success is False
122
+ assert result.strategy == "no_match"
123
+
124
+
125
+ # === Concurrency and atomicity tests ===
126
+
127
+ def _worker_apply(i: int, path: Path) -> bool:
128
+ """Worker that applies a unique non-overlapping patch under lock."""
129
+ old = f"# UNIQUE_PATCH_{i}_START\n"
130
+ new = f"# UNIQUE_PATCH_{i}_DONE\n"
131
+ # distinct markers, no value chaining conflict
132
+ result = apply_patch(path, old_text=old, new_text=new)
133
+ return result.success
134
+
135
+
136
+ def test_concurrent_patches_no_lost_update(tmp_path: Path) -> None:
137
+ """8 concurrent workers on same file: lock serializes, no lost updates, final coherent."""
138
+ path = tmp_path / "concurrent.py"
139
+ # initial content with 8 distinct unique markers
140
+ initial = "\n".join(f"# UNIQUE_PATCH_{i}_START" for i in range(8)) + "\n"
141
+ path.write_text(initial, encoding="utf-8")
142
+
143
+ # each patch changes its unique marker; lock ensures serialization, all succeed
144
+ with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
145
+ futures = [
146
+ executor.submit(_worker_apply, i, path) for i in range(8)
147
+ ]
148
+ successes = [f.result() for f in concurrent.futures.as_completed(futures)]
149
+
150
+ assert all(successes), "Some patches lost due to race"
151
+ final = path.read_text(encoding="utf-8")
152
+ # final should have all DONE markers, no START left
153
+ for i in range(8):
154
+ assert f"# UNIQUE_PATCH_{i}_DONE" in final
155
+ assert f"# UNIQUE_PATCH_{i}_START" not in final
156
+
157
+ # hash chain consistent (changed from initial)
158
+ new_hash = file_hash(final)
159
+ assert new_hash != file_hash(initial)
160
+
161
+
162
+ def test_atomic_write_interruption_leaves_original_intact(tmp_path: Path) -> None:
163
+ """Simulated mid-write crash leaves ORIGINAL file intact (temp may remain, no truncate)."""
164
+ path = tmp_path / "atomic_test.txt"
165
+ original = "IMPORTANT ORIGINAL CONTENT\nDO NOT LOSE\n"
166
+ path.write_text(original, encoding="utf-8")
167
+ orig_hash = file_hash(original)
168
+
169
+ # simulate crash mid atomic write by patching _atomic_write temporarily
170
+ def crashing_atomic(p: Path, content: str) -> None:
171
+ dir_ = p.parent
172
+ with tempfile.NamedTemporaryFile(
173
+ mode="w", encoding="utf-8", dir=dir_, delete=False, suffix=".tmp"
174
+ ) as tmp:
175
+ tmp.write(content[:10]) # partial write
176
+ tmp.flush()
177
+ os.fsync(tmp.fileno())
178
+ # simulate kill before replace
179
+ raise RuntimeError("simulated crash mid-write")
180
+
181
+ original_atomic = None
182
+ try:
183
+ # monkey patch for test
184
+ import cluxion_agentplugin_supercoder.core.hash_patch as hp
185
+
186
+ original_atomic = hp._atomic_write
187
+ hp._atomic_write = crashing_atomic
188
+ with contextlib.suppress(RuntimeError):
189
+ hp._atomic_write(path, "CORRUPTED NEW CONTENT\n")
190
+ # after crash, original must be untouched
191
+ after = path.read_text(encoding="utf-8")
192
+ assert after == original, "Atomic write failed: original was corrupted or truncated"
193
+ assert file_hash(after) == orig_hash
194
+ finally:
195
+ import cluxion_agentplugin_supercoder.core.hash_patch as hp
196
+
197
+ if original_atomic is not None:
198
+ hp._atomic_write = original_atomic
199
+ # cleanup any leftover temp if test created
200
+ for f in tmp_path.glob("*.tmp"):
201
+ f.unlink(missing_ok=True)
@@ -160,7 +160,7 @@ wheels = [
160
160
 
161
161
  [[package]]
162
162
  name = "cluxion-agentplugin-supercoder"
163
- version = "0.2.5"
163
+ version = "0.2.7"
164
164
  source = { editable = "." }
165
165
  dependencies = [
166
166
  { name = "psutil" },
@@ -1,82 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
-
5
- import pytest
6
-
7
- from cluxion_agentplugin_supercoder.core.hash_patch import apply_patch, file_hash
8
-
9
-
10
- def test_exact_patch(tmp_path: Path) -> None:
11
- path = tmp_path / "a.py"
12
- path.write_text("alpha\nbeta\ngamma\n", encoding="utf-8")
13
- expected = file_hash(path.read_text(encoding="utf-8"))
14
- result = apply_patch(path, old_text="beta\n", new_text="BETA\n", expected_file_hash=expected)
15
- assert result.success is True
16
- assert "BETA" in path.read_text(encoding="utf-8")
17
-
18
-
19
- def test_stale_patch_blocked(tmp_path: Path) -> None:
20
- path = tmp_path / "a.py"
21
- path.write_text("one\n", encoding="utf-8")
22
- result = apply_patch(path, old_text="one\n", new_text="two\n", expected_file_hash="0" * 64)
23
- assert result.success is False
24
- assert "changed" in result.message
25
-
26
-
27
- def test_missing_file_fails_closed(tmp_path: Path) -> None:
28
- result = apply_patch(tmp_path / "absent.py", old_text="x", new_text="y")
29
- assert result.success is False
30
- assert result.strategy == "missing_file"
31
-
32
-
33
- def test_fuzzy_patch_tolerates_minor_drift(tmp_path: Path) -> None:
34
- path = tmp_path / "a.py"
35
- body = "def handler(request):\n value = compute(request)\n return value\n"
36
- path.write_text(body, encoding="utf-8")
37
- # old_text drifts from the file by one comment line the model forgot.
38
- drifted = "def handler(request):\n value = compute(request) # cached\n return value\n"
39
- result = apply_patch(path, old_text=drifted, new_text="def handler(request):\n return compute(request)\n")
40
- assert result.success is True
41
- assert result.strategy == "fuzzy"
42
- assert 0.86 <= result.similarity < 1.0
43
- assert path.read_text(encoding="utf-8") == "def handler(request):\n return compute(request)\n"
44
-
45
-
46
- def test_low_similarity_is_no_match(tmp_path: Path) -> None:
47
- path = tmp_path / "a.py"
48
- path.write_text("alpha\nbeta\ngamma\n", encoding="utf-8")
49
- result = apply_patch(path, old_text="completely different content\n", new_text="x\n")
50
- assert result.success is False
51
- assert result.strategy == "no_match"
52
-
53
-
54
- def test_ambiguous_fuzzy_candidates_refuse_to_guess(tmp_path: Path) -> None:
55
- # Two near-identical blocks: a fuzzy match could land on either, so the
56
- # patch must fail instead of silently editing the wrong one.
57
- path = tmp_path / "a.py"
58
- block = "def f():\n return 1\n"
59
- path.write_text(block + "\n" + block, encoding="utf-8")
60
- near = "def f():\n return 2\n"
61
- result = apply_patch(path, old_text=near, new_text="def f():\n return 3\n")
62
- assert result.success is False
63
- assert result.strategy == "no_match"
64
-
65
-
66
- def test_exact_match_uses_first_occurrence(tmp_path: Path) -> None:
67
- path = tmp_path / "a.py"
68
- path.write_text("x = 1\ny = 2\nx = 1\n", encoding="utf-8")
69
- result = apply_patch(path, old_text="x = 1\n", new_text="x = 9\n")
70
- assert result.success is True
71
- assert path.read_text(encoding="utf-8") == "x = 9\ny = 2\nx = 1\n"
72
-
73
-
74
- def test_sha256_prefix_and_bad_hash_rejected(tmp_path: Path) -> None:
75
- path = tmp_path / "a.py"
76
- path.write_text("one\n", encoding="utf-8")
77
- prefixed = "sha256:" + file_hash("one\n")
78
- result = apply_patch(path, old_text="one\n", new_text="two\n", expected_file_hash=prefixed)
79
- assert result.success is True
80
- path.write_text("one\n", encoding="utf-8")
81
- with pytest.raises(ValueError):
82
- apply_patch(path, old_text="one\n", new_text="two\n", expected_file_hash="abc")