470 lines
15 KiB
TypeScript
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);
|
|
});
|
|
}
|