fix: a NUL byte was hiding two source files from grep, git and diff

TaskHost.tsx used a literal NUL as the delimiter in its drag-and-drop
payload, and tools/dict/build.mjs used one to join headword and part of
speech. Both work at runtime. Both also make the file *binary* to every
text tool: git shows "Bin 12259 bytes" instead of a diff, and grep prints
nothing at all for a match.

That is not hypothetical. Searching TaskHost.tsx for "<input" came back
empty three times while reviewing it, which is how its four exercise
inputs came to be reported as absent -- and why the accessibility defect
in them went unseen. Written as the escape \u0000 the value is identical
and the file stays text.

test/source-hygiene.test.ts fails on any control byte in a source file, so
this cannot come back quietly.

With the files readable again, the sweep the NUL had been hiding: eleven
form controls had no accessible name. The exercise blanks announced only
an ellipsis, and the part-of-speech select announced nothing. A
placeholder is not a label -- it disappears the moment you type. All
eleven now carry one, named after the thing they answer.

`npm run lint` gains --max-warnings 0. exhaustive-deps is configured as a
warning, so a hooks-dependency bug would have passed CI silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-08 21:06:39 +02:00
parent c93ea657f9
commit 0b04931699
7 changed files with 58 additions and 2 deletions

View File

@@ -152,6 +152,7 @@ function ConjugationTrainer() {
className="ko"
value={value}
placeholder="…"
aria-label={`Conjugate ${question.dict}${mode === "past" ? "past 반말" : "반말"}`}
onChange={(e) => {
composer.onExternalInput();
setValue(e.target.value);

View File

@@ -108,6 +108,7 @@ export function VocabTab() {
type="search"
value={query}
placeholder="Search 한글 or English…"
aria-label="Search your words in 한글 or English"
onChange={(e) => setQuery(e.target.value)}
/>
<button className="btn" onClick={() => setAdding((a) => !a)} aria-expanded={adding}>
@@ -142,16 +143,19 @@ export function VocabTab() {
className="ko"
value={draft.headword}
placeholder="한글"
aria-label="The word, in 한글"
required
onChange={(e) => setDraft({ ...draft, headword: e.target.value })}
/>
<input
value={draft.gloss}
placeholder="What it means"
aria-label="What it means"
required
onChange={(e) => setDraft({ ...draft, gloss: e.target.value })}
/>
<select
aria-label="Part of speech"
value={draft.pos}
onChange={(e) => setDraft({ ...draft, pos: e.target.value })}
>

Binary file not shown.

View File

@@ -146,6 +146,7 @@ export function WordRail({ words, revealed, onReveal }: WordRailProps) {
type="search"
value={query}
placeholder="Look a word up…"
aria-label="Look a word up"
onChange={(e) => setQuery(e.target.value)}
/>
</div>

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Hankan a Korean reading tutor for manhwa. Offline-first, web + Android from one codebase.",
"description": "Hankan \u2014 a Korean reading tutor for manhwa. Offline-first, web + Android from one codebase.",
"workspaces": [
"app",
"server"
@@ -19,7 +19,7 @@
"typecheck": "tsc -b --pretty",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"lint": "eslint . --max-warnings 0",
"dev": "npm run dev -w app",
"build": "npm run build -w app",
"preview": "npm run preview -w app",

View File

@@ -0,0 +1,50 @@
/* Source files must stay text.
TaskHost.tsx carried a literal NUL byte, used as a delimiter in a
drag-and-drop payload. It worked at runtime, and it made the file binary
to git, grep and diff -- every search for that component's markup came
back empty, including the one meant to find an accessibility defect in
it. Written as the escape \\u0000 it behaves identically and stays text. */
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const ROOTS = ["app/src", "server/src", "shared", "tools", "test", "types"];
const SKIP = new Set(["node_modules", "dist", "android", ".vite"]);
const TEXT = /\.(ts|tsx|mjs|js|css|json|md|html|yml|yaml|sql)$/;
function walk(dir: string, out: string[] = []): string[] {
for (const e of readdirSync(dir)) {
if (SKIP.has(e)) continue;
const full = join(dir, e);
if (statSync(full).isDirectory()) walk(full, out);
else if (TEXT.test(e)) out.push(full);
}
return out;
}
describe("source hygiene", () => {
const files = ROOTS.flatMap((r) => walk(r));
it("scans a meaningful number of files", () => {
expect(files.length).toBeGreaterThan(50);
});
it("has no control bytes that would make a file binary to git and grep", () => {
const offenders: string[] = [];
for (const f of files) {
const buf = readFileSync(f);
for (let i = 0; i < buf.length; i++) {
const b = buf[i]!;
// Everything below 0x20 except tab, LF and CR.
if (b < 0x20 && b !== 0x09 && b !== 0x0a && b !== 0x0d) {
const line = buf.subarray(0, i).toString("utf8").split("\n").length;
offenders.push(`${f}:${line} -- byte 0x${b.toString(16).padStart(2, "0")}`);
break;
}
}
}
expect(offenders).toEqual([]);
});
});

Binary file not shown.