Initial Commit
This commit is contained in:
469
scripts/lint/check-no-hardcoded-strings.ts
Normal file
469
scripts/lint/check-no-hardcoded-strings.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user