Skip to content

Commit 53aeec7

Browse files
committed
bugfix: render-diff.py bracket placement respects word boundaries (M212)
- Add expand_to_word_boundary() to extend brackets to complete words - Add _find_wrap_point() to avoid splitting inside [...] brackets - Fixes: [ma]x_width now shows [max_width]
1 parent 110ecac commit 53aeec7

3 files changed

Lines changed: 124 additions & 19 deletions

File tree

.claude/cat/retrospectives/mistakes.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2495,6 +2495,32 @@
24952495
"catches_variations": true
24962496
},
24972497
"correct_behavior": "Copy-paste the EXACT render-diff.py Bash output into response - do not extract, reformat, or summarize"
2498+
},
2499+
{
2500+
"id": "M212",
2501+
"timestamp": "2026-01-23T12:15:00Z",
2502+
"category": "logical_error",
2503+
"description": "render-diff.py placed [] brackets incorrectly, splitting words like max_width into [ma]x_width",
2504+
"root_cause": "Character-level diff matching found minimal differences without respecting word boundaries, and line wrapping could split bracketed regions",
2505+
"rca_method": "C",
2506+
"rca_method_name": "causal-barrier",
2507+
"prevention_type": "code_fix",
2508+
"prevention_path": "/workspace/plugin/scripts/render-diff.py",
2509+
"pattern_keywords": [
2510+
"render-diff",
2511+
"brackets",
2512+
"word-boundary",
2513+
"wrapping"
2514+
],
2515+
"prevention_implemented": true,
2516+
"prevention_verified": true,
2517+
"recurrence_of": null,
2518+
"prevention_quality": {
2519+
"verification_type": "positive",
2520+
"fragility": "low",
2521+
"catches_variations": true
2522+
},
2523+
"correct_behavior": "Expand bracket boundaries to complete words and wrap lines at safe points outside brackets"
24982524
}
24992525
]
25002526
}

.claude/cat/retrospectives/retrospectives.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"last_retrospective": "2026-01-22T17:40:38-05:00",
3-
"mistake_count_since_last": 5,
3+
"mistake_count_since_last": 6,
44
"config": {
55
"mistake_count_threshold": 10,
66
"trigger_interval_days": 7

plugin/scripts/render-diff.py

Lines changed: 97 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,33 @@ def common_suffix_len(s1: str, s2: str) -> int:
164164
return max_len
165165

166166

167+
def is_word_char(c: str) -> bool:
168+
"""Check if character is part of a word/identifier."""
169+
return c.isalnum() or c == '_'
170+
171+
172+
def expand_to_word_boundary(line: str, start: int, end: int) -> tuple[int, int]:
173+
"""Expand a range to include complete words at boundaries.
174+
175+
If start is in the middle of a word, expand left to word start.
176+
If end is in the middle of a word, expand right to word end.
177+
"""
178+
# Expand start leftward if in middle of word
179+
while start > 0 and is_word_char(line[start - 1]) and is_word_char(line[start]):
180+
start -= 1
181+
182+
# Expand end rightward if in middle of word
183+
while end < len(line) and is_word_char(line[end - 1]) and is_word_char(line[end]):
184+
end += 1
185+
186+
return start, end
187+
188+
167189
def highlight_word_diff(old_line: str, new_line: str, is_old: bool, used: UsedSymbols) -> str:
168-
"""Apply word-level diff highlighting with [] brackets."""
190+
"""Apply word-level diff highlighting with [] brackets.
191+
192+
Expands bracket boundaries to complete words to avoid splitting identifiers.
193+
"""
169194
prefix_len = common_prefix_len(old_line, new_line)
170195
suffix_len = common_suffix_len(old_line, new_line)
171196

@@ -183,18 +208,24 @@ def highlight_word_diff(old_line: str, new_line: str, is_old: bool, used: UsedSy
183208
if old_diff_len <= 0 or new_diff_len <= 0:
184209
return old_line if is_old else new_line
185210

186-
used.bracket = True
187-
211+
# Expand to word boundaries for cleaner highlighting
188212
if is_old:
189-
prefix = old_line[:prefix_len]
190-
diff_part = old_line[prefix_len:prefix_len + old_diff_len]
191-
suffix = old_line[prefix_len + old_diff_len:]
192-
return f"{prefix}[{diff_part}]{suffix}"
213+
start = prefix_len
214+
end = prefix_len + old_diff_len
215+
start, end = expand_to_word_boundary(old_line, start, end)
216+
prefix = old_line[:start]
217+
diff_part = old_line[start:end]
218+
suffix = old_line[end:]
193219
else:
194-
prefix = new_line[:prefix_len]
195-
diff_part = new_line[prefix_len:prefix_len + new_diff_len]
196-
suffix = new_line[prefix_len + new_diff_len:]
197-
return f"{prefix}[{diff_part}]{suffix}"
220+
start = prefix_len
221+
end = prefix_len + new_diff_len
222+
start, end = expand_to_word_boundary(new_line, start, end)
223+
prefix = new_line[:start]
224+
diff_part = new_line[start:end]
225+
suffix = new_line[end:]
226+
227+
used.bracket = True
228+
return f"{prefix}[{diff_part}]{suffix}"
198229

199230

200231
class DiffRenderer:
@@ -287,6 +318,47 @@ def _print_column_header(self, context: str):
287318
f"{fill_char(BOX_HORIZONTAL, self.content_width + 1)}{BOX_T_LEFT}"
288319
)
289320

321+
def _find_wrap_point(self, content: str, max_width: int) -> int:
322+
"""Find safe wrap point that doesn't split inside [...] brackets.
323+
324+
Returns character index to wrap at, preserving bracket integrity.
325+
"""
326+
if len(content) <= max_width:
327+
return len(content)
328+
329+
# Track bracket nesting to avoid splitting inside [...]
330+
bracket_depth = 0
331+
last_safe_point = 0
332+
current_width = 0
333+
334+
for i, char in enumerate(content):
335+
# Calculate display width
336+
ea = unicodedata.east_asian_width(char)
337+
char_width = 2 if ea in ('W', 'F') else 1
338+
339+
if current_width + char_width > max_width - 1: # -1 for wrap indicator
340+
# Must wrap here or earlier
341+
if bracket_depth == 0:
342+
return i
343+
elif last_safe_point > 0:
344+
return last_safe_point
345+
else:
346+
# No safe point found, wrap anyway (rare edge case)
347+
return i
348+
349+
current_width += char_width
350+
351+
if char == '[':
352+
bracket_depth += 1
353+
elif char == ']':
354+
bracket_depth = max(0, bracket_depth - 1)
355+
356+
# Safe points are outside brackets
357+
if bracket_depth == 0:
358+
last_safe_point = i + 1
359+
360+
return len(content)
361+
290362
def _print_row(self, old_num: str, symbol: str, new_num: str, content: str):
291363
"""Print a content row, handling wrapping for long lines."""
292364
content_len = display_width(content)
@@ -300,16 +372,20 @@ def _print_row(self, old_num: str, symbol: str, new_num: str, content: str):
300372
f"{padded_content}{BOX_VERTICAL}"
301373
)
302374
else:
303-
# Wrap long lines
375+
# Wrap long lines, preserving bracket integrity
304376
self.used.wrap = True
305-
first_part = content[:self.content_width - 1]
377+
wrap_point = self._find_wrap_point(content, self.content_width)
378+
first_part = content[:wrap_point]
379+
first_width = display_width(first_part)
380+
# Pad to content_width - 1 to leave room for wrap indicator
381+
padding = ' ' * max(0, self.content_width - 1 - first_width)
306382
self.output.append(
307383
f"{BOX_VERTICAL}{pad_num(old_num, COL_OLD)}{BOX_VERTICAL} {symbol} "
308384
f"{BOX_VERTICAL}{pad_num(new_num, COL_NEW)}{BOX_VERTICAL} "
309-
f"{first_part}{BOX_VERTICAL}"
385+
f"{first_part}{padding}{BOX_VERTICAL}"
310386
)
311387

312-
remaining = content[self.content_width - 1:]
388+
remaining = content[wrap_point:]
313389
while remaining:
314390
part_len = display_width(remaining)
315391
if part_len <= self.content_width:
@@ -321,13 +397,16 @@ def _print_row(self, old_num: str, symbol: str, new_num: str, content: str):
321397
)
322398
remaining = ''
323399
else:
324-
next_part = remaining[:self.content_width - 1]
400+
wrap_point = self._find_wrap_point(remaining, self.content_width)
401+
next_part = remaining[:wrap_point]
402+
next_width = display_width(next_part)
403+
padding = ' ' * max(0, self.content_width - 1 - next_width)
325404
self.output.append(
326405
f"{BOX_VERTICAL}{' ' * COL_OLD}{BOX_VERTICAL} "
327406
f"{BOX_VERTICAL}{' ' * COL_NEW}{BOX_VERTICAL} "
328-
f"{next_part}{BOX_VERTICAL}"
407+
f"{next_part}{padding}{BOX_VERTICAL}"
329408
)
330-
remaining = remaining[self.content_width - 1:]
409+
remaining = remaining[wrap_point:]
331410

332411
def _print_hunk_bottom(self):
333412
"""Print hunk box bottom."""

0 commit comments

Comments
 (0)