mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-08-20 22:24:35 +00:00
* fix(prompt): replace the fullwidth colon in the file_read tool description tools.json advertised the example output as "File:path/to/example.go" with a fullwidth colon (U+FF1A), while file_read.go actually emits "File: %s". The description is sent to the model on every review, so the example did not match the output it was describing. Also switches action.yml's OCR_LANGUAGE example from 中文 to Chinese, for the same reason as #861: the value is fed to the LLM and Chinese is what the rest of the project uses. * chore(ci): fail CI when CJK characters appear in source files Comments, identifiers and strings in this repository are meant to be English, but nothing enforced it — #861 had to clean up leftovers by hand, and the same drift keeps arriving through generated code and contributions written internally. scripts/verify-cjk.go walks the index plus untracked files and reports Han ideographs, kana, CJK punctuation and fullwidth forms. Written in Go rather than shell so it does not depend on the container's grep having PCRE, and so `unicode.Is` decides what counts as CJK instead of a byte range that would flag the em dashes used throughout the comments. `//go:build ignore` keeps it out of ./..., so it does not affect go vet, go build or the coverage threshold. Untracked files are included (--others --exclude-standard) so a new file is checked before it lands: while writing this, the script's own comment used Chinese punctuation as an example and went unreported until it was staged. Two escape hatches, preferring the narrow one: an `allow-cjk: <reason>` marker comment on a single line, or a prefix in allowedPrefixes for a whole tree. 23 existing lines get markers (UTF-8 encoding fixtures, multibyte truncation fixtures, language-switcher labels, the fullwidth bar used as a terminal cursor). pages/src/i18n/ is allowlisted as translated UI copy; extensions/vscode/ is allowlisted TEMPORARILY — its comments, test names and zh-cn NLS bundle are still Chinese and need a follow-up pass. Wired into CI next to the license and action-pin checks, plus `make cjk-check` and `make check` for local runs. * chore(ci): generalise the CJK check to all non-English text Addresses the review feedback, and widens the rule that the feedback exposed. Review feedback: - exemptMarker requires its colon, so a bare "allow-cjk" can no longer exempt a line without giving a reason. - The script is named for CJK but missed Hangul. - git ls-files gains -z, so paths that are not plain ASCII arrive unquoted, and its stderr is reported rather than a bare exit status. - main discarded run()'s error entirely and only called os.Exit(1), which is what made the lost stderr invisible in the first place. - The CI step and AGENTS.md say "unapproved", since escape hatches exist. The check was skewed by writing system rather than by language. In one array the 'zh' and 'ja' labels each needed a marker while the adjacent 'ru' label passed untouched, and nine lines of Russian sat in the tree unflagged: two language-switcher labels and the heading-ID fixtures. Contributors writing Chinese had to justify every line; contributors writing Russian had nothing to justify. The rule is now "a letter outside ASCII", since written English needs no letter beyond the ASCII 26 -- Cyrillic and Han as much as the diacritics that spell German or Vietnamese. Scripts are not enumerated, so one nobody has contributed in yet is covered when it arrives. Common and Inherited pass, so letterlike symbols (U+2139, U+2113) are not mistaken for prose, and combining accents are caught, so the decomposed spelling of an accented letter cannot slip through. Symbols and emoji stay out of scope by construction: they are not letters. Renamed to scripts/verify-english-only.go and make english-check, and the marker to allow-non-english:. Text spelled entirely in ASCII still takes a dictionary to identify and stays a matter for review. * docs(agents): restate the English-only rule as rule, homes, hatches The rule was one dense bullet that led with the detection mechanism and mentioned the exemptions only in passing, which is the wrong order for the reader: an agent needs to know where a translation may go before it needs to know which Unicode scripts are flagged. Split into three. The homes are now spelled out from what the tree actually holds, rather than left as "<locale> docs or an i18n table": README and CONTRIBUTING in zh-CN, ja-JP, ko-KR and ru-RU; the doc pages under pages/src/content/docs/ in en, zh, ja and ru; the UI copy tables in pages/src/i18n/. Also why the two are exempt for different reasons -- Markdown by extension, the i18n tables by prefix because they are .ts -- since that decides where a new translation can safely go. Drops the enumerated list of what "make check" runs. It duplicated the Makefile, went stale the moment a check was added (this PR had to edit it), and told an agent nothing it would not read in the output anyway. What is worth saying is that the target writes to the tree. * fix(ci): detect U+FE10–FE6F CJK punctuation in english-only check The vertical forms (U+FE10–FE19), CJK compatibility forms (U+FE30–FE4F) and small form variants (U+FE50–FE6F) were not caught, even though their fullwidth counterparts (U+FF00–FFEF) already were. A small question mark (U+FE56 ﹖) or vertical comma (U+FE10 ︐) left in source reads as correct English punctuation and is invisible in review — the same class of typo the fullwidth range already defends against. Skip U+FE20–FE2F (Combining Half Marks) which are used in Latin text.
275 lines
10 KiB
Go
275 lines
10 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
||
// Copyright 2026 alibaba/open-code-review Contributors
|
||
|
||
//go:build ignore
|
||
|
||
// Command verify-english-only fails when unapproved non-English text appears in
|
||
// source files.
|
||
//
|
||
// Comments, identifiers and user-visible strings in this repository are written
|
||
// in English so that any contributor can review and maintain every file,
|
||
// whichever languages they happen to read. Translated content belongs in the
|
||
// locale-specific docs (README.zh-CN.md, pages/src/content/docs/zh/…) and in
|
||
// the i18n tables, not in code.
|
||
//
|
||
// What it detects, and the one thing it cannot:
|
||
//
|
||
// - Detected: every letter outside ASCII, whichever the writing system. Han,
|
||
// kana, Hangul, Cyrillic, Greek, Arabic, Hebrew and Devanagari, and equally
|
||
// the diacritics that spell German, French, Turkish or Vietnamese. Plus CJK
|
||
// and fullwidth punctuation, and combining accents.
|
||
// - Not detected: another language spelled entirely in ASCII — a romanised
|
||
// transcription, or German with its umlauts written out ("Loeschen der
|
||
// Datei"). Telling that from English needs a dictionary rather than a
|
||
// character test, so it stays a matter for review.
|
||
//
|
||
// Symbols are deliberately left alone: box drawing, arrows, emoji and maths
|
||
// (─ → ≥ ≈ ×) are not letters and appear throughout the TUI output on purpose.
|
||
//
|
||
// Markdown is not scanned: the translated READMEs, CONTRIBUTING files and doc
|
||
// pages are legitimately non-English.
|
||
//
|
||
// Run it directly (the build tag keeps it out of ./... so it does not affect
|
||
// go vet, go build or the coverage threshold):
|
||
//
|
||
// go run scripts/verify-english-only.go
|
||
//
|
||
// Two escape hatches exist, in order of preference:
|
||
//
|
||
// 1. Append an "allow-non-english: <reason>" marker comment to the offending
|
||
// line — the right choice for a handful of lines, e.g. an encoding fixture
|
||
// or a language-switcher label. The rest of the file stays protected.
|
||
//
|
||
// 2. Add a prefix to allowedPrefixes below, for whole trees that are
|
||
// inherently non-English (i18n tables) — or, temporarily, for a backlog
|
||
// that has not been translated yet.
|
||
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
// path, not path/filepath: every path here comes from git ls-files, which
|
||
// always emits forward slashes — on Windows too, since that is how the
|
||
// index stores them. The allowedPrefixes entries assume the same.
|
||
"path"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
// scannedExts lists the extensions treated as source files.
|
||
var scannedExts = map[string]bool{
|
||
".go": true, ".ts": true, ".tsx": true, ".js": true, ".cjs": true,
|
||
".mjs": true, ".py": true, ".sh": true, ".ps1": true, ".css": true,
|
||
".html": true, ".yml": true, ".yaml": true, ".json": true,
|
||
}
|
||
|
||
// scannedNames lists extension-less files that are still source files.
|
||
var scannedNames = map[string]bool{"Makefile": true}
|
||
|
||
// allowedPrefixes exempts paths whose non-English content is expected. Keep
|
||
// each entry narrow and justified; a temporary entry must say what removes it.
|
||
var allowedPrefixes = []struct{ prefix, reason string }{
|
||
{"pages/src/i18n/", "translated UI copy for the docs site"},
|
||
{"extensions/vscode/", "TEMPORARY: the extension's comments, test names and zh-cn NLS bundle are still Chinese; drop this entry once they are translated"},
|
||
}
|
||
|
||
// exemptMarker on a line suppresses the report for that line. The trailing
|
||
// colon is part of the marker so that a bare "allow-non-english" cannot exempt
|
||
// a line without saying why.
|
||
const exemptMarker = "allow-non-english:"
|
||
|
||
// isNonEnglish reports whether r is a letter no English word is written with,
|
||
// or one of the CJK/fullwidth punctuation forms.
|
||
//
|
||
// The rule is "a letter outside ASCII", not "a letter outside Latin". Written
|
||
// English needs no letter beyond the ASCII 26, so anything past that is another
|
||
// language: Cyrillic and Han as obviously as the diacritics of German, French or
|
||
// Turkish. Scripts are not enumerated, which keeps the rule stable as the
|
||
// contributor base grows — one that nobody has contributed in yet is covered on
|
||
// the day it arrives, with no edit here.
|
||
//
|
||
// Testing for letters, rather than for non-ASCII bytes, is what keeps symbols
|
||
// out of scope: the box drawing, arrows, emoji and maths in the TUI output are
|
||
// not letters, and neither are the em dashes used throughout these comments. A
|
||
// plain non-ASCII test would flag every one of them.
|
||
//
|
||
// Common and Inherited are the exception. Those two scripts hold the characters
|
||
// belonging to no writing system in particular, and the letterlike symbols among
|
||
// them are letters only by Unicode category: the information source (U+2139,
|
||
// category Ll) that renders as an info icon, the script small l (U+2113), the
|
||
// capitals of the maths alphabets. None of them writes a word in any language.
|
||
//
|
||
// Letterlike forms that Unicode does assign to a real script stay in scope, so
|
||
// the ohm sign (U+2126, script Greek because it is equivalent to U+03A9) is
|
||
// reported like any other Greek letter. A comment that spells sigma or omega as
|
||
// a glyph therefore needs a marker — deliberate, since exempting Greek to allow
|
||
// maths notation would exempt Greek prose with it.
|
||
//
|
||
// Punctuation is checked separately, and matters as much as letters: a
|
||
// fullwidth colon (U+FF1A) or comma (U+FF0C) left in an English sentence is a
|
||
// typo that reads as correct and is invisible in review. Vertical forms
|
||
// (U+FE10–U+FE19), CJK compatibility forms (U+FE30–U+FE4F) and small form
|
||
// variants (U+FE50–U+FE6F) are covered alongside the fullwidth block.
|
||
func isNonEnglish(r rune) bool {
|
||
switch {
|
||
case r < 0x80: // ASCII, the overwhelming majority of every scanned line
|
||
return false
|
||
case unicode.IsLetter(r) &&
|
||
!unicode.Is(unicode.Common, r) &&
|
||
!unicode.Is(unicode.Inherited, r):
|
||
return true
|
||
case r >= 0x0300 && r <= 0x036F:
|
||
// Combining diacritical marks, so that the decomposed spelling of an
|
||
// accented letter is caught too: NFD writes e-acute as "e" plus U+0301,
|
||
// where the letter itself is plain ASCII and the accent carries the
|
||
// language. Variation selectors (U+FE0F, which follows an emoji) are
|
||
// combining marks as well, but sit outside this block and pass.
|
||
return true
|
||
case r >= 0x3000 && r <= 0x303F: // CJK Symbols and Punctuation
|
||
return true
|
||
case r >= 0xFE10 && r <= 0xFE19: // Vertical Forms
|
||
return true
|
||
case r >= 0xFE30 && r <= 0xFE6F: // CJK Compatibility Forms + Small Form Variants
|
||
return true
|
||
case r >= 0xFF00 && r <= 0xFFEF: // Halfwidth and Fullwidth Forms
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isScanned(file string) bool {
|
||
if scannedNames[path.Base(file)] {
|
||
return true
|
||
}
|
||
return scannedExts[path.Ext(file)]
|
||
}
|
||
|
||
func allowedPrefix(file string) bool {
|
||
for _, a := range allowedPrefixes {
|
||
if strings.HasPrefix(file, a.prefix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// errReported marks a failure that run has already written to stderr in full,
|
||
// so main does not print a redundant one-line summary after the report.
|
||
var errReported = errors.New("findings already reported")
|
||
|
||
type finding struct {
|
||
file string
|
||
line int
|
||
text string
|
||
char rune
|
||
}
|
||
|
||
func scan(file string) ([]finding, error) {
|
||
f, err := os.Open(file)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer f.Close()
|
||
|
||
var found []finding
|
||
sc := bufio.NewScanner(f)
|
||
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||
for n := 1; sc.Scan(); n++ {
|
||
line := sc.Text()
|
||
if strings.Contains(line, exemptMarker) {
|
||
continue
|
||
}
|
||
for _, r := range line {
|
||
if isNonEnglish(r) {
|
||
found = append(found, finding{file: file, line: n, text: strings.TrimSpace(line), char: r})
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return found, sc.Err()
|
||
}
|
||
|
||
// trim shortens a reported line so the report stays readable.
|
||
func trim(s string) string {
|
||
const max = 100
|
||
if len([]rune(s)) <= max {
|
||
return s
|
||
}
|
||
return string([]rune(s)[:max]) + "…"
|
||
}
|
||
|
||
func run() error {
|
||
// --others --exclude-standard includes files that are not committed yet, so
|
||
// a new file is checked before it lands rather than the run after. Ignored
|
||
// paths (dist/, node_modules/) stay out.
|
||
//
|
||
// -z separates paths with NUL and emits them verbatim; without it git quotes
|
||
// and escapes any path that is not plain ASCII — exactly the kind of path
|
||
// internal/diff/git_test.go has fixtures for.
|
||
out, err := exec.Command("git", "ls-files", "-z", "--cached", "--others", "--exclude-standard").Output()
|
||
if err != nil {
|
||
// Output() fills ExitError.Stderr; without it the error reads as a bare
|
||
// "exit status 128" and the CI log never shows what git complained about.
|
||
var exitErr *exec.ExitError
|
||
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
|
||
return fmt.Errorf("git ls-files: %w: %s", err, strings.TrimSpace(string(exitErr.Stderr)))
|
||
}
|
||
return fmt.Errorf("git ls-files: %w", err)
|
||
}
|
||
|
||
var findings []finding
|
||
var scanned int
|
||
// NUL-terminated, so the final element is empty; the file == "" guard below
|
||
// drops it. Do not trim the output: a path may legitimately end in a space.
|
||
for _, file := range strings.Split(string(out), "\x00") {
|
||
if file == "" || !isScanned(file) || allowedPrefix(file) {
|
||
continue
|
||
}
|
||
if _, err := os.Stat(file); err != nil {
|
||
continue // deleted but still indexed
|
||
}
|
||
scanned++
|
||
found, err := scan(file)
|
||
if err != nil {
|
||
return fmt.Errorf("scan %s: %w", file, err)
|
||
}
|
||
findings = append(findings, found...)
|
||
}
|
||
|
||
if len(findings) > 0 {
|
||
fmt.Fprintf(os.Stderr, "ERROR: unapproved non-English text found in %d line(s):\n", len(findings))
|
||
for _, f := range findings {
|
||
fmt.Fprintf(os.Stderr, " %s:%d: %q in %s\n", f.file, f.line, f.char, trim(f.text))
|
||
}
|
||
fmt.Fprintf(os.Stderr, `
|
||
Source files are English-only: comments, identifiers and strings alike.
|
||
Translated prose belongs in README.<locale>.md, pages/src/content/docs/<locale>/
|
||
or an i18n table.
|
||
|
||
If the non-English text is intentional — an encoding fixture, a
|
||
language-switcher label — append a marker comment, including the reason, to
|
||
that line:
|
||
|
||
{"multibyte truncation", 6, "..."}, // %s fixture exercises rune boundaries
|
||
|
||
For a whole tree that is inherently non-English, add a prefix to
|
||
allowedPrefixes in scripts/verify-english-only.go instead.
|
||
`, exemptMarker)
|
||
return errReported
|
||
}
|
||
|
||
fmt.Printf("No unapproved non-English text in %d scanned source files.\n", scanned)
|
||
return nil
|
||
}
|
||
|
||
func main() {
|
||
if err := run(); err != nil {
|
||
if !errors.Is(err, errReported) {
|
||
fmt.Fprintln(os.Stderr, "verify-english-only:", err)
|
||
}
|
||
os.Exit(1)
|
||
}
|
||
}
|