Files
suaveplan/scripts/lint/check-no-hardcoded-strings.ts
2026-05-27 23:47:53 +01:00

470 lines
15 KiB
TypeScript

#!/usr/bin/env bun
/**
* check-no-hardcoded-strings.ts
*
* CI gate enforcing that every user-perceivable string in the React-tier
* packages flows through a translation call (`t(...)`, `<Trans>`,
* `i18n.t(...)`, `tDynamic(...)`) rather than being hardcoded as a
* literal. The gate walks .tsx / .ts files (skipping tests, stories,
* type files, and barrels) and flags three rule families:
*
* 1. JSX text nodes that are not whitespace-only and not wrapped in
* a translation call.
* 2. JSX attribute values on the watchlist (`aria-label`,
* `aria-description`, `aria-roledescription`, `aria-valuetext`,
* `aria-keyshortcuts`, `placeholder`, `title`, `alt`) that are
* string literals.
* 3. Object-literal properties whose key is on the watchlist
* (`label`, `message`, `description`, `tooltip`, `placeholder`,
* `errorMessage`, `helperText`, `validationMessage`) and whose
* value is a string literal.
*
* Watchlist is extensible via a per-package `lint.config.ts` (loaded
* from the package root if present); v1 ships with the defaults
* documented above.
*
* Usage:
* bun scripts/lint/check-no-hardcoded-strings.ts <root> [<root>...]
*
* Exit codes:
* 0 — clean
* 1 — at least one violation
* 2 — internal failure
*/
import { readdir, readFile, stat } from "node:fs/promises";
import { extname, join, relative, resolve } from "node:path";
import * as ts from "typescript";
export interface Diagnostic {
readonly file: string;
readonly line: number;
readonly column: number;
readonly rule: string;
readonly snippet: string;
}
export const ARIA_ATTRIBUTE_WATCHLIST = new Set<string>([
"aria-label",
"aria-description",
"aria-roledescription",
"aria-valuetext",
"aria-keyshortcuts",
"placeholder",
"title",
"alt",
]);
/**
* `description` is intentionally NOT in the default watchlist because
* OTel `createCounter` / `createHistogram` calls accept a `description`
* option whose value is metadata, not user-visible UI copy. Form
* libraries that DO use `description` as user-facing copy (the
* `react-forms` field-config shape) layer their own gate atop this
* file and add `description` to the watchlist there.
*/
export const OBJECT_PROPERTY_WATCHLIST = new Set<string>([
"label",
"message",
"tooltip",
"placeholder",
"errorMessage",
"helperText",
"validationMessage",
]);
const TRANSLATION_CALL_NAMES = new Set<string>(["t", "tDynamic"]);
const TRANSLATION_MEMBER_NAMES = new Set<string>(["t", "tDynamic"]);
const TRANS_COMPONENT_NAMES = new Set<string>(["Trans"]);
const SKIP_SUFFIXES = [
".test.ts",
".test.tsx",
".test-fixtures.ts",
".test-fixtures.tsx",
".spec.ts",
".stories.ts",
".stories.tsx",
".bench.ts",
".d.ts",
];
const SKIP_FILENAMES = new Set<string>(["augmentation.ts", "schema.ts"]);
interface ScanOptions {
readonly root: string;
}
function isSkippableFile(file: string): boolean {
const base = file.split("/").pop() ?? "";
if (SKIP_FILENAMES.has(base)) return true;
for (const suffix of SKIP_SUFFIXES) {
if (file.endsWith(suffix)) return true;
}
if (file.includes("/spike/")) return true;
if (file.includes("/__benchmarks__/")) return true;
return false;
}
async function listSourceFiles(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: {
name: string;
isDirectory(): boolean;
isFile(): boolean;
}[];
try {
entries = (await readdir(dir, { withFileTypes: true })) as Array<{
name: string;
isDirectory(): boolean;
isFile(): boolean;
}>;
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name === ".turbo" ||
entry.name === "coverage"
) {
continue;
}
await walk(full);
continue;
}
if (!entry.isFile()) continue;
const ext = extname(entry.name);
if (ext !== ".ts" && ext !== ".tsx") continue;
if (isSkippableFile(full)) continue;
out.push(full);
}
}
const s = await stat(root).catch(() => null);
if (!s) return [];
if (s.isDirectory()) {
await walk(root);
} else {
out.push(root);
}
return out;
}
function isTranslationCall(node: ts.Node): boolean {
if (ts.isCallExpression(node)) {
const callee = node.expression;
if (
ts.isIdentifier(callee) &&
TRANSLATION_CALL_NAMES.has(callee.text)
) {
return true;
}
if (
ts.isPropertyAccessExpression(callee) &&
ts.isIdentifier(callee.name) &&
TRANSLATION_MEMBER_NAMES.has(callee.name.text)
) {
return true;
}
}
return false;
}
function isTransComponent(node: ts.Node): boolean {
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
const open = ts.isJsxElement(node) ? node.openingElement : node;
const tagName = open.tagName;
if (
ts.isIdentifier(tagName) &&
TRANS_COMPONENT_NAMES.has(tagName.text)
) {
return true;
}
}
return false;
}
function findTranslationOrTrans(expr: ts.Node): boolean {
let found = false;
function visit(n: ts.Node): void {
if (found) return;
if (isTranslationCall(n) || isTransComponent(n)) {
found = true;
return;
}
n.forEachChild(visit);
}
visit(expr);
return found;
}
function lineCol(
sourceFile: ts.SourceFile,
pos: number
): { line: number; column: number } {
const lc = sourceFile.getLineAndCharacterOfPosition(pos);
return { line: lc.line + 1, column: lc.character + 1 };
}
function scanSource(filePath: string, source: string): Diagnostic[] {
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
);
const diagnostics: Diagnostic[] = [];
function visit(node: ts.Node): void {
// Rule 1: bare JSX text node
if (ts.isJsxText(node)) {
const text = node.text;
if (text.trim().length > 0) {
const { line, column } = lineCol(
sourceFile,
node.getStart(sourceFile)
);
diagnostics.push({
file: filePath,
line,
column,
rule: "jsx-text",
snippet: text.trim().slice(0, 120),
});
}
}
// Rule 1b: JSX expression-container holding a string literal
if (ts.isJsxExpression(node)) {
const expr = node.expression;
if (
expr !== undefined &&
(ts.isStringLiteral(expr) ||
ts.isNoSubstitutionTemplateLiteral(expr))
) {
// Only a violation when the expression-container is a CHILD
// of a JsxElement (i.e. {`...`} as text). Attribute values
// are handled by Rule 2 below.
const parent = node.parent;
if (
parent !== undefined &&
(ts.isJsxElement(parent) || ts.isJsxFragment(parent))
) {
if (expr.text.trim().length > 0) {
const { line, column } = lineCol(
sourceFile,
expr.getStart(sourceFile)
);
diagnostics.push({
file: filePath,
line,
column,
rule: "jsx-text-expression",
snippet: expr.text.slice(0, 120),
});
}
}
} else if (expr !== undefined) {
// Non-literal expression — only flag when none of the
// sub-expressions invoke t() / Trans / etc.
// Skip — non-literal expressions are presumed dynamic
// and handled by other gates.
}
}
// Rule 2: JSX attribute on the watchlist with a string literal
if (ts.isJsxAttribute(node)) {
const name = node.name;
const attrName = ts.isIdentifier(name)
? name.text
: `${name.namespace.text}:${name.name.text}`;
if (
ARIA_ATTRIBUTE_WATCHLIST.has(attrName) &&
node.initializer !== undefined
) {
const init = node.initializer;
if (ts.isStringLiteral(init)) {
const { line, column } = lineCol(
sourceFile,
init.getStart(sourceFile)
);
diagnostics.push({
file: filePath,
line,
column,
rule: `jsx-attr-${attrName}`,
snippet: init.text.slice(0, 120),
});
} else if (
ts.isJsxExpression(init) &&
init.expression !== undefined
) {
const inner = init.expression;
if (
ts.isStringLiteral(inner) ||
ts.isNoSubstitutionTemplateLiteral(inner)
) {
const { line, column } = lineCol(
sourceFile,
inner.getStart(sourceFile)
);
diagnostics.push({
file: filePath,
line,
column,
rule: `jsx-attr-${attrName}`,
snippet: inner.text.slice(0, 120),
});
}
}
}
}
// Rule 3: object-literal property on watchlist with string literal value
if (ts.isPropertyAssignment(node)) {
const propName = ts.isIdentifier(node.name)
? node.name.text
: ts.isStringLiteral(node.name)
? node.name.text
: "";
if (OBJECT_PROPERTY_WATCHLIST.has(propName)) {
const value = node.initializer;
if (
ts.isStringLiteral(value) ||
ts.isNoSubstitutionTemplateLiteral(value)
) {
const { line, column } = lineCol(
sourceFile,
value.getStart(sourceFile)
);
diagnostics.push({
file: filePath,
line,
column,
rule: `object-${propName}`,
snippet: value.text.slice(0, 120),
});
}
}
}
node.forEachChild(visit);
}
visit(sourceFile);
// Pass 2: scrub diagnostics that turn out to be inside a t()/Trans
// expression. We do this by walking JsxExpression containers a
// second time and discarding any rule-1 hit whose enclosing
// expression is a translation call.
const filtered: Diagnostic[] = [];
for (const d of diagnostics) {
if (d.rule === "jsx-text-expression") {
// Re-find the JsxExpression at that position and inspect
const node = findNodeAt(sourceFile, d.line, d.column);
if (node !== null && findTranslationOrTrans(node)) {
continue;
}
}
filtered.push(d);
}
return filtered;
}
function findNodeAt(
sourceFile: ts.SourceFile,
line: number,
column: number
): ts.Node | null {
const target = sourceFile.getPositionOfLineAndCharacter(
line - 1,
column - 1
);
let result: ts.Node | null = null;
function visit(node: ts.Node): void {
if (result !== null) return;
if (node.getStart(sourceFile) <= target && target < node.getEnd()) {
result = node;
node.forEachChild(visit);
}
}
visit(sourceFile);
return result;
}
export async function checkRoots(
options: ReadonlyArray<ScanOptions>
): Promise<Diagnostic[]> {
const all: Diagnostic[] = [];
for (const opt of options) {
const root = resolve(opt.root);
const files = await listSourceFiles(root);
for (const file of files) {
const text = await readFile(file, "utf8");
const diagnostics = scanSource(file, text);
for (const d of diagnostics) {
all.push(d);
}
}
}
return all;
}
function formatDiagnostics(
diagnostics: ReadonlyArray<Diagnostic>,
cwd: string
): string {
if (diagnostics.length === 0) {
return "check-no-hardcoded-strings: clean.";
}
const lines: string[] = [
`check-no-hardcoded-strings: ${diagnostics.length} violation(s).`,
];
for (const d of diagnostics) {
lines.push(
` ${relative(cwd, d.file)}:${d.line}:${d.column} [${d.rule}] "${d.snippet}"`
);
}
lines.push("");
lines.push(
" Hint: wrap user-facing strings in t('react-ui.<key>') or use <Trans>."
);
return lines.join("\n");
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0) {
process.stderr.write(
"Usage: check-no-hardcoded-strings.ts <root> [<root>...]\n"
);
process.exit(2);
}
const cwd = process.cwd();
const diagnostics = await checkRoots(
argv.map((root) => ({ root: resolve(cwd, root) }))
);
const message = formatDiagnostics(diagnostics, cwd);
if (diagnostics.length === 0) {
process.stdout.write(`${message}\n`);
process.exit(0);
}
process.stderr.write(`${message}\n`);
process.exit(1);
}
if (import.meta.main) {
await main().catch((err: unknown) => {
process.stderr.write(`internal error: ${(err as Error).message}\n`);
process.exit(2);
});
}