Skip to content

Commit 31bac94

Browse files
authored
fix: add diacritic preservation pre-commit hook (#521)
Add scripts/preserve-diacritics.py + scripts/test_preserve_diacritics.py and wire the hook into .pre-commit-config.yaml. Blocks commits that mechanically rewrite Unicode content to ASCII: - Em-dash/en-dash/ellipsis/arrows/smart quotes -> ASCII forms - Vietnamese diacritics stripped (NFD-collapse or transliteration) - Emoji removed or replaced with text labels Pure additions of diacritics/Unicode punctuation always pass. Detection uses paired-diff hunks with a similarity heuristic to suppress false positives on genuine content rewrites. Stderr forced to UTF-8 for readable output on Windows consoles. Source + tests live in n24q02m/claude-plugins (PR #145). Companion PRs deploy the same script across the fleet so every repo carries its own local copy of the guard. Rule reference: feedback_vietnamese_diacritics (no mechanical ASCIIfication).
1 parent 904fe36 commit 31bac94

3 files changed

Lines changed: 484 additions & 0 deletions

File tree

.pre-commit-config.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,17 @@ repos:
6161
entry: bash scripts/enforce-commit.sh
6262
language: system
6363
stages: [commit-msg]
64+
65+
# ============================================================================
66+
# Content integrity: block ASCII rewriting of Vietnamese diacritics
67+
# + Unicode punctuation (em-dash, ellipsis, arrows, smart quotes, emoji).
68+
# See scripts/preserve-diacritics.py and feedback_vietnamese_diacritics rule.
69+
# ============================================================================
70+
- repo: local
71+
hooks:
72+
- id: preserve-diacritics
73+
name: Preserve Vietnamese diacritics + Unicode punctuation
74+
entry: python scripts/preserve-diacritics.py
75+
language: system
76+
pass_filenames: false
77+
stages: [pre-commit]

scripts/preserve-diacritics.py

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
#!/usr/bin/env python3
2+
"""Pre-commit hook: prevent ASCII rewriting of Vietnamese diacritics + Unicode punctuation.
3+
4+
Blocks commits that replace:
5+
1. Unicode punctuation (em-dash, ellipsis, arrows, smart quotes) with ASCII equivalents.
6+
2. Vietnamese diacritics with bare vowels (NFD-strip or transliteration).
7+
3. Emoji characters removed or replaced with ASCII labels.
8+
9+
Rationale: Jules/Sentinel style AI PRs frequently "normalize" non-ASCII text, which
10+
silently damages Vietnamese content + typography. Rule: feedback_vietnamese_diacritics.
11+
12+
Pure additions of diacritics / Unicode punctuation (progress) always PASS.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import io
18+
import re
19+
import subprocess
20+
import sys
21+
import unicodedata
22+
from pathlib import Path
23+
24+
# Force UTF-8 on stderr so Vietnamese / Unicode chars display correctly on
25+
# Windows (default cp1252 otherwise replaces non-ASCII chars with '?').
26+
if hasattr(sys.stderr, "buffer"):
27+
sys.stderr = io.TextIOWrapper(
28+
sys.stderr.buffer, encoding="utf-8", errors="replace", line_buffering=True
29+
)
30+
31+
# Unicode char -> candidate ASCII replacements frequently produced by AI rewrites.
32+
UNICODE_REPLACEMENTS: dict[str, list[str]] = {
33+
"\u2014": ["---", "--", "-"], # em-dash
34+
"\u2013": ["--", "-"], # en-dash
35+
"\u2026": ["..."], # horizontal ellipsis
36+
"\u2192": ["->"], # rightwards arrow
37+
"\u2190": ["<-"], # leftwards arrow
38+
"\u21d2": ["=>"], # rightwards double arrow
39+
"\u21d0": ["<="], # leftwards double arrow
40+
"\u2194": ["<->"], # left-right arrow
41+
"\u201c": ['"'], # left double quote
42+
"\u201d": ['"'], # right double quote
43+
"\u2018": ["'"], # left single quote
44+
"\u2019": ["'"], # right single quote / apostrophe
45+
"\u00d7": ["x", "*"], # multiplication sign
46+
"\u2713": ["v", "[x]", "[v]"], # check mark
47+
"\u2717": ["x", "[ ]"], # ballot x
48+
"\u00b7": ["*", "."], # middle dot
49+
"\u2022": ["*", "-"], # bullet
50+
}
51+
52+
# Vietnamese precomposed letters (NFC). Lowercase + uppercase.
53+
_VN_BASE = "àảãáạâấầẩẫậăắằẳẵặèẻẽéẹêếềểễệìỉĩíịòỏõóọôốồổỗộơớờởỡợùủũúụưứừửữựỳỷỹýỵđ"
54+
VIETNAMESE_DIACRITIC_CHARS: set[str] = set(_VN_BASE + _VN_BASE.upper())
55+
56+
# Emoji detection: any codepoint in common emoji blocks.
57+
_EMOJI_RE = re.compile(
58+
"["
59+
"\U0001f300-\U0001f5ff" # Misc symbols & pictographs
60+
"\U0001f600-\U0001f64f" # Emoticons
61+
"\U0001f680-\U0001f6ff" # Transport & map
62+
"\U0001f700-\U0001f77f" # Alchemical
63+
"\U0001f780-\U0001f7ff" # Geometric shapes extended
64+
"\U0001f800-\U0001f8ff" # Supplemental arrows-C
65+
"\U0001f900-\U0001f9ff" # Supplemental symbols & pictographs
66+
"\U0001fa00-\U0001fa6f" # Chess / symbols
67+
"\U0001fa70-\U0001faff" # Symbols & pictographs extended-A
68+
"\U00002600-\U000026ff" # Misc symbols
69+
"\U00002700-\U000027bf" # Dingbats
70+
"]",
71+
flags=re.UNICODE,
72+
)
73+
74+
# Files we deliberately skip (binary-ish or generated).
75+
_SKIP_SUFFIXES = {
76+
".lock",
77+
".svg",
78+
".png",
79+
".jpg",
80+
".jpeg",
81+
".gif",
82+
".webp",
83+
".ico",
84+
".pdf",
85+
".zip",
86+
".tar",
87+
".gz",
88+
".woff",
89+
".woff2",
90+
".ttf",
91+
".eot",
92+
".mp3",
93+
".mp4",
94+
".webm",
95+
".wasm",
96+
".min.js",
97+
".min.css",
98+
}
99+
_SKIP_DIRS = {".git", "node_modules", "dist", "build", ".venv", "venv", "__pycache__"}
100+
101+
102+
def _is_skippable(path: str) -> bool:
103+
p = Path(path)
104+
if any(part in _SKIP_DIRS for part in p.parts):
105+
return True
106+
if p.suffix.lower() in _SKIP_SUFFIXES:
107+
return True
108+
# Lockfiles
109+
return p.name in {
110+
"bun.lockb",
111+
"bun.lock",
112+
"package-lock.json",
113+
"yarn.lock",
114+
"uv.lock",
115+
"poetry.lock",
116+
"Cargo.lock",
117+
"go.sum",
118+
}
119+
120+
121+
def _run_git(args: list[str]) -> str:
122+
"""Run git returning UTF-8 decoded stdout. Windows cp1252 default would
123+
mangle Vietnamese/Unicode — force UTF-8 explicitly."""
124+
raw = subprocess.check_output(["git", *args])
125+
return raw.decode("utf-8", errors="replace")
126+
127+
128+
def _staged_files() -> list[str]:
129+
"""Files added or modified in the staged index (no deletions, no renames-only)."""
130+
out = _run_git(["diff", "--cached", "--name-only", "--diff-filter=AM"])
131+
return [line for line in out.splitlines() if line]
132+
133+
134+
def _diff_pairs(file_path: str) -> list[tuple[int, str, str]]:
135+
"""Return list of (line_number, removed_line, added_line) pairs.
136+
137+
Pairs are aligned within the same hunk using position matching: the k-th
138+
'-' removal is paired with the k-th '+' addition of that hunk. Unpaired
139+
lines (pure add / pure delete) are skipped — they are definitionally
140+
not rewrites of existing content.
141+
"""
142+
try:
143+
diff = _run_git(["diff", "--cached", "-U0", "--no-color", "--", file_path])
144+
except subprocess.CalledProcessError:
145+
return []
146+
147+
pairs: list[tuple[int, str, str]] = []
148+
removed: list[str] = []
149+
added: list[str] = []
150+
plus_line_no = 0
151+
hunk_plus_start = 0
152+
153+
def _flush(start_line: int) -> None:
154+
# Pair k-th removed with k-th added; any overflow is pure add/delete.
155+
for idx in range(min(len(removed), len(added))):
156+
pairs.append((start_line + idx, removed[idx], added[idx]))
157+
removed.clear()
158+
added.clear()
159+
160+
for line in diff.splitlines():
161+
if line.startswith("@@"):
162+
_flush(hunk_plus_start)
163+
m = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
164+
if m:
165+
hunk_plus_start = int(m.group(1))
166+
plus_line_no = hunk_plus_start
167+
continue
168+
if line.startswith("---") or line.startswith("+++"):
169+
continue
170+
if line.startswith("-"):
171+
removed.append(line[1:])
172+
elif line.startswith("+"):
173+
added.append(line[1:])
174+
else:
175+
# context line (unlikely with -U0) — flush
176+
_flush(hunk_plus_start)
177+
plus_line_no += 1
178+
hunk_plus_start = plus_line_no
179+
_flush(hunk_plus_start)
180+
return pairs
181+
182+
183+
def _strip_diacritics(s: str) -> str:
184+
"""Return NFD-stripped lowercase form with đ->d, Đ->D."""
185+
s = s.replace("đ", "d").replace("Đ", "D")
186+
nfd = unicodedata.normalize("NFD", s)
187+
return "".join(c for c in nfd if not unicodedata.combining(c))
188+
189+
190+
def _check_pair(old: str, new: str) -> list[tuple[str, str, str]]:
191+
"""Return list of (rule, old_excerpt, new_excerpt) violations for one pair."""
192+
violations: list[tuple[str, str, str]] = []
193+
194+
# Rule 1: Unicode punctuation replaced with ASCII.
195+
# Strategy: strip ALL tracked unicode punct from `old` and ALL their ASCII
196+
# forms from `new`; if the resulting skeletons match (similarity), this is
197+
# a mechanical normalization, not a content rewrite.
198+
old_skel = old
199+
new_skel = new
200+
hit_uni: list[str] = []
201+
for uni, ascii_forms in UNICODE_REPLACEMENTS.items():
202+
if uni in old and uni not in new and any(form in new for form in ascii_forms):
203+
hit_uni.append(uni)
204+
old_skel = old_skel.replace(uni, "")
205+
for form in ascii_forms:
206+
new_skel = new_skel.replace(form, "")
207+
if hit_uni and _similar(old_skel.strip(), new_skel.strip()):
208+
for uni in hit_uni:
209+
violations.append((f"unicode-punct {uni!r}->ascii", old, new))
210+
211+
# Rule 2: Vietnamese diacritics stripped.
212+
old_diacritics = [c for c in old if c in VIETNAMESE_DIACRITIC_CHARS]
213+
new_diacritics = [c for c in new if c in VIETNAMESE_DIACRITIC_CHARS]
214+
if len(old_diacritics) > len(new_diacritics):
215+
# Confirm via NFD-strip round-trip: does stripping old give us new?
216+
old_stripped = _strip_diacritics(old)
217+
new_lower = new.replace("đ", "d").replace("Đ", "D")
218+
if old_stripped.strip().lower() == new_lower.strip().lower():
219+
violations.append(("vietnamese-diacritic-strip", old, new))
220+
elif _similar(old_stripped, new_lower) and len(old_diacritics) - len(new_diacritics) >= 2:
221+
# Many diacritics vanished but content otherwise similar.
222+
violations.append(("vietnamese-diacritic-strip", old, new))
223+
224+
# Rule 3: Emoji removed / replaced.
225+
old_emoji = _EMOJI_RE.findall(old)
226+
new_emoji = _EMOJI_RE.findall(new)
227+
if len(old_emoji) > len(new_emoji):
228+
# Confirm similarity so that full-paragraph rewrites don't trip it.
229+
old_no_emoji = _EMOJI_RE.sub("", old).strip()
230+
new_no_emoji = _EMOJI_RE.sub("", new).strip()
231+
if _similar(old_no_emoji, new_no_emoji):
232+
violations.append(("emoji-removed", old, new))
233+
234+
return violations
235+
236+
237+
def _similar(a: str, b: str) -> bool:
238+
"""Cheap similarity: shared >=70% of the shorter string's characters in order."""
239+
if not a and not b:
240+
return True
241+
if not a or not b:
242+
return False
243+
shorter, longer = (a, b) if len(a) <= len(b) else (b, a)
244+
if len(shorter) == 0:
245+
return False
246+
# Abs length gap guard: if one side is >2x the other, treat as different.
247+
if len(longer) > 2 * max(len(shorter), 1):
248+
return False
249+
# Character-in-order match ratio.
250+
i = 0
251+
for ch in longer:
252+
if i < len(shorter) and ch == shorter[i]:
253+
i += 1
254+
return (i / len(shorter)) >= 0.7
255+
256+
257+
def main() -> int:
258+
files = sys.argv[1:] if len(sys.argv) > 1 else _staged_files()
259+
files = [f for f in files if not _is_skippable(f) and Path(f).is_file()]
260+
261+
violations: list[tuple[str, int, str, str, str]] = []
262+
for f in files:
263+
for line_no, old, new in _diff_pairs(f):
264+
for rule, old_ex, new_ex in _check_pair(old, new):
265+
violations.append((f, line_no, rule, old_ex, new_ex))
266+
267+
if not violations:
268+
return 0
269+
270+
print(
271+
"ASCII-rewriting detected (violates feedback_vietnamese_diacritics rule):",
272+
file=sys.stderr,
273+
)
274+
for f, line_no, rule, old, new in violations[:20]:
275+
print(f" {f}:{line_no} [{rule}]", file=sys.stderr)
276+
print(f" OLD: {old[:120]}", file=sys.stderr)
277+
print(f" NEW: {new[:120]}", file=sys.stderr)
278+
if len(violations) > 20:
279+
print(f" ... and {len(violations) - 20} more", file=sys.stderr)
280+
print("", file=sys.stderr)
281+
print(f"Total violations: {len(violations)}", file=sys.stderr)
282+
print(
283+
"If intentional (e.g. fixing mojibake), bypass requires explicit user approval.",
284+
file=sys.stderr,
285+
)
286+
return 1
287+
288+
289+
if __name__ == "__main__":
290+
sys.exit(main())

0 commit comments

Comments
 (0)