tzero-mcp 10.7.0 → 10.7.2
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.
- package/CHANGELOG.md +589 -0
- package/CONTRIBUTING.md +71 -0
- package/README.md +25 -17
- package/bin/postinstall.js +34 -0
- package/bin/tzero-mcp.js +16 -19
- package/bin/tzero.js +14 -19
- package/index.d.ts +24 -0
- package/index.js +7 -3
- package/package.json +24 -2
- package/src/addmcp.py +1194 -0
- package/src/main.py +60 -0
- package/src/siber_akademi_icon.py +77 -0
- package/src/tzero/jarvis/semantic_compressor.py +373 -0
- package/src/tzero.py +58 -0
- package/src/tzero_benchmark.py +475 -0
- package/src/tzero_bridge.py +362 -0
- package/src/tzero_ci.py +153 -0
- package/src/tzero_commit.py +192 -0
- package/src/tzero_cost.py +163 -0
- package/src/tzero_dashboard.py +1448 -0
- package/src/tzero_deps.py +287 -0
- package/src/tzero_drift.py +208 -0
- package/src/tzero_explain.py +240 -0
- package/src/tzero_features.py +3022 -0
- package/src/tzero_fix.py +228 -0
- package/src/tzero_fixer/__init__.py +63 -0
- package/src/tzero_fixer/amalgamation.py +334 -0
- package/src/tzero_fixer/ast_syntax_healer.py +464 -0
- package/src/tzero_fixer/ast_type_modernizer.py +288 -0
- package/src/tzero_fixer/async_io_modernizer.py +127 -0
- package/src/tzero_fixer/automated_regression_suite.py +600 -0
- package/src/tzero_fixer/benchmarks_and_diagnostics.py +174 -0
- package/src/tzero_fixer/ci_cd_self_healing_gate.py +246 -0
- package/src/tzero_fixer/code_duplicity_and_refactorer.py +151 -0
- package/src/tzero_fixer/code_smells_healer.py +260 -0
- package/src/tzero_fixer/cwe_remediator.py +171 -0
- package/src/tzero_fixer/dead_code_eliminator.py +150 -0
- package/src/tzero_fixer/dependency_vulnerability_auditor.py +309 -0
- package/src/tzero_fixer/git_advanced_surgeon.py +280 -0
- package/src/tzero_fixer/hardware_and_disk_doctor.py +325 -0
- package/src/tzero_fixer/import_graph_healer.py +412 -0
- package/src/tzero_fixer/interactive_tui.py +91 -0
- package/src/tzero_fixer/knowledge_base.py +295 -0
- package/src/tzero_fixer/knowledge_base_extended.py +940 -0
- package/src/tzero_fixer/linters_and_static_analyzer.py +307 -0
- package/src/tzero_fixer/mcp_server.py +447 -0
- package/src/tzero_fixer/models.py +385 -0
- package/src/tzero_fixer/orchestrator.py +375 -0
- package/src/tzero_fixer/os_doctor.py +502 -0
- package/src/tzero_fixer/packaging_abi_surgeon.py +323 -0
- package/src/tzero_fixer/pc_fixer_doctor.py +599 -0
- package/src/tzero_fixer/performance_and_memory_profiler.py +274 -0
- package/src/tzero_fixer/polyglot_ast_parsers.py +313 -0
- package/src/tzero_fixer/polyglot_build_healer.py +364 -0
- package/src/tzero_fixer/polyglot_config_healer.py +318 -0
- package/src/tzero_fixer/polyglot_doctor.py +372 -0
- package/src/tzero_fixer/polyglot_linters.py +619 -0
- package/src/tzero_fixer/polyglot_schema_validator.py +305 -0
- package/src/tzero_fixer/security_cwe_database.py +444 -0
- package/src/tzero_fixer/security_deep_rules.py +114 -0
- package/src/tzero_fixer/security_rules_catalog.py +110 -0
- package/src/tzero_fixer/security_zero_leak.py +326 -0
- package/src/tzero_fixer/smart_patch_recommender.py +223 -0
- package/src/tzero_fixer/synthetic_workspace_generator.py +220 -0
- package/src/tzero_fixer/system_diagnostics_extended.py +493 -0
- package/src/tzero_fixer/system_repair_recipes.py +447 -0
- package/src/tzero_fixer/venv_doctor.py +309 -0
- package/src/tzero_gateway.py +885 -0
- package/src/tzero_guard.py +243 -0
- package/src/tzero_infra.py +274 -0
- package/src/tzero_jarvis/__init__.py +124 -0
- package/src/tzero_jarvis/causal_graph.py +310 -0
- package/src/tzero_jarvis/graph_visualizer.py +395 -0
- package/src/tzero_jarvis/hybrid_search.py +214 -0
- package/src/tzero_jarvis/latent_press.py +137 -0
- package/src/tzero_jarvis/memory_consolidation.py +212 -0
- package/src/tzero_jarvis/minhash_lsh.py +184 -0
- package/src/tzero_jarvis/orchestrator.py +335 -0
- package/src/tzero_jarvis/self_healing.py +308 -0
- package/src/tzero_jarvis/semantic_compressor.py +887 -0
- package/src/tzero_jarvis/swarm.py +261 -0
- package/src/tzero_jarvis/swarm_bft.py +144 -0
- package/src/tzero_jarvis/workspace_orchestrator.py +1167 -0
- package/src/tzero_jarvis/zero_key_router.py +173 -0
- package/src/tzero_mcp.py +4858 -0
- package/src/tzero_neural_cortex.py +1425 -0
- package/src/tzero_packer.py +370 -0
- package/src/tzero_patch.py +624 -0
- package/src/tzero_pc_fixer.py +12657 -0
- package/src/tzero_pipe.py +95 -0
- package/src/tzero_polyglot.py +422 -0
- package/src/tzero_reachability.py +272 -0
- package/src/tzero_replay.py +141 -0
- package/src/tzero_security.py +570 -0
- package/src/tzero_snapshot.py +258 -0
- package/src/tzero_stubs.py +313 -0
- package/src/tzero_superdesign.py +774 -0
- package/src/tzero_sync.py +241 -0
- package/src/tzero_task_benchmark.py +363 -0
- package/src/tzero_tui.py +244 -0
- package/src/tzero_v3.py +9435 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Siber Akademi — T-Zero Smart Micro-Patch & AST-Tolerant Fuzzy Patcher.
|
|
4
|
+
Developer: Toprak Ahmet Aydoğmuş (Siber Akademi)
|
|
5
|
+
License: MIT
|
|
6
|
+
|
|
7
|
+
1. SmartMicroPatcher: Compresses standard git diffs and unified diffs into ultra-dense
|
|
8
|
+
semantic AST micro-patches, reducing token payload by 80% to 90% for LLM workflows.
|
|
9
|
+
2. ASTFuzzyPatcher: Applies AI/LLM diffs and patches to codebase files with multi-tier
|
|
10
|
+
tolerance against line-drift, whitespace discrepancies, indentation mismatches,
|
|
11
|
+
AST symbol boundary shifts, and sliding-window fuzzy similarity.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
if sys.platform == "win32":
|
|
16
|
+
try:
|
|
17
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
18
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
import difflib
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Tuple, List, Dict, Any, Optional
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ==============================================================================
|
|
31
|
+
# 1. SMART MICRO-PATCH COMPRESSOR
|
|
32
|
+
# ==============================================================================
|
|
33
|
+
|
|
34
|
+
class SmartMicroPatcher:
|
|
35
|
+
"""Condenses Git diffs and file deltas into semantic AST micro-patches."""
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def compress_diff(raw_diff: str, max_context_lines: int = 1) -> Tuple[str, float]:
|
|
39
|
+
"""Compresses unified git diff into a compact semantic patch.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
raw_diff: Standard output from 'git diff' or unified diff.
|
|
43
|
+
max_context_lines: Maximum surrounding context lines to retain per hunk (default 1).
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
(compact_micro_patch, token_savings_percentage)
|
|
47
|
+
"""
|
|
48
|
+
orig_len = len(raw_diff)
|
|
49
|
+
if orig_len < 40:
|
|
50
|
+
return raw_diff, 0.0
|
|
51
|
+
|
|
52
|
+
lines = raw_diff.splitlines()
|
|
53
|
+
output = []
|
|
54
|
+
current_file = ""
|
|
55
|
+
in_hunk = False
|
|
56
|
+
hunk_changes = []
|
|
57
|
+
trailing_context = []
|
|
58
|
+
|
|
59
|
+
def flush_hunk():
|
|
60
|
+
nonlocal hunk_changes, trailing_context, in_hunk
|
|
61
|
+
if hunk_changes:
|
|
62
|
+
trimmed_trailing = trailing_context[:max_context_lines]
|
|
63
|
+
output.extend(hunk_changes + trimmed_trailing)
|
|
64
|
+
hunk_changes = []
|
|
65
|
+
trailing_context = []
|
|
66
|
+
in_hunk = False
|
|
67
|
+
|
|
68
|
+
for line in lines:
|
|
69
|
+
if line.startswith("diff --git "):
|
|
70
|
+
flush_hunk()
|
|
71
|
+
m = re.search(r"b/(.*)$", line)
|
|
72
|
+
current_file = m.group(1) if m else line[11:]
|
|
73
|
+
output.append(f"\n📁 [{current_file}]")
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
if line.startswith(("index ", "--- ", "+++ ", "new file mode ", "deleted file mode ")):
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if line.startswith("@@"):
|
|
80
|
+
flush_hunk()
|
|
81
|
+
hunk_match = re.match(r"@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)", line)
|
|
82
|
+
if hunk_match:
|
|
83
|
+
start_line = hunk_match.group(1)
|
|
84
|
+
symbol_scope = hunk_match.group(2).strip()
|
|
85
|
+
scope_tag = f" :: {symbol_scope}" if symbol_scope else ""
|
|
86
|
+
output.append(f" ⚡ L{start_line}{scope_tag}")
|
|
87
|
+
else:
|
|
88
|
+
output.append(f" ⚡ {line.strip()}")
|
|
89
|
+
in_hunk = True
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if in_hunk:
|
|
93
|
+
if line.startswith(("+", "-")) and not line.startswith(("+++", "---")):
|
|
94
|
+
flush_trailing = trailing_context[:max_context_lines]
|
|
95
|
+
if flush_trailing:
|
|
96
|
+
hunk_changes.extend(flush_trailing)
|
|
97
|
+
trailing_context = []
|
|
98
|
+
hunk_changes.append(f" {line}")
|
|
99
|
+
elif line.startswith(" "):
|
|
100
|
+
if not hunk_changes:
|
|
101
|
+
if len(trailing_context) < max_context_lines:
|
|
102
|
+
trailing_context.append(f" {line}")
|
|
103
|
+
else:
|
|
104
|
+
trailing_context = [f" {line}"]
|
|
105
|
+
else:
|
|
106
|
+
if len(trailing_context) < max_context_lines:
|
|
107
|
+
trailing_context.append(f" {line}")
|
|
108
|
+
else:
|
|
109
|
+
flush_hunk()
|
|
110
|
+
|
|
111
|
+
flush_hunk()
|
|
112
|
+
compact_res = "\n".join(output).strip()
|
|
113
|
+
savings = max(0.0, ((orig_len - len(compact_res)) / max(orig_len, 1)) * 100.0)
|
|
114
|
+
return compact_res, savings
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def format_micro_patch_report(raw_diff: str, max_context_lines: int = 1) -> str:
|
|
118
|
+
"""Formats an AI-ready Markdown report containing the micro-patch."""
|
|
119
|
+
compact, savings = SmartMicroPatcher.compress_diff(raw_diff, max_context_lines=max_context_lines)
|
|
120
|
+
approx_orig_tokens = max(1, int(len(raw_diff) / 3.8))
|
|
121
|
+
approx_compact_tokens = max(1, int(len(compact) / 3.8))
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
f"# 🧩 T-Zero AST Smart Micro-Patch\n"
|
|
125
|
+
f"- **Raw Diff Tokens:** ~{approx_orig_tokens:,} tokens\n"
|
|
126
|
+
f"- **Compressed Patch Tokens:** ~{approx_compact_tokens:,} tokens\n"
|
|
127
|
+
f"- **Payload Compression:** **-{savings:.1f}% savings**\n\n"
|
|
128
|
+
f"```patch\n{compact}\n```"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ==============================================================================
|
|
133
|
+
# 2. AST-TOLERANT FUZZY PATCHER
|
|
134
|
+
# ==============================================================================
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class PatchHunk:
|
|
138
|
+
"""Represents a parsed diff hunk with context, removals, and additions."""
|
|
139
|
+
old_start: int
|
|
140
|
+
old_length: int
|
|
141
|
+
new_start: int
|
|
142
|
+
new_length: int
|
|
143
|
+
symbol_scope: str = ""
|
|
144
|
+
lines: List[str] = field(default_factory=list)
|
|
145
|
+
old_lines: List[str] = field(default_factory=list)
|
|
146
|
+
new_lines: List[str] = field(default_factory=list)
|
|
147
|
+
hunk_index: int = 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class FilePatch:
|
|
152
|
+
"""Represents hunks targeting a specific file path."""
|
|
153
|
+
source_file: Optional[str] = None
|
|
154
|
+
target_file: Optional[str] = None
|
|
155
|
+
hunks: List[PatchHunk] = field(default_factory=list)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ASTFuzzyPatcher:
|
|
159
|
+
"""Multi-tier AST-Tolerant and Whitespace-Agnostic Fuzzy Patcher.
|
|
160
|
+
|
|
161
|
+
Tiers of tolerance:
|
|
162
|
+
1. exact: Line-exact match at hunk header coordinates.
|
|
163
|
+
2. line_drift: Content-exact match across entire file regardless of line offset.
|
|
164
|
+
3. whitespace_normalized: Strips leading/trailing spaces and adapts indentation.
|
|
165
|
+
4. ast_symbol_boundary: Bounded search within enclosing function/class/method AST scope.
|
|
166
|
+
5. fuzzy_similarity: Sliding-window Levenshtein / SequenceMatcher match (ratio >= tolerance).
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def parse_patch(cls, patch_text: str) -> List[FilePatch]:
|
|
171
|
+
"""Parses unified diff, git diff, or raw hunks into FilePatch structures."""
|
|
172
|
+
lines = patch_text.splitlines()
|
|
173
|
+
file_patches: List[FilePatch] = []
|
|
174
|
+
current_file_patch = FilePatch()
|
|
175
|
+
current_hunk: Optional[PatchHunk] = None
|
|
176
|
+
hunk_idx = 0
|
|
177
|
+
|
|
178
|
+
hunk_header_re = re.compile(r"^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$")
|
|
179
|
+
|
|
180
|
+
for line in lines:
|
|
181
|
+
# Git file header: diff --git a/foo.py b/foo.py
|
|
182
|
+
if line.startswith("diff --git "):
|
|
183
|
+
if current_hunk:
|
|
184
|
+
current_file_patch.hunks.append(current_hunk)
|
|
185
|
+
current_hunk = None
|
|
186
|
+
if current_file_patch.hunks or current_file_patch.target_file:
|
|
187
|
+
file_patches.append(current_file_patch)
|
|
188
|
+
m = re.search(r"b/(.*)$", line)
|
|
189
|
+
target = m.group(1).strip() if m else None
|
|
190
|
+
current_file_patch = FilePatch(target_file=target)
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
# Standard diff headers: --- a/foo.py, +++ b/foo.py
|
|
194
|
+
if line.startswith("--- ") and not line.startswith("--- /dev/null"):
|
|
195
|
+
m = re.search(r"(?:a/|\b)(.+?)(?:\t.*|$)", line[4:].strip())
|
|
196
|
+
if m:
|
|
197
|
+
current_file_patch.source_file = m.group(1).strip()
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if line.startswith("+++ ") and not line.startswith("+++ /dev/null"):
|
|
201
|
+
m = re.search(r"(?:b/|\b)(.+?)(?:\t.*|$)", line[4:].strip())
|
|
202
|
+
if m:
|
|
203
|
+
current_file_patch.target_file = m.group(1).strip()
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# Hunk header: @@ -10,5 +10,6 @@ def my_func():
|
|
207
|
+
m_hunk = hunk_header_re.match(line)
|
|
208
|
+
if m_hunk:
|
|
209
|
+
if current_hunk:
|
|
210
|
+
current_file_patch.hunks.append(current_hunk)
|
|
211
|
+
hunk_idx += 1
|
|
212
|
+
old_s = int(m_hunk.group(1))
|
|
213
|
+
old_l = int(m_hunk.group(2)) if m_hunk.group(2) is not None else 1
|
|
214
|
+
new_s = int(m_hunk.group(3))
|
|
215
|
+
new_l = int(m_hunk.group(4)) if m_hunk.group(4) is not None else 1
|
|
216
|
+
scope = m_hunk.group(5).strip()
|
|
217
|
+
|
|
218
|
+
current_hunk = PatchHunk(
|
|
219
|
+
old_start=old_s,
|
|
220
|
+
old_length=old_l,
|
|
221
|
+
new_start=new_s,
|
|
222
|
+
new_length=new_l,
|
|
223
|
+
symbol_scope=scope,
|
|
224
|
+
hunk_index=hunk_idx
|
|
225
|
+
)
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
# Hunk body lines
|
|
229
|
+
if current_hunk is not None:
|
|
230
|
+
if line.startswith("+"):
|
|
231
|
+
current_hunk.lines.append(line)
|
|
232
|
+
current_hunk.new_lines.append(line[1:])
|
|
233
|
+
elif line.startswith("-"):
|
|
234
|
+
current_hunk.lines.append(line)
|
|
235
|
+
current_hunk.old_lines.append(line[1:])
|
|
236
|
+
elif line.startswith(" "):
|
|
237
|
+
current_hunk.lines.append(line)
|
|
238
|
+
current_hunk.old_lines.append(line[1:])
|
|
239
|
+
current_hunk.new_lines.append(line[1:])
|
|
240
|
+
elif line == "":
|
|
241
|
+
# Empty context line
|
|
242
|
+
current_hunk.lines.append(" ")
|
|
243
|
+
current_hunk.old_lines.append("")
|
|
244
|
+
current_hunk.new_lines.append("")
|
|
245
|
+
elif line.startswith("\\"):
|
|
246
|
+
#
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
if current_hunk:
|
|
250
|
+
current_file_patch.hunks.append(current_hunk)
|
|
251
|
+
if current_file_patch.hunks or current_file_patch.target_file:
|
|
252
|
+
file_patches.append(current_file_patch)
|
|
253
|
+
|
|
254
|
+
if not file_patches and current_hunk is not None:
|
|
255
|
+
file_patches.append(FilePatch(hunks=[current_hunk]))
|
|
256
|
+
|
|
257
|
+
return file_patches
|
|
258
|
+
|
|
259
|
+
@classmethod
|
|
260
|
+
def extract_symbol_name(cls, scope_hint: str) -> Optional[str]:
|
|
261
|
+
"""Extracts identifier name from hunk symbol scope header (e.g., 'def charge(x):')."""
|
|
262
|
+
if not scope_hint:
|
|
263
|
+
return None
|
|
264
|
+
m = re.search(r"(?:def|class|func|fn|function|struct|impl)\s+([A-Za-z0-9_]+)", scope_hint)
|
|
265
|
+
if m:
|
|
266
|
+
return m.group(1)
|
|
267
|
+
# Fallback: look for identifier followed by parens
|
|
268
|
+
m2 = re.search(r"([A-Za-z0-9_]{3,})\s*\(", scope_hint)
|
|
269
|
+
return m2.group(1) if m2 else None
|
|
270
|
+
|
|
271
|
+
@classmethod
|
|
272
|
+
def find_symbol_range(cls, lines: List[str], symbol_name: str) -> Optional[Tuple[int, int]]:
|
|
273
|
+
"""Finds line range [start, end] for a symbol in lines using AST or polyglot indentation."""
|
|
274
|
+
if not symbol_name:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
# 1. Try Python AST if file is Python
|
|
278
|
+
code_str = "\n".join(lines)
|
|
279
|
+
try:
|
|
280
|
+
tree = ast.parse(code_str)
|
|
281
|
+
for node in ast.walk(tree):
|
|
282
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
283
|
+
if node.name == symbol_name:
|
|
284
|
+
start_line = max(0, node.lineno - 1)
|
|
285
|
+
end_line = getattr(node, "end_lineno", len(lines))
|
|
286
|
+
return (start_line, min(len(lines), end_line))
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
# 2. Universal Polyglot Fallback: Regex + Indentation Scope
|
|
291
|
+
sym_pattern = re.compile(r"(?:def|class|func|fn|function|struct|impl|pub fn)\s+" + re.escape(symbol_name) + r"\b")
|
|
292
|
+
for i, line in enumerate(lines):
|
|
293
|
+
if sym_pattern.search(line):
|
|
294
|
+
# Found symbol definition start
|
|
295
|
+
base_indent = len(line) - len(line.lstrip())
|
|
296
|
+
end_i = len(lines)
|
|
297
|
+
for j in range(i + 1, len(lines)):
|
|
298
|
+
cur_line = lines[j]
|
|
299
|
+
if not cur_line.strip():
|
|
300
|
+
continue
|
|
301
|
+
cur_indent = len(cur_line) - len(cur_line.lstrip())
|
|
302
|
+
if cur_indent <= base_indent:
|
|
303
|
+
end_i = j
|
|
304
|
+
break
|
|
305
|
+
return (i, end_i)
|
|
306
|
+
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
@classmethod
|
|
310
|
+
def adjust_indentation(cls, target_lines: List[str], match_idx: int, old_lines: List[str], new_lines: List[str]) -> List[str]:
|
|
311
|
+
"""Adjusts leading indentation of new_lines to match target_lines at match_idx."""
|
|
312
|
+
if not new_lines or not old_lines or match_idx >= len(target_lines):
|
|
313
|
+
return list(new_lines)
|
|
314
|
+
|
|
315
|
+
# Detect target indentation
|
|
316
|
+
target_line = target_lines[match_idx] if match_idx < len(target_lines) else ""
|
|
317
|
+
target_indent_len = len(target_line) - len(target_line.lstrip())
|
|
318
|
+
target_indent_str = target_line[:target_indent_len]
|
|
319
|
+
|
|
320
|
+
# Detect old hunk indentation
|
|
321
|
+
first_old = old_lines[0] if old_lines else ""
|
|
322
|
+
old_indent_len = len(first_old) - len(first_old.lstrip())
|
|
323
|
+
old_indent_str = first_old[:old_indent_len]
|
|
324
|
+
|
|
325
|
+
if target_indent_str == old_indent_str:
|
|
326
|
+
return list(new_lines)
|
|
327
|
+
|
|
328
|
+
adjusted: List[str] = []
|
|
329
|
+
for line in new_lines:
|
|
330
|
+
if not line.strip():
|
|
331
|
+
adjusted.append("")
|
|
332
|
+
continue
|
|
333
|
+
cur_indent_len = len(line) - len(line.lstrip())
|
|
334
|
+
rel_diff = cur_indent_len - old_indent_len
|
|
335
|
+
if rel_diff >= 0:
|
|
336
|
+
adjusted.append(target_indent_str + (" " * rel_diff) + line.lstrip())
|
|
337
|
+
else:
|
|
338
|
+
cut = max(0, len(target_indent_str) + rel_diff)
|
|
339
|
+
adjusted.append(target_indent_str[:cut] + line.lstrip())
|
|
340
|
+
|
|
341
|
+
return adjusted
|
|
342
|
+
|
|
343
|
+
@classmethod
|
|
344
|
+
def match_hunk(
|
|
345
|
+
cls,
|
|
346
|
+
target_lines: List[str],
|
|
347
|
+
hunk: PatchHunk,
|
|
348
|
+
fuzzy: bool = True,
|
|
349
|
+
tolerance: float = 0.75
|
|
350
|
+
) -> Tuple[bool, int, int, str, float, List[str]]:
|
|
351
|
+
"""Finds match slice [start_idx : end_idx] for hunk with multi-tier tolerance.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
(matched, start_idx, end_idx, strategy, confidence, adjusted_new_lines)
|
|
355
|
+
"""
|
|
356
|
+
k = len(hunk.old_lines)
|
|
357
|
+
|
|
358
|
+
# Handle pure addition hunk (no old lines to replace)
|
|
359
|
+
if k == 0:
|
|
360
|
+
exp_pos = max(0, min(len(target_lines), hunk.old_start - 1))
|
|
361
|
+
return True, exp_pos, exp_pos, "exact_insertion", 1.0, list(hunk.new_lines)
|
|
362
|
+
|
|
363
|
+
expected_start = max(0, min(len(target_lines), hunk.old_start - 1))
|
|
364
|
+
norm_old = [line.strip() for line in hunk.old_lines]
|
|
365
|
+
|
|
366
|
+
# Extract symbol scope range if present in hunk header
|
|
367
|
+
symbol_name = cls.extract_symbol_name(hunk.symbol_scope)
|
|
368
|
+
sym_range = cls.find_symbol_range(target_lines, symbol_name) if symbol_name else None
|
|
369
|
+
|
|
370
|
+
# ----------------------------------------------------------------------
|
|
371
|
+
# Tier 1: Exact coordinates & content
|
|
372
|
+
# ----------------------------------------------------------------------
|
|
373
|
+
# Only accept exact match if expected_start is not outside a specified symbol scope
|
|
374
|
+
if expected_start + k <= len(target_lines):
|
|
375
|
+
if target_lines[expected_start : expected_start + k] == hunk.old_lines:
|
|
376
|
+
if not sym_range or (sym_range[0] <= expected_start < sym_range[1]):
|
|
377
|
+
return True, expected_start, expected_start + k, "exact", 1.0, list(hunk.new_lines)
|
|
378
|
+
|
|
379
|
+
# ----------------------------------------------------------------------
|
|
380
|
+
# Tier 2: Line drift search across entire file (exact content)
|
|
381
|
+
# ----------------------------------------------------------------------
|
|
382
|
+
exact_matches = []
|
|
383
|
+
for i in range(len(target_lines) - k + 1):
|
|
384
|
+
if target_lines[i : i + k] == hunk.old_lines:
|
|
385
|
+
exact_matches.append(i)
|
|
386
|
+
|
|
387
|
+
if exact_matches:
|
|
388
|
+
if sym_range:
|
|
389
|
+
sym_matches = [idx for idx in exact_matches if sym_range[0] <= idx < sym_range[1]]
|
|
390
|
+
if sym_matches:
|
|
391
|
+
best_idx = min(sym_matches, key=lambda idx: abs(idx - expected_start))
|
|
392
|
+
strat = "ast_symbol_boundary" if len(exact_matches) > 1 else "line_drift"
|
|
393
|
+
return True, best_idx, best_idx + k, strat, 1.0, list(hunk.new_lines)
|
|
394
|
+
best_idx = min(exact_matches, key=lambda idx: abs(idx - expected_start))
|
|
395
|
+
return True, best_idx, best_idx + k, "line_drift", 1.0, list(hunk.new_lines)
|
|
396
|
+
|
|
397
|
+
if not fuzzy:
|
|
398
|
+
return False, -1, -1, "none", 0.0, []
|
|
399
|
+
|
|
400
|
+
# ----------------------------------------------------------------------
|
|
401
|
+
# Tier 3: Whitespace & Indentation Normalized Search
|
|
402
|
+
# ----------------------------------------------------------------------
|
|
403
|
+
ws_matches = []
|
|
404
|
+
for i in range(len(target_lines) - k + 1):
|
|
405
|
+
candidate = [target_lines[i + j].strip() for j in range(k)]
|
|
406
|
+
if candidate == norm_old:
|
|
407
|
+
ws_matches.append(i)
|
|
408
|
+
|
|
409
|
+
if ws_matches:
|
|
410
|
+
if sym_range:
|
|
411
|
+
sym_ws_matches = [idx for idx in ws_matches if sym_range[0] <= idx < sym_range[1]]
|
|
412
|
+
if sym_ws_matches:
|
|
413
|
+
best_idx = min(sym_ws_matches, key=lambda idx: abs(idx - expected_start))
|
|
414
|
+
strat = "ast_symbol_boundary" if len(ws_matches) > 1 else "whitespace_normalized"
|
|
415
|
+
adjusted = cls.adjust_indentation(target_lines, best_idx, hunk.old_lines, hunk.new_lines)
|
|
416
|
+
return True, best_idx, best_idx + k, strat, 0.95, adjusted
|
|
417
|
+
best_idx = min(ws_matches, key=lambda idx: abs(idx - expected_start))
|
|
418
|
+
adjusted = cls.adjust_indentation(target_lines, best_idx, hunk.old_lines, hunk.new_lines)
|
|
419
|
+
return True, best_idx, best_idx + k, "whitespace_normalized", 0.95, adjusted
|
|
420
|
+
|
|
421
|
+
# ----------------------------------------------------------------------
|
|
422
|
+
# Tier 5: Sliding-Window Fuzzy Similarity (Levenshtein / SequenceMatcher)
|
|
423
|
+
# ----------------------------------------------------------------------
|
|
424
|
+
search_range = range(sym_range[0], max(sym_range[0], sym_range[1] - k + 1)) if sym_range else range(len(target_lines) - k + 1)
|
|
425
|
+
old_block = "\n".join(norm_old)
|
|
426
|
+
best_ratio = 0.0
|
|
427
|
+
best_idx = -1
|
|
428
|
+
|
|
429
|
+
for i in search_range:
|
|
430
|
+
candidate_block = "\n".join(target_lines[i + j].strip() for j in range(k))
|
|
431
|
+
ratio = difflib.SequenceMatcher(None, candidate_block, old_block).ratio()
|
|
432
|
+
distance_penalty = min(0.12, abs(i - expected_start) * 0.0003)
|
|
433
|
+
effective_score = ratio - distance_penalty
|
|
434
|
+
if effective_score > best_ratio:
|
|
435
|
+
best_ratio = effective_score
|
|
436
|
+
best_idx = i
|
|
437
|
+
|
|
438
|
+
if best_idx != -1 and best_ratio >= tolerance:
|
|
439
|
+
strat = "fuzzy_similarity"
|
|
440
|
+
adjusted = cls.adjust_indentation(target_lines, best_idx, hunk.old_lines, hunk.new_lines)
|
|
441
|
+
return True, best_idx, best_idx + k, strat, round(best_ratio, 3), adjusted
|
|
442
|
+
|
|
443
|
+
return False, -1, -1, "none", 0.0, []
|
|
444
|
+
|
|
445
|
+
@classmethod
|
|
446
|
+
def apply_fuzzy_patch(
|
|
447
|
+
cls,
|
|
448
|
+
source_content: str,
|
|
449
|
+
patch_content: str,
|
|
450
|
+
fuzzy: bool = True,
|
|
451
|
+
tolerance: float = 0.75,
|
|
452
|
+
dry_run: bool = False,
|
|
453
|
+
reverse: bool = False
|
|
454
|
+
) -> Tuple[bool, str, Dict[str, Any]]:
|
|
455
|
+
"""Applies patch to source_content with multi-tier tolerance.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
source_content: Original file source code.
|
|
459
|
+
patch_content: Unified diff, git diff, or hunk text.
|
|
460
|
+
fuzzy: Enable whitespace, AST, and similarity tolerance (default: True).
|
|
461
|
+
tolerance: Minimum similarity ratio for Tier 5 fuzzy match (default: 0.75).
|
|
462
|
+
dry_run: Validate applicability without returning mutated code.
|
|
463
|
+
reverse: Un-apply patch (swaps old_lines and new_lines).
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
(success, patched_content, metadata_report)
|
|
467
|
+
"""
|
|
468
|
+
file_patches = cls.parse_patch(patch_content)
|
|
469
|
+
all_hunks: List[PatchHunk] = []
|
|
470
|
+
for fp in file_patches:
|
|
471
|
+
all_hunks.extend(fp.hunks)
|
|
472
|
+
|
|
473
|
+
if not all_hunks:
|
|
474
|
+
return False, source_content, {
|
|
475
|
+
"success": False,
|
|
476
|
+
"error": "No valid diff hunks detected in patch content",
|
|
477
|
+
"applied_hunks": 0,
|
|
478
|
+
"total_hunks": 0
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
# Handle reverse patch mode
|
|
482
|
+
if reverse:
|
|
483
|
+
for h in all_hunks:
|
|
484
|
+
h.old_lines, h.new_lines = h.new_lines, h.old_lines
|
|
485
|
+
h.old_start, h.new_start = h.new_start, h.old_start
|
|
486
|
+
h.old_length, h.new_length = h.new_length, h.old_length
|
|
487
|
+
|
|
488
|
+
target_lines = source_content.splitlines()
|
|
489
|
+
hunk_results: List[Dict[str, Any]] = []
|
|
490
|
+
|
|
491
|
+
# Step 1: Match each hunk against target lines
|
|
492
|
+
# To avoid offset collisions, find match positions first or apply bottom-to-top
|
|
493
|
+
matches = []
|
|
494
|
+
for h in all_hunks:
|
|
495
|
+
matched, start_idx, end_idx, strategy, conf, adj_lines = cls.match_hunk(
|
|
496
|
+
target_lines, h, fuzzy=fuzzy, tolerance=tolerance
|
|
497
|
+
)
|
|
498
|
+
matches.append({
|
|
499
|
+
"hunk": h,
|
|
500
|
+
"matched": matched,
|
|
501
|
+
"start_idx": start_idx,
|
|
502
|
+
"end_idx": end_idx,
|
|
503
|
+
"strategy": strategy,
|
|
504
|
+
"confidence": conf,
|
|
505
|
+
"adjusted_lines": adj_lines
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
# Check if all hunks were matched
|
|
509
|
+
all_matched = all(m["matched"] for m in matches)
|
|
510
|
+
|
|
511
|
+
# Sort matches in descending order of start_idx to apply bottom-to-top safely
|
|
512
|
+
sorted_matches = sorted(
|
|
513
|
+
[m for m in matches if m["matched"]],
|
|
514
|
+
key=lambda m: m["start_idx"],
|
|
515
|
+
reverse=True
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if all_matched and not dry_run:
|
|
519
|
+
for m in sorted_matches:
|
|
520
|
+
s, e = m["start_idx"], m["end_idx"]
|
|
521
|
+
target_lines[s:e] = m["adjusted_lines"]
|
|
522
|
+
|
|
523
|
+
for m in matches:
|
|
524
|
+
h = m["hunk"]
|
|
525
|
+
hunk_results.append({
|
|
526
|
+
"hunk_index": h.hunk_index,
|
|
527
|
+
"status": "applied" if m["matched"] else "rejected",
|
|
528
|
+
"strategy": m["strategy"],
|
|
529
|
+
"confidence": m["confidence"],
|
|
530
|
+
"old_start": h.old_start,
|
|
531
|
+
"matched_line": (m["start_idx"] + 1) if m["matched"] else None,
|
|
532
|
+
"scope_symbol": h.symbol_scope
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
applied_count = sum(1 for m in matches if m["matched"])
|
|
536
|
+
patched_text = "\n".join(target_lines)
|
|
537
|
+
if source_content.endswith("\n") and not patched_text.endswith("\n"):
|
|
538
|
+
patched_text += "\n"
|
|
539
|
+
|
|
540
|
+
meta = {
|
|
541
|
+
"success": all_matched,
|
|
542
|
+
"total_hunks": len(all_hunks),
|
|
543
|
+
"applied_hunks": applied_count,
|
|
544
|
+
"failed_hunks": len(all_hunks) - applied_count,
|
|
545
|
+
"strategies_used": list({m["strategy"] for m in matches if m["matched"]}),
|
|
546
|
+
"hunk_results": hunk_results,
|
|
547
|
+
"dry_run": dry_run,
|
|
548
|
+
"reverse": reverse,
|
|
549
|
+
"original_lines": len(source_content.splitlines()),
|
|
550
|
+
"patched_lines": len(patched_text.splitlines())
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return all_matched, (patched_text if all_matched and not dry_run else source_content), meta
|
|
554
|
+
|
|
555
|
+
@classmethod
|
|
556
|
+
def patch_file(
|
|
557
|
+
cls,
|
|
558
|
+
target_file_path: str,
|
|
559
|
+
patch_text_or_path: str,
|
|
560
|
+
in_place: bool = True,
|
|
561
|
+
fuzzy: bool = True,
|
|
562
|
+
tolerance: float = 0.75,
|
|
563
|
+
dry_run: bool = False,
|
|
564
|
+
backup: bool = False
|
|
565
|
+
) -> Dict[str, Any]:
|
|
566
|
+
"""Patches a file on disk using AST-tolerant fuzzy matching.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
target_file_path: Absolute or relative path to target file.
|
|
570
|
+
patch_text_or_path: Diff string or path to .diff/.patch file.
|
|
571
|
+
in_place: Write changes directly to target_file_path (default: True).
|
|
572
|
+
fuzzy: Enable fuzzy and AST matching (default: True).
|
|
573
|
+
tolerance: Fuzzy matching threshold (default: 0.75).
|
|
574
|
+
dry_run: Verify patch applicability without writing to disk.
|
|
575
|
+
backup: Create .bak file before modifying (default: False).
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
Dictionary with operation status and hunk metrics.
|
|
579
|
+
"""
|
|
580
|
+
if not os.path.exists(target_file_path):
|
|
581
|
+
return {
|
|
582
|
+
"success": False,
|
|
583
|
+
"error": f"Target file not found: {target_file_path}",
|
|
584
|
+
"target_file": target_file_path
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
# Resolve patch content
|
|
588
|
+
if os.path.isfile(patch_text_or_path):
|
|
589
|
+
with open(patch_text_or_path, "r", encoding="utf-8", errors="replace") as pf:
|
|
590
|
+
patch_content = pf.read()
|
|
591
|
+
else:
|
|
592
|
+
patch_content = patch_text_or_path
|
|
593
|
+
|
|
594
|
+
with open(target_file_path, "r", encoding="utf-8", errors="replace") as tf:
|
|
595
|
+
original_code = tf.read()
|
|
596
|
+
|
|
597
|
+
success, patched_code, meta = cls.apply_fuzzy_patch(
|
|
598
|
+
original_code,
|
|
599
|
+
patch_content,
|
|
600
|
+
fuzzy=fuzzy,
|
|
601
|
+
tolerance=tolerance,
|
|
602
|
+
dry_run=dry_run
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
meta["target_file"] = target_file_path
|
|
606
|
+
|
|
607
|
+
if success and in_place and not dry_run:
|
|
608
|
+
if backup:
|
|
609
|
+
bak_path = f"{target_file_path}.bak"
|
|
610
|
+
with open(bak_path, "w", encoding="utf-8") as bf:
|
|
611
|
+
bf.write(original_code)
|
|
612
|
+
meta["backup_created"] = bak_path
|
|
613
|
+
|
|
614
|
+
with open(target_file_path, "w", encoding="utf-8") as out_f:
|
|
615
|
+
out_f.write(patched_code)
|
|
616
|
+
meta["file_written"] = True
|
|
617
|
+
|
|
618
|
+
return meta
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# Global convenience aliases
|
|
622
|
+
apply_fuzzy_patch = ASTFuzzyPatcher.apply_fuzzy_patch
|
|
623
|
+
patch_file = ASTFuzzyPatcher.patch_file
|
|
624
|
+
|