#!/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(...)`, ``, * `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 [...] * * 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([ "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([ "label", "message", "tooltip", "placeholder", "errorMessage", "helperText", "validationMessage", ]); const TRANSLATION_CALL_NAMES = new Set(["t", "tDynamic"]); const TRANSLATION_MEMBER_NAMES = new Set(["t", "tDynamic"]); const TRANS_COMPONENT_NAMES = new Set(["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(["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 { const out: string[] = []; async function walk(dir: string): Promise { 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 ): Promise { 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, 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.') or use ." ); return lines.join("\n"); } async function main(): Promise { const argv = process.argv.slice(2); if (argv.length === 0) { process.stderr.write( "Usage: check-no-hardcoded-strings.ts [...]\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); }); }