Files
hooks/hooks/lang-guard.sh
T
jiantw83 75fba4715c feat(simplified): 字表擴充到 1006 字並補齊跳過清單
What:簡體字表由 102 字擴充到 1006 字;lang-guard.sh 的跳過清單補上
jsc-meta 的 references/ste100.md 與 tools/ste100-lint.sh。

Why:102 字的舊表覆蓋率太低——拿 21 個常見簡體詞實測只抓到 4 個,「简体字」
「举办」「觉得」「战争」「医药」這類全部漏掉,規則等於形同虛設。跳過清單則是
對稱性問題:規則文件與機檢工具本身會列舉簡體字當範例,不跳過就每次先抓到自己。

How:字表逐字檢查「簡化後才出現的字形」,剔除正體也在用的字(后、台、干、只、
里、面、制、志、出、斗、丑、了、卷、咸、云、留、言、短、困、陷、游、封、吞),
再拿 jsc 全部 11 個存取庫共 221 個檔案的既有正體中文語料回歸驗證,誤報為零。
檔頭寫明排除原則與「改完必須拿語料重驗」的要求。跳過清單比照 ste100-lint.sh 的做法。

Who:非程式碼輸出的繁中無亂碼檢查。
2026-08-27 09:53:55 +08:00

179 lines
8.8 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env sh
# lang-guard.sh — 非程式碼輸出一律繁體中文、UTF-8、無亂碼、無簡體字(hook > prompt 的強制層)。
# 規則正文的唯一來源:jsc-meta 的 references/ste100.md。本腳本只實作可用樣式判定的三項:
# 簡體字、亂碼、非 UTF-8 編碼;用詞、標點、語氣那些判不出來的交給 ste100-guard.sh 的提示層。
#
# 用法:
# lang-guard.sh prompt 注入規則摘要(UserPromptSubmit 或規則檔取文字用)
# lang-guard.sh 掃描剛寫入的單一檔案(PostToolUse)
# lang-guard.sh sweep [dir] 掃描整個工作區這次改過的所有檔案(沒有 post-tool hook 的 CLI 用)
#
# 為什麼要有 sweep:只有 claude 接得到 PostToolUse,逐檔精準掃得到。codex 只有每輪結束的
# notify、kiro 只有 userPromptSubmit、copilot 與 antigravity 只有包裝別名,這四個都拿不到
# 「剛剛寫了哪個檔」,只能改成掃整個工作區的 git diff。時機晚一點,涵蓋範圍一樣。
#
# 輸入相容:
# Claude: PostToolUse 的 stdin JSON,取 tool_input.file_path。
# 其他 CLI: 環境變數 JSC_CHANGED_FILE。
# 兩者都取不到就安靜降級(exit 0)。
#
# 掃描範圍跟 comment-scope.sh 有兩點刻意不同,不要照抄那支的判斷:
# 1. 三項檢查都掃整個檔案,不是只掃註解行。程式碼的任何位置都不該出現簡體字或亂碼——
# 字串常值、識別字、資料內容一樣算輸出。comment-scope.sh 只掃註解,是因為它抓的是
# 註解夾帶文件編號,那件事只發生在註解裡。
# 2. markdown 與純文字檔要掃。它們正是「非程式碼輸出」的主場:README、wiki 頁、PR 描述、
# commit 訊息都是這類檔案。comment-scope.sh 刻意跳過 .md,因為那裡沒有程式碼註解。
#
# 掃描深度:檔案在 git 工作區內且已追蹤,就只掃 `git diff HEAD` 的新增行,不翻舊帳;
# 不在 git 內或檔案尚未追蹤才整檔掃描。sweep 一律只看 git diff。
#
# 結束碼:0=沒命中或資料不足;2=命中,訊息走 stderr 交回模型自行修正(不擋寫入,檔案已經寫好了)。
# 逃生門:JSC_LANG_GUARD=off。
set -u
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib.sh" 2>/dev/null || true
[ "${JSC_LANG_GUARD:-on}" = "off" ] && exit 0
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
WORDLIST="$HERE/simplified.txt"
if [ "${1:-}" = "prompt" ]; then
echo "[jsc] 所有非程式碼輸出一律繁體中文、UTF-8、無亂碼、無簡體字。適用範圍:程式碼註解、commit 訊息、PR 描述與標題、wiki 頁、對使用者的回報、README 與各種文件、錯誤訊息與日誌文字。程式碼本身的關鍵字、識別字、API 欄位名維持原樣,但其中的中文一樣要是繁體。"
echo "[jsc] 送出前自我檢查:1)簡體字(例:应、为、这、说、发、国)一律換成繁體;2)替代字元「U+FFFD」與雙重編碼亂碼(Ã、â 開頭的怪序列)代表編碼壞掉,重寫那段而不是保留;3)檔案一律存成 UTF-8,不加 BOM。規則正文見 jsc-meta 的 references/ste100.md。"
exit 0
fi
# 簡體字樣式:由 hooks/simplified.txt 組出,字表是單一真實來源,腳本裡不留第二份。
# 讀不到字表就回傳 1,呼叫端安靜跳過這一項,不中斷整支腳本——少抓一項,好過整支 hook 死掉。
simplified_pattern() {
[ -f "$WORDLIST" ] || return 1
_p=$(sed -e 's/#.*//' -e 's/[[:space:]]//g' "$WORDLIST" 2>/dev/null \
| grep -v '^$' | tr '\n' '|' | sed 's/|$//')
[ -n "$_p" ] || return 1
printf '%s' "$_p"
}
# 亂碼樣式一律用位元組比對(LC_ALL=C),不靠語系的字元範圍:
# 替代字元 U+FFFD(EF BF BD)本身;
# � ——「U+FFFD 的 UTF-8 位元組再被當成 Latin-1 讀一次」的雙重編碼殘骸;
# U+00C0–U+00FF 的字後面緊接 U+0080–U+00BF 的字(ä、æ¸、è©、ç”),這是 UTF-8 被當成
# Latin-1 讀一次再存回 UTF-8 的固定長相。中文的 UTF-8 前導位元組落在 E4–E9,被誤讀後
# 就變成 ä–é 開頭、後面接 U+0080–U+00BF 的兩三個字,所以前導字要收整個 Latin-1 字母段,
# 只收「Ã」會漏掉最常見的中文亂碼;
# â 後面接任何非 ASCII 字(’、“),引號與破折號被雙重編碼時的典型長相。
# 用位元組比對是因為 grep -E 的字元範圍在不同語系下行為不一致,位元組範圍到哪都一樣。
# 誤報防線:正常的西歐文字(câmara、crème、naïve)重音字後面接的是 ASCII 字母,不會命中。
mojibake_pattern() {
_fffd=$(printf '\357\277\275')
_lo=$(printf '\200'); _hi=$(printf '\277')
_c3=$(printf '\303'); _c2=$(printf '\302') # U+00C0–U+00FF 與 U+0080–U+00BF 的前導位元組
_bx=$(printf '\303\242') # 「â」
_l2=$(printf '\302'); _h2=$(printf '\364')
printf '%s|%s|%s[%s-%s]%s[%s-%s]|%s[%s-%s]' \
"$_fffd" '�' \
"$_c3" "$_lo" "$_hi" "$_c2" "$_lo" "$_hi" \
"$_bx" "$_l2" "$_h2"
}
hit() { # $1=樣式 $2=說明;命中就把說明與最多三行證據印到 stdout
m=$(printf '%s\n' "$lines" | LC_ALL=C grep -nE "$1" 2>/dev/null | head -n 3)
[ -n "$m" ] || return 0
printf ' %s\n' "$2"
printf '%s\n' "$m" | sed 's/^/ /'
}
scan_file() { # $1=檔案路徑;命中就把報告印到 stdout 並回傳 1,沒命中回傳 0
f=$1
[ -f "$f" ] || return 0
# 產生檔與壓縮輸出跳過:內容不是人寫的,抓到也沒有人要改。
case "$f" in
*.lock|*.min.js|*.min.css|*.map) return 0 ;;
esac
# 字表與兩支語言規則腳本跳過:這幾份檔案裡的簡體字與亂碼樣本是「被討論的對象」,
# 不是被使用。同一個道理,jsc-meta 的 ste100-lint.sh 也跳過 references/ste100.md。
# 不跳過的話,這支 hook 每次都會先抓到自己,訊號全被自己的噪音蓋掉。
case "$f" in
*/simplified.txt|simplified.txt) return 0 ;;
*/ste100-guard.sh|ste100-guard.sh) return 0 ;;
*/lang-guard.sh|lang-guard.sh) return 0 ;;
*/references/ste100.md|ste100.md) return 0 ;;
*/ste100-lint.sh|ste100-lint.sh) return 0 ;;
esac
# 二進位檔跳過。只認 NUL 位元組——拿「非可列印字元」當判準會把所有含中文的檔案誤判成二進位。
raw=$(head -c 1024 "$f" 2>/dev/null | wc -c)
txt=$(head -c 1024 "$f" 2>/dev/null | LC_ALL=C tr -d '\000' | wc -c)
[ "$raw" = "$txt" ] || return 0
d=$(dirname -- "$f")
if git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
git -C "$d" ls-files --error-unmatch -- "$f" >/dev/null 2>&1; then
lines=$(git -C "$d" diff HEAD -- "$f" 2>/dev/null | sed -n 's/^+[^+]/&/p' | cut -c2-)
[ -n "$lines" ] || return 0
else
lines=$(cat "$f" 2>/dev/null)
fi
out=$(
_sp=$(simplified_pattern) && hit "$_sp" '簡體字,改成繁體'
hit "$(mojibake_pattern)" '亂碼:替代字元或雙重編碼殘骸,重寫這一段'
# 編碼檢查沒有行號可指,命中就整檔報一行。沒有 iconv 就跳過這一項。
if command -v iconv >/dev/null 2>&1; then
printf '%s\n' "$lines" | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1 \
|| printf ' %s\n' '非 UTF-8 編碼,整檔轉存成 UTF-8(不加 BOM)'
fi
)
[ -n "$out" ] || return 0
printf '%s\n' "$f"
printf '%s\n' "$out"
return 1
}
advice() {
printf ' 修法:簡體字換成對應繁體字;亂碼那段重打,不要留著半壞的字元;檔案存成 UTF-8。\n'
printf ' 規則正文見 jsc-meta 的 references/ste100.md,字表在 hooks/simplified.txt。誤判時用 JSC_LANG_GUARD=off 關閉。\n'
}
if [ "${1:-}" = "sweep" ]; then
target=${2:-.}
[ -d "$target" ] || exit 0
root=$(git -C "$target" rev-parse --show-toplevel 2>/dev/null) || exit 0
[ -n "$root" ] || exit 0
changed=$(git -C "$root" diff --name-only HEAD 2>/dev/null)
[ -n "$changed" ] || exit 0
# 報告累積在暫存檔:迴圈跑在管線的子行程裡,變數帶不回來。
tmp=${TMPDIR:-/tmp}/jsc-lang-guard.$$
: > "$tmp" 2>/dev/null || exit 0
printf '%s\n' "$changed" | while IFS= read -r rel; do
[ -n "$rel" ] || continue
scan_file "$root/$rel" >> "$tmp" 2>/dev/null
done
if [ -s "$tmp" ]; then
{
printf '[jsc] 工作區有簡體字、亂碼或編碼問題,請就地修正:\n'
sed 's/^/ /' "$tmp"
advice
} >&2
rm -f "$tmp"
exit 2
fi
rm -f "$tmp"
exit 0
fi
read_stdin 2>/dev/null || STDIN_JSON=""
file=$(json_str file_path 2>/dev/null || true)
[ -n "$file" ] || file="${JSC_CHANGED_FILE:-}"
[ -n "$file" ] || exit 0
report=$(scan_file "$file") && exit 0
{
printf '[jsc] 這個檔案有簡體字、亂碼或編碼問題,請就地修正:\n'
printf '%s\n' "$report"
advice
} >&2
exit 2