agentplay 0.1.0__py3-none-any.whl
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.
- agentplay/__init__.py +6 -0
- agentplay/__main__.py +5 -0
- agentplay/_claude_role.py +45 -0
- agentplay/_core/__init__.py +4 -0
- agentplay/_core/claude_edit_evidence.py +397 -0
- agentplay/_core/code_authorship.py +401 -0
- agentplay/_core/ingestion/__init__.py +0 -0
- agentplay/_core/ingestion/coding_agent/__init__.py +0 -0
- agentplay/_core/ingestion/coding_agent/_paths.py +111 -0
- agentplay/_core/ingestion/coding_agent/_sources/__init__.py +0 -0
- agentplay/_core/ingestion/coding_agent/_sources/_base.py +183 -0
- agentplay/_core/ingestion/coding_agent/_sources/_jsonl_utils.py +752 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/__init__.py +0 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/parsers/__init__.py +56 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/parsers/base.py +156 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/parsers/claude_code.py +957 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/parsers/claude_cost_summary.py +148 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/parsers/codex.py +1055 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/parsers/copilot.py +912 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/parsers/copilot_vscode.py +505 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/parsers/cursor_agent_transcript.py +309 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/parsers/cursor_store.py +1072 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/parsers/kiro.py +537 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/parsers/opencode.py +436 -0
- agentplay/_core/ingestion/coding_agent/_sources/local/source.py +2441 -0
- agentplay/_core/ingestion/coding_agent/behavior_signals.py +851 -0
- agentplay/_core/ingestion/coding_agent/edit_lines.py +239 -0
- agentplay/_core/ingestion/coding_agent/file_paths.py +87 -0
- agentplay/_core/ingestion/coding_agent/metrics.py +316 -0
- agentplay/_core/ingestion/coding_agent/native_operations.py +271 -0
- agentplay/_core/ingestion/coding_agent/produced.py +570 -0
- agentplay/_core/ingestion/coding_agent/session_pure.py +1176 -0
- agentplay/_core/ingestion/coding_agent/skill_names.py +358 -0
- agentplay/_core/ingestion/coding_agent/spans.py +325 -0
- agentplay/_core/ingestion/coding_agent/tool_taxonomy.py +246 -0
- agentplay/_core/pricing.py +681 -0
- agentplay/_util.py +25 -0
- agentplay/builder.py +2455 -0
- agentplay/cli.py +437 -0
- agentplay/compare.py +96 -0
- agentplay/discover.py +332 -0
- agentplay/doctor.py +618 -0
- agentplay/fix.py +477 -0
- agentplay/hooks/__init__.py +0 -0
- agentplay/hooks/compress.py +167 -0
- agentplay/hooks/trim.py +437 -0
- agentplay/materialize.py +1671 -0
- agentplay/pipeline.py +140 -0
- agentplay/server.py +214 -0
- agentplay/store.py +129 -0
- agentplay/viewer/assets/index-Bc-cysF_.css +1 -0
- agentplay/viewer/assets/index-DvI1RDgp.js +51 -0
- agentplay/viewer/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- agentplay/viewer/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- agentplay/viewer/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- agentplay/viewer/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- agentplay/viewer/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- agentplay/viewer/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- agentplay/viewer/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- agentplay/viewer/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- agentplay/viewer/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- agentplay/viewer/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- agentplay/viewer/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- agentplay/viewer/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- agentplay/viewer/demo/claude-code-rate-limit.json +1166 -0
- agentplay/viewer/demo/codex-flaky-test.json +579 -0
- agentplay/viewer/demo/cursor-copy-tweak.json +242 -0
- agentplay/viewer/demo/index.json +136 -0
- agentplay/viewer/index.html +40 -0
- agentplay-0.1.0.dist-info/METADATA +40 -0
- agentplay-0.1.0.dist-info/RECORD +75 -0
- agentplay-0.1.0.dist-info/WHEEL +4 -0
- agentplay-0.1.0.dist-info/entry_points.txt +2 -0
- agentplay-0.1.0.dist-info/licenses/LICENSE +202 -0
- agentplay-0.1.0.dist-info/licenses/NOTICE +13 -0
agentplay/__init__.py
ADDED
agentplay/__main__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Claude Code transcript role detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def claude_subagent_transcript(data: bytes) -> bool:
|
|
9
|
+
"""Whether this transcript is a Claude Code subagent's, not a session of its own.
|
|
10
|
+
|
|
11
|
+
Claude Code writes a subagent's turns to their own file and marks every
|
|
12
|
+
conversation record ``isSidechain: true``. Newer builds front-load metadata
|
|
13
|
+
records (``queue-operation``, ``last-prompt``, ``ai-title``) that carry no
|
|
14
|
+
flag, so checking only the first line lets a subagent file pass as a
|
|
15
|
+
standalone session.
|
|
16
|
+
|
|
17
|
+
The test is ALL, not ANY: a real session that merely used subagents has its
|
|
18
|
+
own unflagged turns. Only a transcript whose every conversation record is
|
|
19
|
+
flagged is itself a subagent's.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
data: raw transcript bytes.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
True only when at least one conversation record is a sidechain and none
|
|
26
|
+
is not. False for an empty, unparseable, or ordinary transcript.
|
|
27
|
+
"""
|
|
28
|
+
saw_sidechain = False
|
|
29
|
+
for raw in data.split(b"\n"):
|
|
30
|
+
raw = raw.strip()
|
|
31
|
+
if not raw:
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
record = json.loads(raw)
|
|
35
|
+
except (ValueError, UnicodeDecodeError):
|
|
36
|
+
continue
|
|
37
|
+
if not isinstance(record, dict):
|
|
38
|
+
continue
|
|
39
|
+
if record.get("type") not in ("user", "assistant"):
|
|
40
|
+
continue
|
|
41
|
+
if record.get("isSidechain") is True:
|
|
42
|
+
saw_sidechain = True
|
|
43
|
+
continue
|
|
44
|
+
return False
|
|
45
|
+
return saw_sidechain
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Recover full-line edit evidence from Claude's recorded successful patch result."""
|
|
2
|
+
|
|
3
|
+
from difflib import SequenceMatcher
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from agentplay._core.code_authorship import (
|
|
7
|
+
MAX_EDITS_PER_CALL,
|
|
8
|
+
MAX_HASHES_PER_CALL,
|
|
9
|
+
extract_edit_evidence,
|
|
10
|
+
line_fingerprints,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def result_patches(lines: list[dict]) -> dict[str, dict | None]:
|
|
15
|
+
"""Index native results; None fences invalid/conflicting IDs from legacy fallback."""
|
|
16
|
+
results: dict[str, dict] = {}
|
|
17
|
+
conflicting: set[str] = set()
|
|
18
|
+
for line in lines:
|
|
19
|
+
result = line.get("toolUseResult")
|
|
20
|
+
message = line.get("message")
|
|
21
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
22
|
+
if not isinstance(content, list):
|
|
23
|
+
continue
|
|
24
|
+
blocks = [b for b in content if isinstance(b, dict) and b.get("type") == "tool_result"]
|
|
25
|
+
for block in blocks:
|
|
26
|
+
key = block.get("tool_use_id")
|
|
27
|
+
if not isinstance(key, str) or not key:
|
|
28
|
+
continue
|
|
29
|
+
if block.get("is_error"):
|
|
30
|
+
conflicting.add(key)
|
|
31
|
+
elif "toolUseResult" in line:
|
|
32
|
+
if len(blocks) != 1 or not isinstance(result, dict):
|
|
33
|
+
conflicting.add(key)
|
|
34
|
+
elif key in results and results[key] != result:
|
|
35
|
+
conflicting.add(key)
|
|
36
|
+
else:
|
|
37
|
+
results[key] = result
|
|
38
|
+
return {**results, **dict.fromkeys(conflicting)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def call_edit_evidence(
|
|
42
|
+
tool_name: Any, tool_input: Any, native: Any, *, has_native: bool, successful: bool
|
|
43
|
+
) -> dict:
|
|
44
|
+
"""Choose a Claude call's edit evidence: its native result when that is the truth.
|
|
45
|
+
|
|
46
|
+
Edit always prefers its recorded patch: a present-but-unverifiable result
|
|
47
|
+
yields ``invalid_edits`` rather than the weaker request-string capture. A
|
|
48
|
+
Write whose result is an ``update`` (it overwrote an existing file) replays
|
|
49
|
+
as that result's hunks, because a full-content ``write`` cannot be placed
|
|
50
|
+
over a base; a fenced Write result (conflicting or invalid) yields
|
|
51
|
+
``invalid_edits`` like a fenced Edit. Every other call keeps request-based
|
|
52
|
+
capture.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
tool_name: The tool's name as recorded.
|
|
56
|
+
tool_input: The tool_use input.
|
|
57
|
+
native: This call's entry from ``result_patches`` (None when fenced).
|
|
58
|
+
has_native: Whether ``result_patches`` has an entry for this call.
|
|
59
|
+
successful: Whether the call produced a non-error result.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A ``{"status", "edits"}`` evidence dict.
|
|
63
|
+
"""
|
|
64
|
+
name = tool_name.lower() if isinstance(tool_name, str) else ""
|
|
65
|
+
if (
|
|
66
|
+
successful
|
|
67
|
+
and has_native
|
|
68
|
+
and (
|
|
69
|
+
name == "edit"
|
|
70
|
+
# None is result_patches' conflict fence: never fall back to the request.
|
|
71
|
+
or (
|
|
72
|
+
name == "write"
|
|
73
|
+
and (native is None or isinstance(native, dict) and native.get("type") == "update")
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
):
|
|
77
|
+
return extract_result_edit(tool_name, tool_input, native) or {
|
|
78
|
+
"status": "invalid_edits",
|
|
79
|
+
"edits": [],
|
|
80
|
+
}
|
|
81
|
+
return extract_edit_evidence(tool_name, tool_input, successful=successful)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def extract_result_edit(tool_name: str, tool_input: Any, result: Any) -> dict | None:
|
|
85
|
+
"""Hash a verified native Edit/overwriting-Write result; mismatches yield no evidence.
|
|
86
|
+
|
|
87
|
+
The patch records the actual complete lines changed by the tool, including
|
|
88
|
+
boundary text omitted from an Edit substring. A Write over an existing file
|
|
89
|
+
records an ``update`` patch of the same shape, verified against the written
|
|
90
|
+
content (and the original file when recorded). No current working-tree read
|
|
91
|
+
is used to reconstruct historical content. No source text leaves this helper.
|
|
92
|
+
"""
|
|
93
|
+
if (
|
|
94
|
+
not isinstance(tool_name, str)
|
|
95
|
+
or tool_name.lower() not in {"edit", "write"}
|
|
96
|
+
or not isinstance(tool_input, dict)
|
|
97
|
+
or not isinstance(result, dict)
|
|
98
|
+
):
|
|
99
|
+
return None
|
|
100
|
+
write = tool_name.lower() == "write"
|
|
101
|
+
path = tool_input.get("file_path")
|
|
102
|
+
if (
|
|
103
|
+
not isinstance(path, str)
|
|
104
|
+
or not path
|
|
105
|
+
or result.get("filePath") != path
|
|
106
|
+
or result.get("userModified") is not False
|
|
107
|
+
):
|
|
108
|
+
return None
|
|
109
|
+
if write:
|
|
110
|
+
written = tool_input.get("content")
|
|
111
|
+
if (
|
|
112
|
+
result.get("type") != "update"
|
|
113
|
+
or not isinstance(written, str)
|
|
114
|
+
or result.get("content") != written
|
|
115
|
+
or len(written) > 1_000_000
|
|
116
|
+
):
|
|
117
|
+
return None
|
|
118
|
+
elif (
|
|
119
|
+
result.get("oldString") != tool_input.get("old_string")
|
|
120
|
+
or result.get("newString") != tool_input.get("new_string")
|
|
121
|
+
or not isinstance(tool_input.get("old_string"), str)
|
|
122
|
+
or not tool_input["old_string"]
|
|
123
|
+
or not isinstance(tool_input.get("new_string"), str)
|
|
124
|
+
or type(result.get("replaceAll")) is not bool
|
|
125
|
+
or type(tool_input.get("replace_all", False)) is not bool
|
|
126
|
+
or result["replaceAll"] != tool_input.get("replace_all", False)
|
|
127
|
+
):
|
|
128
|
+
return None
|
|
129
|
+
hunks = result.get("structuredPatch")
|
|
130
|
+
if not isinstance(hunks, list) or not 0 < len(hunks) <= MAX_EDITS_PER_CALL:
|
|
131
|
+
return None
|
|
132
|
+
original = result.get("originalFile")
|
|
133
|
+
reference = None
|
|
134
|
+
if write:
|
|
135
|
+
if isinstance(original, str):
|
|
136
|
+
if len(original) > 1_000_000:
|
|
137
|
+
return None
|
|
138
|
+
reference = (original.splitlines(), written.splitlines())
|
|
139
|
+
elif original is not None:
|
|
140
|
+
return None
|
|
141
|
+
elif isinstance(original, str):
|
|
142
|
+
if len(original) > 1_000_000 or tool_input["old_string"] not in original:
|
|
143
|
+
return None
|
|
144
|
+
replacements = original.count(tool_input["old_string"]) if result["replaceAll"] else 1
|
|
145
|
+
if (
|
|
146
|
+
len(original)
|
|
147
|
+
+ replacements * (len(tool_input["new_string"]) - len(tool_input["old_string"]))
|
|
148
|
+
> 1_000_000
|
|
149
|
+
):
|
|
150
|
+
return None
|
|
151
|
+
updated = original.replace(
|
|
152
|
+
tool_input["old_string"],
|
|
153
|
+
tool_input["new_string"],
|
|
154
|
+
-1 if result["replaceAll"] else 1,
|
|
155
|
+
)
|
|
156
|
+
reference = (original.splitlines(), updated.splitlines())
|
|
157
|
+
elif original is not None:
|
|
158
|
+
return None
|
|
159
|
+
reconstructed = []
|
|
160
|
+
next_original_line = 0
|
|
161
|
+
direct_match = len(hunks) == 1 or result.get("replaceAll") is True
|
|
162
|
+
edits = []
|
|
163
|
+
total = 0
|
|
164
|
+
total_chars = 0
|
|
165
|
+
last_old = last_new = 0
|
|
166
|
+
for hunk in hunks:
|
|
167
|
+
if not isinstance(hunk, dict):
|
|
168
|
+
return None
|
|
169
|
+
if any(
|
|
170
|
+
type(hunk.get(k)) is not int or hunk[k] < 0
|
|
171
|
+
for k in ("oldStart", "newStart", "oldLines", "newLines")
|
|
172
|
+
):
|
|
173
|
+
return None
|
|
174
|
+
if hunk["oldStart"] == 0:
|
|
175
|
+
return None
|
|
176
|
+
if hunk["oldStart"] < last_old or hunk["newStart"] < last_new:
|
|
177
|
+
return None
|
|
178
|
+
last_old = hunk["oldStart"] + hunk["oldLines"]
|
|
179
|
+
last_new = hunk["newStart"] + hunk["newLines"]
|
|
180
|
+
lines = hunk.get("lines")
|
|
181
|
+
if not isinstance(lines, list):
|
|
182
|
+
return None
|
|
183
|
+
before, after = [], []
|
|
184
|
+
for line in lines:
|
|
185
|
+
if (
|
|
186
|
+
not isinstance(line, str)
|
|
187
|
+
or not line
|
|
188
|
+
or line[0] not in " +-"
|
|
189
|
+
or len(line.splitlines()) != 1
|
|
190
|
+
or line.splitlines()[0] != line
|
|
191
|
+
or any(0xD800 <= ord(c) <= 0xDFFF for c in line)
|
|
192
|
+
):
|
|
193
|
+
return None
|
|
194
|
+
total_chars += len(line)
|
|
195
|
+
if total_chars > 1_000_000:
|
|
196
|
+
return None
|
|
197
|
+
if line[0] != "+":
|
|
198
|
+
before.append(line[1:])
|
|
199
|
+
if line[0] != "-":
|
|
200
|
+
after.append(line[1:])
|
|
201
|
+
if (
|
|
202
|
+
len(before) != hunk["oldLines"]
|
|
203
|
+
or len(after) != hunk["newLines"]
|
|
204
|
+
or not before
|
|
205
|
+
or before == after
|
|
206
|
+
):
|
|
207
|
+
return None
|
|
208
|
+
if reference is not None:
|
|
209
|
+
old_start = hunk["oldStart"] - 1
|
|
210
|
+
new_start = max(0, hunk["newStart"] - 1)
|
|
211
|
+
if (
|
|
212
|
+
old_start < 0
|
|
213
|
+
or reference[0][old_start : old_start + len(before)] != before
|
|
214
|
+
or reference[1][new_start : new_start + len(after)] != after
|
|
215
|
+
):
|
|
216
|
+
return None
|
|
217
|
+
reconstructed.extend(reference[0][next_original_line:old_start])
|
|
218
|
+
reconstructed.extend(after)
|
|
219
|
+
next_original_line = old_start + len(before)
|
|
220
|
+
if reference is None and write:
|
|
221
|
+
# No original recorded: the full written content still pins every
|
|
222
|
+
# hunk's after side to its exact position.
|
|
223
|
+
new_start = max(0, hunk["newStart"] - 1)
|
|
224
|
+
if written.splitlines()[new_start : new_start + len(after)] != after:
|
|
225
|
+
return None
|
|
226
|
+
elif reference is None:
|
|
227
|
+
before_text = "\n".join(before) + "\n"
|
|
228
|
+
after_text = "\n".join(after) + "\n" if after else ""
|
|
229
|
+
direct_match = direct_match and _matches_overlapping_replacement(
|
|
230
|
+
before_text,
|
|
231
|
+
after_text,
|
|
232
|
+
tool_input["old_string"],
|
|
233
|
+
tool_input["new_string"],
|
|
234
|
+
result["replaceAll"],
|
|
235
|
+
)
|
|
236
|
+
total += len(before) + len(after)
|
|
237
|
+
if total > MAX_HASHES_PER_CALL:
|
|
238
|
+
return None
|
|
239
|
+
edits.append(
|
|
240
|
+
{
|
|
241
|
+
"path": path,
|
|
242
|
+
"operation": "replace",
|
|
243
|
+
"before": line_fingerprints("\n".join(before) + "\n"),
|
|
244
|
+
"after": line_fingerprints("\n".join(after) + "\n") if after else [],
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
if reference is not None:
|
|
248
|
+
reconstructed.extend(reference[0][next_original_line:])
|
|
249
|
+
if reconstructed != reference[1]:
|
|
250
|
+
return None
|
|
251
|
+
if (
|
|
252
|
+
reference is None
|
|
253
|
+
and not write
|
|
254
|
+
and not direct_match
|
|
255
|
+
and not _matches_requested_changes(
|
|
256
|
+
hunks, tool_input["old_string"], tool_input["new_string"], result["replaceAll"]
|
|
257
|
+
)
|
|
258
|
+
):
|
|
259
|
+
return None
|
|
260
|
+
return {"status": "captured", "edits": edits}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _matches_overlapping_replacement(
|
|
264
|
+
before: str, after: str, old: str, new: str, replace_all: bool
|
|
265
|
+
) -> bool:
|
|
266
|
+
"""Check a full replacement or a clipped patch with consistent shared context."""
|
|
267
|
+
replacements = before.count(old) if replace_all else 1
|
|
268
|
+
if len(before) + replacements * (len(new) - len(old)) > 1_000_000:
|
|
269
|
+
return False
|
|
270
|
+
if old in before and before.replace(old, new, -1 if replace_all else 1) == after:
|
|
271
|
+
return True
|
|
272
|
+
if replace_all or len(old) + len(new) > 1_000_000:
|
|
273
|
+
return False
|
|
274
|
+
prefix = 0
|
|
275
|
+
while prefix < min(len(old), len(new)) and old[prefix] == new[prefix]:
|
|
276
|
+
prefix += 1
|
|
277
|
+
suffix = 0
|
|
278
|
+
while suffix < min(len(old), len(new)) - prefix and old[-suffix - 1] == new[-suffix - 1]:
|
|
279
|
+
suffix += 1
|
|
280
|
+
old_core = old[prefix : len(old) - suffix]
|
|
281
|
+
new_core = new[prefix : len(new) - suffix]
|
|
282
|
+
needle, haystack = (old_core, before) if old_core else (new_core, after)
|
|
283
|
+
if not needle:
|
|
284
|
+
return False
|
|
285
|
+
position = haystack.find(needle)
|
|
286
|
+
for _ in range(64):
|
|
287
|
+
if position < 0:
|
|
288
|
+
return False
|
|
289
|
+
start = position - prefix
|
|
290
|
+
if before[:position] + new_core + before[position + len(old_core) :] == after:
|
|
291
|
+
if all(
|
|
292
|
+
observed[max(0, start) : min(len(observed), start + len(requested))]
|
|
293
|
+
== requested[max(0, -start) : min(len(requested), len(observed) - start)]
|
|
294
|
+
for observed, requested in ((before, old), (after, new))
|
|
295
|
+
):
|
|
296
|
+
return True
|
|
297
|
+
position = haystack.find(needle, position + 1)
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _matches_requested_changes(hunks: list[dict], old: str, new: str, replace_all: bool) -> bool:
|
|
302
|
+
"""Validate clipped hunks against request changes, positions and overlapping context.
|
|
303
|
+
|
|
304
|
+
Native patches can omit unchanged lines from a long replacement. Align each
|
|
305
|
+
changed group with the request diff; only the request's boundary lines may
|
|
306
|
+
have surrounding file text, and that surrounding text must remain unchanged.
|
|
307
|
+
"""
|
|
308
|
+
old_lines, new_lines = old.splitlines(), new.splitlines()
|
|
309
|
+
if len(old) + len(new) > 1_000_000 or len(old_lines) + len(new_lines) > MAX_HASHES_PER_CALL:
|
|
310
|
+
return False
|
|
311
|
+
expected = [
|
|
312
|
+
op
|
|
313
|
+
for op in SequenceMatcher(None, old_lines, new_lines, autojunk=False).get_opcodes()
|
|
314
|
+
if op[0] != "equal"
|
|
315
|
+
]
|
|
316
|
+
actual = []
|
|
317
|
+
for hunk in hunks:
|
|
318
|
+
old_pos, new_pos = hunk["oldStart"] - 1, max(0, hunk["newStart"] - 1)
|
|
319
|
+
group = None
|
|
320
|
+
for line in [*hunk["lines"], " "]:
|
|
321
|
+
if line[0] == " ":
|
|
322
|
+
if group is not None:
|
|
323
|
+
actual.append(group)
|
|
324
|
+
group = None
|
|
325
|
+
old_pos += 1
|
|
326
|
+
new_pos += 1
|
|
327
|
+
else:
|
|
328
|
+
if group is None:
|
|
329
|
+
group = [old_pos, new_pos, [], []]
|
|
330
|
+
if line[0] == "-":
|
|
331
|
+
group[2].append(line[1:])
|
|
332
|
+
old_pos += 1
|
|
333
|
+
else:
|
|
334
|
+
group[3].append(line[1:])
|
|
335
|
+
new_pos += 1
|
|
336
|
+
if (
|
|
337
|
+
not expected
|
|
338
|
+
or not actual
|
|
339
|
+
or len(actual) % len(expected)
|
|
340
|
+
or (not replace_all and len(actual) != len(expected))
|
|
341
|
+
):
|
|
342
|
+
return False
|
|
343
|
+
for occurrence in range(len(actual) // len(expected)):
|
|
344
|
+
offsets = None
|
|
345
|
+
for group, (_, i1, i2, j1, j2) in zip(actual[occurrence * len(expected) :], expected, strict=False):
|
|
346
|
+
before, after = "\n".join(group[2]), "\n".join(group[3])
|
|
347
|
+
wanted_before, wanted_after = "\n".join(old_lines[i1:i2]), "\n".join(new_lines[j1:j2])
|
|
348
|
+
position = before.find(wanted_before)
|
|
349
|
+
matched = False
|
|
350
|
+
while position >= 0:
|
|
351
|
+
prefix, suffix = before[:position], before[position + len(wanted_before) :]
|
|
352
|
+
if (
|
|
353
|
+
(not prefix or (i1 == j1 == 0 and "\n" not in prefix))
|
|
354
|
+
and (
|
|
355
|
+
not suffix
|
|
356
|
+
or (
|
|
357
|
+
i2 == len(old_lines)
|
|
358
|
+
and j2 == len(new_lines)
|
|
359
|
+
and not old.endswith("\n")
|
|
360
|
+
and not new.endswith("\n")
|
|
361
|
+
and "\n" not in suffix
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
and after == prefix + wanted_after + suffix
|
|
365
|
+
and (wanted_before or not before)
|
|
366
|
+
):
|
|
367
|
+
matched = True
|
|
368
|
+
break
|
|
369
|
+
position = before.find(wanted_before, position + 1) if wanted_before else -1
|
|
370
|
+
alignment = (group[0] - i1, group[1] - j1)
|
|
371
|
+
if not matched or (offsets is not None and alignment != offsets):
|
|
372
|
+
return False
|
|
373
|
+
offsets = alignment
|
|
374
|
+
if offsets[1] - offsets[0] != occurrence * (new.count("\n") - old.count("\n")):
|
|
375
|
+
return False
|
|
376
|
+
for hunk in hunks:
|
|
377
|
+
for marker, start, offset, lines, text in (
|
|
378
|
+
("+", hunk["oldStart"] - 1, offsets[0], old_lines, old),
|
|
379
|
+
("-", max(0, hunk["newStart"] - 1), offsets[1], new_lines, new),
|
|
380
|
+
):
|
|
381
|
+
visible = [line[1:] for line in hunk["lines"] if line[0] != marker]
|
|
382
|
+
for index, line in enumerate(visible):
|
|
383
|
+
relative = start + index - offset
|
|
384
|
+
if not 0 <= relative < len(lines):
|
|
385
|
+
continue
|
|
386
|
+
expected_line = lines[relative]
|
|
387
|
+
if relative == 0 and relative == len(lines) - 1 and not text.endswith("\n"):
|
|
388
|
+
valid = expected_line in line
|
|
389
|
+
elif relative == 0:
|
|
390
|
+
valid = line.endswith(expected_line)
|
|
391
|
+
elif relative == len(lines) - 1 and not text.endswith("\n"):
|
|
392
|
+
valid = line.startswith(expected_line)
|
|
393
|
+
else:
|
|
394
|
+
valid = line == expected_line
|
|
395
|
+
if not valid:
|
|
396
|
+
return False
|
|
397
|
+
return True
|