Files
tkmind_go/ui/goose2/scripts/check-i18n-strings.mjs
T
Jack Amadeo 482f1962c1 Move goose2 (#8516)
Signed-off-by: Jack Amadeo <jackamadeo@squareup.com>
Co-authored-by: block-open-source[bot] <201011344+block-open-source[bot]@users.noreply.github.com>
Co-authored-by: block-open-source[bot] <1159699+block-open-source[bot]@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: tulsi <tulsi@block.xyz>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: Alex Hancock <alexhancock@block.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Nahiyan Khan <nahiyan.khan@gmail.com>
Co-authored-by: Lifei Zhou <lifei@squareup.com>
2026-04-14 14:39:30 +00:00

255 lines
6.7 KiB
JavaScript

import { readFileSync, readdirSync } from "node:fs";
import { extname, join, relative } from "node:path";
import ts from "typescript";
const CHECKED_PATHS = [
"src/app/ui",
"src/features/agents",
"src/features/chat/ui",
"src/features/home",
"src/features/projects",
"src/features/settings",
"src/features/skills",
"src/features/sidebar",
"src/features/status",
"src/features/sessions",
"src/shared/ui/ai-elements/code-block.tsx",
"src/shared/ui/ai-elements/environment-variables.tsx",
"src/shared/ui/ai-elements/message.tsx",
"src/shared/ui/ai-elements/plan.tsx",
"src/shared/ui/ai-elements/snippet.tsx",
"src/shared/ui/ai-elements/stack-trace.tsx",
"src/shared/ui/ai-elements/terminal.tsx",
"src/shared/ui/ai-elements/context.tsx",
"src/shared/ui/ai-elements/commit.tsx",
];
const EXCLUDED_PATH_SEGMENTS = ["__tests__"];
const EXCLUDED_FILE_MARKERS = [".test.", ".spec."];
const CHECKED_EXTENSIONS = new Set([".ts", ".tsx"]);
const TEXT_ATTRIBUTE_NAMES = new Set([
"aria-label",
"title",
"placeholder",
"alt",
]);
const TEXT_EXCLUDED_TAGS = new Set(["code", "pre", "kbd"]);
const IGNORE_COMMENT = "i18n-check-ignore";
function walkPath(targetPath) {
const entries = [];
let statEntries;
try {
statEntries = readdirSync(targetPath, { withFileTypes: true });
} catch {
if (CHECKED_EXTENSIONS.has(extname(targetPath))) {
return [targetPath];
}
return [];
}
for (const entry of statEntries) {
const fullPath = join(targetPath, entry.name);
if (entry.isDirectory()) {
entries.push(...walkPath(fullPath));
continue;
}
if (CHECKED_EXTENSIONS.has(extname(entry.name))) {
entries.push(fullPath);
}
}
return entries;
}
function isExcluded(filePath) {
const rel = relative(".", filePath);
const normalizedRel = rel.replace(/\\/g, "/");
const pathSegments = normalizedRel.split("/");
return (
EXCLUDED_PATH_SEGMENTS.some((segment) => pathSegments.includes(segment)) ||
EXCLUDED_FILE_MARKERS.some((marker) => normalizedRel.includes(marker))
);
}
function collapseWhitespace(text) {
return text.replace(/\s+/g, " ").trim();
}
function isProbablyUserFacingText(text) {
if (!text) return false;
if (!/\p{L}/u.test(text)) return false;
if (/^https?:\/\//.test(text)) return false;
if (/^[./~@#][\w./-]+$/.test(text)) return false;
if (/^[A-Z0-9_:-]+$/.test(text)) return false;
if (/^[\w.-]+\.[A-Za-z]{2,}$/.test(text)) return false;
return true;
}
function getLineText(sourceText, sourceFile, lineIndex) {
if (lineIndex < 0) return "";
const lineStarts = sourceFile.getLineStarts();
if (lineIndex >= lineStarts.length) return "";
const lineStart = lineStarts[lineIndex];
const lineEnd =
lineIndex + 1 < lineStarts.length
? lineStarts[lineIndex + 1]
: sourceText.length;
return sourceText.slice(lineStart, lineEnd);
}
function hasIgnoreComment(sourceText, sourceFile, node) {
const start = node.getStart(sourceFile);
const { line } = sourceFile.getLineAndCharacterOfPosition(start);
return [line - 1, line].some((lineIndex) =>
getLineText(sourceText, sourceFile, lineIndex).includes(IGNORE_COMMENT),
);
}
function getParentTagName(node) {
if (ts.isJsxElement(node.parent)) {
return node.parent.openingElement.tagName.getText();
}
if (ts.isJsxSelfClosingElement(node.parent)) {
return node.parent.tagName.getText();
}
return null;
}
function normalizeJsxText(text) {
return collapseWhitespace(text);
}
function extractStringFromExpression(expression) {
if (!expression) return null;
if (ts.isStringLiteralLike(expression)) {
return expression.text;
}
if (ts.isNoSubstitutionTemplateLiteral(expression)) {
return expression.text;
}
if (ts.isTemplateExpression(expression)) {
let text = expression.head.text;
for (const span of expression.templateSpans) {
text += `{${span.expression.getText()}}${span.literal.text}`;
}
return text;
}
return null;
}
function formatLocation(sourceFile, position) {
const { line, character } =
sourceFile.getLineAndCharacterOfPosition(position);
return `${sourceFile.fileName}:${line + 1}:${character + 1}`;
}
function collectViolations(filePath) {
const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const violations = [];
function report(node, kind, text) {
const normalizedText = collapseWhitespace(text);
if (!isProbablyUserFacingText(normalizedText)) return;
if (hasIgnoreComment(sourceText, sourceFile, node)) return;
violations.push({
location: formatLocation(sourceFile, node.getStart(sourceFile)),
kind,
text: normalizedText,
});
}
function visit(node) {
if (ts.isJsxText(node)) {
const tagName = getParentTagName(node);
if (tagName && TEXT_EXCLUDED_TAGS.has(tagName)) {
return;
}
const text = normalizeJsxText(node.getText(sourceFile));
report(node, "jsx-text", text);
}
if (ts.isJsxAttribute(node)) {
const attributeName = node.name.text;
if (TEXT_ATTRIBUTE_NAMES.has(attributeName) && node.initializer) {
if (ts.isStringLiteral(node.initializer)) {
report(
node.initializer,
`prop:${attributeName}`,
node.initializer.text,
);
}
if (ts.isJsxExpression(node.initializer)) {
const text = extractStringFromExpression(node.initializer.expression);
if (text) {
report(node.initializer, `prop:${attributeName}`, text);
}
}
}
}
if (ts.isJsxExpression(node) && node.expression) {
if (ts.isJsxAttribute(node.parent)) {
return;
}
const text = extractStringFromExpression(node.expression);
if (text) {
report(node, "jsx-expression", text);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return violations;
}
const files = CHECKED_PATHS.flatMap(walkPath)
.filter((filePath) => !isExcluded(filePath))
.sort();
const violations = files.flatMap((filePath) => collectViolations(filePath));
if (violations.length > 0) {
console.error("i18n string check failed:");
for (const violation of violations) {
console.error(
` - ${violation.location} [${violation.kind}] ${JSON.stringify(violation.text)}`,
);
}
console.error("");
console.error(
`Wrap user-facing strings in translations or annotate a narrow exception with "${IGNORE_COMMENT}".`,
);
console.error(
"The current enforcement scope is intentionally limited to app areas already migrated to i18n.",
);
process.exit(1);
} else {
console.log("i18n string check passed.");
}