From dafcba65c2d47650ad534ad4a60a8677745e97ae Mon Sep 17 00:00:00 2001 From: Ken Jo Date: Mon, 24 Aug 2026 08:30:39 +0900 Subject: [PATCH] @ ci: fail the build on CP949-damaged source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lvis-plugin-ep shipped 245 mangled lines for three months. A commit about deleting old pages was written by a tool that read the repo's UTF-8 Korean through a CP949 decoder and wrote the result back; nothing noticed, because nothing in CI reads Korean. Two of those lines were not comments — a room alias table whose keys no user input could match, and a remote-work check comparing against a literal that no longer existed. Restoring the text was one repo's problem. Detecting it is every repo's, so the check belongs here rather than in a test file each plugin has to grow its own copy of. The damage has a fingerprint and the gate looks for exactly that: a UTF-8 byte pair CP949 can map lands in the CJK ideograph block, one it cannot becomes a literal '?' welded to the surviving Hangul, and the orphaned third byte of a syllable passes through as a raw C1 control character. All three are things a repo that writes Korean and English never produces on purpose. U+FFFD is deliberately not a signal. This transform never emits it, and the code that defends against a different decoder's output — ep's and meeting's `includes("�")` guards — names the character legitimately. Including it would have failed two clean repos on their own correctness checks. Deliberate mojibake does exist: lvis-plugin-git detects broken drafts by matching what CP949 does to its headings. Those lines carry a `[mojibake-sample]` tag, which is greppable, so the exemptions stay visible in the file that needs them instead of accumulating in a line-number list here that would rot on the next edit. Verified against all seven plugin repos at main: clean, 4-55 files each. At ep's commit before the restoration, 248 lines. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W9K9WE1ATSqYFStZGtLpNk --- .github/workflows/plugin-ci.yml | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index f3a84ab..d9f4e61 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -40,6 +40,73 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ inputs.node-version }} + # Korean source read back by a CP949 decoder and written out again is + # destroyed, and nothing else in CI reads Korean. It happened once, in + # lvis-plugin-ep on 2026-05-26, inside a commit about deleting old pages: + # 245 lines were mangled and shipped for three months. Two were not + # comments — a room-alias table whose keys no user input could match, and + # a remote-work check comparing against a mangled literal. + # + # The damage has a fingerprint. A UTF-8 byte pair CP949 can map lands in + # the CJK ideograph block; one it cannot is written as '?'; and the + # orphaned third byte of a Hangul syllable passes through as a raw C1 + # control character. None of those belong in a repo that writes Korean, + # so a line carrying one fails the build that introduces it. U+FFFD is + # deliberately not one of the signals: this damage never produces it, and + # the code that guards against a *different* decoder's output legitimately + # names the character. + # + # Deliberate mojibake — a detector for it, a comment documenting it — + # tags its line `[mojibake-sample]`. That tag is greppable, so the + # exemptions stay visible instead of piling up in a list here. + - name: Encoding gate (no CP949 mojibake in source) + shell: bash + run: | + cat > "$RUNNER_TEMP/encoding-gate.mjs" <<'JS' + import { execSync } from "node:child_process"; + import { readFileSync } from "node:fs"; + + const EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json", ".md"]; + const CJK = /[⺀-⿿㐀-䶿一-鿿豈-﫿]/; + const LOST = /\?[가-힣]|[가-힣]\?[가-힣]/; + const EXEMPT = /\[mojibake-sample\]/; + const hasC1 = (line) => + [...line].some((ch) => ch.charCodeAt(0) >= 0x80 && ch.charCodeAt(0) <= 0x9f); + + const tracked = execSync("git ls-files", { encoding: "utf8" }).split("\n"); + const scanned = tracked.filter( + (file) => + (/^(src|skills)\//.test(file) || + file === "plugin.json" || + file === "package.json") && + EXTENSIONS.some((ext) => file.endsWith(ext)), + ); + + const damaged = []; + for (const file of scanned) { + readFileSync(file, "utf8") + .split("\n") + .forEach((line, index) => { + if (EXEMPT.test(line)) return; + if (CJK.test(line) || LOST.test(line) || hasC1(line)) { + damaged.push(`${file}:${index + 1}: ${line.trim().slice(0, 120)}`); + } + }); + } + + if (damaged.length > 0) { + for (const hit of damaged) console.log(`::error::${hit}`); + console.log( + `\n${damaged.length} line(s) carry the fingerprint of a CP949 round trip.\n` + + "If the text is meant to look that way, tag its line [mojibake-sample].\n" + + "Otherwise the file was written by a tool that misread its encoding;\n" + + "recover the text from git history rather than retyping it.", + ); + process.exit(1); + } + console.log(`encoding gate: ${scanned.length} file(s) clean`); + JS + node "$RUNNER_TEMP/encoding-gate.mjs" - name: Verify submodule integrity shell: bash run: |