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:
@@ -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);
|
||||
|
||||
@@ -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.
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
50
test/source-hygiene.test.ts
Normal file
50
test/source-hygiene.test.ts
Normal 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.
Reference in New Issue
Block a user