Files
2026-08-06 13:59:36 +02:00

351 lines
12 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
const SCRIPT = path.resolve(import.meta.dirname, "i18n-sweep.mjs");
type Fixture = Record<string, string>;
interface SweepResult {
status: number | null;
stdout: string;
stderr: string;
/** Findings under "English plural concatenation" — defects. */
plurals: string[];
/** Findings under "literal(s) to review by eye" — judgement calls. */
eyeReview: string[];
}
/** Split the report on its two headers so a test can assert which half a finding landed in. */
function parseSections(stdout: string): Pick<SweepResult, "plurals" | "eyeReview"> {
const lines = stdout.split("\n");
const eyeHeader = lines.findIndex((l) => l.includes("literal(s) to review by eye"));
const isFinding = (l: string) => /^\s+\S+:\d+:/.test(l);
return {
plurals: lines.slice(0, eyeHeader).filter(isFinding),
eyeReview: lines.slice(eyeHeader).filter(isFinding),
};
}
function run(...args: string[]): SweepResult {
const result = spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" });
return {
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
...parseSections(result.stdout),
};
}
/** Materialise a fixture tree and hand its root to `use`, cleaning up afterwards. */
function withFixture<T>(fixture: Fixture, use: (root: string) => T): T {
const root = mkdtempSync(path.join(tmpdir(), "kandev-i18n-sweep-"));
try {
for (const [file, source] of Object.entries(fixture)) {
const full = path.join(root, file);
mkdirSync(path.dirname(full), { recursive: true });
writeFileSync(full, source);
}
return use(root);
} finally {
rmSync(root, { recursive: true, force: true });
}
}
function sweep(fixture: Fixture): SweepResult {
return withFixture(fixture, (root) => run(root));
}
describe("English plural concatenation", () => {
it("reports a morpheme concatenated onto a count in JSX", () => {
const result = sweep({
"file-count.tsx": [
"export function FileCount({ n }: { n: number }) {",
' return <span>{n} file{n === 1 ? "" : "s"}</span>;',
"}",
].join("\n"),
});
expect(result.plurals).toHaveLength(1);
expect(result.plurals[0]).toContain("file-count.tsx:2");
});
it("reports both orderings of the conditional", () => {
const result = sweep({
"orderings.ts": [
'export const a = (n: number) => `${n} item${n !== 1 ? "s" : ""}`;',
'export const b = (n: number) => `${n} item${n === 1 ? "" : "s"}`;',
].join("\n"),
});
expect(result.plurals).toHaveLength(2);
});
it("reports a plural morpheme hoisted into a helper variable", () => {
const result = sweep({
"helper.ts": [
"export function summary(count: number) {",
' const plural = count === 1 ? "" : "s";',
" return `${count} match${plural}`;",
"}",
].join("\n"),
});
expect(result.plurals).toHaveLength(1);
expect(result.plurals[0]).toContain("helper.ts:2");
});
it("does not report a t() call using count with _one/_other keys", () => {
const result = sweep({
"translated.tsx": [
'import { useTranslation } from "react-i18next";',
"",
"export function FileCount({ n }: { n: number }) {",
" const { t } = useTranslation();",
' return <span>{t("common:fileCount", { count: n })}</span>;',
"}",
].join("\n"),
});
expect(result.plurals).toEqual([]);
});
it("does not report a ternary that selects whole words rather than a morpheme", () => {
const result = sweep({
"words.ts": ['export const verb = (n: number) => (n === 1 ? "is" : "are");'].join("\n"),
});
expect(result.plurals).toEqual([]);
});
});
/**
* The regression that matters. Filtering literals by length *during* the scan
* desynchronises quote pairing: `"xs"` is skipped, so its closing quote pairs
* with the next string's opening quote and the gap between them (`") return "`)
* is reported as copy. That produced 25 false positives on a directory with one
* real hit, which is exactly the noise that gets a check ignored.
*/
describe("quote pairing", () => {
it("does not turn the gap between two literals into a finding", () => {
const result = sweep({
"icon-size.ts": [
"export function iconSize(size: string) {",
' if (size === "xs") return "h-6 w-6 shrink-0";',
' return "h-8 w-8 shrink-0";',
"}",
].join("\n"),
});
expect(result.eyeReview).toEqual([]);
expect(result.stdout).not.toContain("return");
});
it("still finds prose that follows a short literal on the same line", () => {
const result = sweep({
"mixed.ts": [
"export function label(size: string) {",
' if (size === "xs") return "[layout] Compact mode selected";',
"}",
].join("\n"),
});
expect(result.eyeReview).toHaveLength(1);
expect(result.eyeReview[0]).toContain("Compact mode selected");
});
});
describe("eye-review filtering", () => {
it("skips Tailwind class strings, catalog keys and identifiers", () => {
const result = sweep({
"noise.tsx": [
'export const cls = "flex items-center gap-2";',
'export const key = "settings:deleteExecutor";',
'export const id = "task-archive-dialog";',
'export const url = "https://kandev.ai/docs";',
].join("\n"),
});
expect(result.eyeReview).toEqual([]);
});
it("skips test files, comments and imports", () => {
const result = sweep({
"thing.test.tsx": 'export const copy = "Archive this board";',
"commented.ts": [
'import { thing } from "./Archive this board";',
'// const copy = "Archive this board";',
' * "Archive this board"',
].join("\n"),
});
expect(result.eyeReview).toEqual([]);
});
it("finds copy in a plain .ts helper the jsx-only lint rule cannot see", () => {
const result = sweep({
"storage-hooks.ts": [
"export function warn(error: unknown) {",
' console.warn("[storage] Could not read the disk usage report:", error);',
"}",
].join("\n"),
});
expect(result.eyeReview).toHaveLength(1);
expect(result.eyeReview[0]).toContain("storage-hooks.ts:2");
});
/**
* Characterization, not endorsement. `NOISE_VAL`'s `[a-z0-9-]+` and
* `[A-Z][A-Z0-9_]*` branches each match a single leading character before
* `.*$` swallows the rest, so every value starting with a letter is dropped —
* a `const ROWS = [{ label: "Disk usage" }]` config table stays invisible.
* Pinned here so the limit is a known property of the tool rather than a
* surprise, and so a future widening has to change this test on purpose.
*/
it("does not surface prose beginning with a letter — a known limit of the filter", () => {
const result = sweep({
"rows.ts": [
"export const ROWS = [",
' { label: "Disk usage" },',
' { label: "Memory in use" },',
"];",
].join("\n"),
});
expect(result.eyeReview).toEqual([]);
});
});
/**
* The regression this suite exists for. The sweep used to walk directories only,
* so a file argument hit ENOTDIR inside the walk, was swallowed, and produced the
* boilerplate footer with no findings and exit 0 — a clean bill of health for
* input it never opened. Briefs had instructed exactly that form
* (`pnpm run i18n:sweep <file> <file> …`), so real batches were signed off on a
* scan of nothing.
*/
describe("path arguments", () => {
const DEFECT = 'export const s = (n: number) => `${n} file${n === 1 ? "" : "s"}`;';
const LOOSE = "loose.tsx";
const SUBDIR = "nested";
const TREE: Fixture = { [LOOSE]: DEFECT, [`${SUBDIR}/counter.ts`]: DEFECT };
it("reports the findings in a file named directly", () => {
const result = withFixture(TREE, (root) => run(path.join(root, LOOSE)));
expect(result.status).toBe(0);
expect(result.plurals).toHaveLength(1);
expect(result.plurals[0]).toContain(`${LOOSE}:1`);
});
it("reports the union of file and directory arguments", () => {
const result = withFixture(TREE, (root) =>
run(path.join(root, SUBDIR), path.join(root, LOOSE)),
);
expect(result.plurals).toHaveLength(2);
expect(result.plurals.join("\n")).toContain("counter.ts:1");
expect(result.plurals.join("\n")).toContain(`${LOOSE}:1`);
});
it("counts a file reached through two arguments once", () => {
const result = withFixture(TREE, (root) => run(root, path.join(root, LOOSE)));
expect(result.plurals).toHaveLength(2);
});
it("exits non-zero on a path that does not exist rather than reporting clean", () => {
const result = run("does/not/exist");
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("does/not/exist");
expect(result.stdout).not.toContain("0 English plural concatenation(s)");
});
/**
* `0 findings` alongside `scanned 0 files` is self-evidently a non-result;
* `0 findings` alone is indistinguishable from a pass. Printing the count is
* the cheap structural guard against the whole failure class.
*/
it("prints how many files were scanned, and from how many arguments", () => {
const result = withFixture(TREE, (root) =>
run(path.join(root, SUBDIR), path.join(root, LOOSE)),
);
expect(result.stdout).toContain("scanned 2 file(s) from 2 argument(s)");
});
});
describe("report shape", () => {
/** A file with nothing to find, so the report is only its own boilerplate. */
const CLEAN: Fixture = { "clean.ts": "export const n = 1;" };
it("always prints both sections and exits 0, even with nothing to report", () => {
const result = sweep(CLEAN);
expect(result.status).toBe(0);
expect(result.stdout).toContain("0 English plural concatenation(s)");
expect(result.stdout).toContain("0 literal(s) to review by eye");
});
it("exits 0 even when defects are found — it is a human tool, not a gate", () => {
const result = sweep({
"defect.ts": 'export const s = (n: number) => `${n} file${n === 1 ? "" : "s"}`;',
});
expect(result.status).toBe(0);
});
it("notes the prompt-builder exclusion in the eye-review section", () => {
const result = sweep(CLEAN);
expect(result.stdout).toContain("agent-facing");
});
/**
* The known-good prompt builders in `components/github` surface under *plural
* concatenation*, not eye-review — an agent-facing string carrying `{n}
* check{s}` is not a defect, because the text goes verbatim to a model. A
* caveat printed only by the eye-review section would leave the defect list
* telling a maintainer to break working prompts.
*/
it("repeats the prompt-builder exclusion in the plural section when it has findings", () => {
const result = sweep({
"prompt-builder.ts": [
"// Agent-facing: sent verbatim to the model, deliberately not translated.",
"export const heading = (failed: string[]) =>",
' `### ${failed.length} CI Check${failed.length !== 1 ? "s" : ""} Failed`;',
].join("\n"),
});
const pluralSection = result.stdout.slice(0, result.stdout.indexOf("to review by eye"));
expect(result.plurals).toHaveLength(1);
expect(pluralSection).toContain("agent-facing");
expect(pluralSection).toContain("pr-checks-section.tsx");
});
it("keeps the plural section bare when it has no findings", () => {
const result = sweep(CLEAN);
const pluralSection = result.stdout.slice(0, result.stdout.indexOf("to review by eye"));
expect(pluralSection).not.toContain("agent-facing");
});
it("exits non-zero when given no path", () => {
const result = run();
expect(result.status).toBe(2);
expect(result.stderr).toContain("Usage:");
});
it("notes that copy from a plain helper is invisible to it", () => {
const result = sweep(CLEAN);
expect(result.stdout).toContain("plain helper");
});
});