Files
stablyai--orca/config/scripts/audit-localization-coverage.mjs
T
2026-07-13 13:05:33 +08:00

620 lines
16 KiB
JavaScript

import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate'])
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
'ariaLabel',
'aria-label',
'aria-description',
'alt',
'description',
'emptyText',
'helperText',
'keywords',
'label',
'message',
'placeholder',
'subtitle',
'text',
'title',
'toggleDescription',
'tooltip'
])
const USER_VISIBLE_OBJECT_KEYS = new Set([
'ariaLabel',
'badge',
'description',
'emptyText',
'error',
'helperText',
'keywords',
'label',
'message',
'placeholder',
'subtitle',
'title',
'toggleDescription',
'tooltip'
])
const USER_VISIBLE_FUNCTION_NAMES = new Set([
'alert',
'confirm',
'prompt',
'showError',
'showToast'
])
const USER_VISIBLE_OBJECT_METHODS = new Set([
'error',
'info',
'loading',
'message',
'promise',
'success',
'warning'
])
const USER_VISIBLE_OBJECT_NAMES = new Set(['toast'])
function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
if (
relative.endsWith('.d.ts') ||
relative.includes('.test.') ||
relative.includes('.spec.') ||
relative.includes('/__tests__/')
) {
return true
}
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
}
async function collectSourceFiles(root, dir) {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (!SKIP_PATH_PARTS.has(entry.name)) {
files.push(...(await collectSourceFiles(root, fullPath)))
}
continue
}
if (
entry.isFile() &&
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
!isSkippedFile(root, fullPath)
) {
files.push(fullPath)
}
}
return files
}
function hasHumanLanguageText(text) {
const trimmed = text.replace(/\s+/g, ' ').trim()
if (trimmed.length < 2) {
return false
}
if (/^[\d\s!-/:-@[-`{-~]+$/.test(trimmed)) {
return false
}
return /[A-Za-z\u00C0-\u024F\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(trimmed)
}
function compactText(text) {
return text.replace(/\s+/g, ' ').trim()
}
function lineAndColumn(sourceFile, node) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
return { line: position.line + 1, column: position.character + 1 }
}
function propertyNameText(name) {
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
return name.text
}
if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) {
return name.expression.text
}
return undefined
}
function expressionNameText(node) {
if (ts.isIdentifier(node)) {
return node.text
}
if (ts.isPropertyAccessExpression(node)) {
return `${expressionNameText(node.expression) ?? ''}.${node.name.text}`.replace(/^\./, '')
}
return undefined
}
function stringParts(node) {
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return [{ text: node.text, dynamic: false }]
}
if (!ts.isTemplateExpression(node)) {
return []
}
return [
{ text: node.head.text, dynamic: true },
...node.templateSpans.map((span) => ({ text: span.literal.text, dynamic: true }))
]
}
function isInsideLocalizationCall(node) {
let current = node.parent
while (current) {
if (ts.isCallExpression(current)) {
const name = expressionNameText(current.expression)
if (name && LOCALIZATION_CALL_NAMES.has(name.split('.').at(-1) ?? name)) {
return true
}
}
current = current.parent
}
return false
}
function isJsxAttributeValue(node) {
const parent = node.parent
if (!parent) {
return undefined
}
if (ts.isJsxAttribute(parent)) {
return propertyNameText(parent.name)
}
if (parent && ts.isJsxExpression(parent) && parent.parent && ts.isJsxAttribute(parent.parent)) {
return propertyNameText(parent.parent.name)
}
return undefined
}
function ancestorJsxAttributeName(node) {
let current = node.parent
while (current) {
if (ts.isJsxAttribute(current)) {
return propertyNameText(current.name)
}
if (
ts.isJsxExpression(current) ||
ts.isConditionalExpression(current) ||
ts.isParenthesizedExpression(current) ||
ts.isBinaryExpression(current)
) {
current = current.parent
continue
}
return undefined
}
return undefined
}
function isRenderedJsxExpression(node) {
let current = node.parent
while (current) {
if (ts.isJsxExpression(current)) {
return (
ts.isJsxElement(current.parent) ||
ts.isJsxFragment(current.parent) ||
ts.isJsxSelfClosingElement(current.parent)
)
}
if (
ts.isConditionalExpression(current) ||
ts.isParenthesizedExpression(current) ||
ts.isTemplateExpression(current) ||
ts.isNoSubstitutionTemplateLiteral(current)
) {
if (ts.isConditionalExpression(current) && current.condition === node) {
return false
}
current = current.parent
continue
}
if (ts.isBinaryExpression(current)) {
if (current.operatorToken.kind !== ts.SyntaxKind.PlusToken) {
return false
}
current = current.parent
continue
}
return false
}
return false
}
function nearestObjectPropertyName(node) {
let current = node.parent
while (current) {
if (ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) {
return propertyNameText(current.name)
}
if (ts.isObjectLiteralExpression(current) || ts.isArrayLiteralExpression(current)) {
current = current.parent
continue
}
return undefined
}
return undefined
}
function hasAncestorObjectPropertyName(node, names) {
let current = node.parent
while (current) {
if (
(ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) &&
names.has(propertyNameText(current.name) ?? '')
) {
return true
}
current = current.parent
}
return false
}
function nearestAncestorObjectPropertyName(node) {
let current = node.parent
while (current) {
if (ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) {
return propertyNameText(current.name)
}
current = current.parent
}
return undefined
}
function findAncestor(node, predicate) {
let current = node.parent
while (current) {
if (predicate(current)) {
return current
}
current = current.parent
}
return undefined
}
function isUserVisibleCallArgument(node) {
const call = findAncestor(node, ts.isCallExpression)
if (!call) {
return false
}
const expressionName = expressionNameText(call.expression)
if (!expressionName) {
return false
}
const parts = expressionName.split('.')
const methodName = parts.at(-1)
const objectName = parts.at(-2)
return (
USER_VISIBLE_FUNCTION_NAMES.has(expressionName) ||
USER_VISIBLE_FUNCTION_NAMES.has(methodName ?? '') ||
(objectName !== undefined &&
USER_VISIBLE_OBJECT_NAMES.has(objectName) &&
USER_VISIBLE_OBJECT_METHODS.has(methodName ?? ''))
)
}
function classifyStringNode(node) {
if (hasAncestorObjectPropertyName(node, new Set(['className', 'classNames']))) {
return undefined
}
if (
findAncestor(
node,
(ancestor) =>
ts.isBinaryExpression(ancestor) && ancestor.operatorToken.kind !== ts.SyntaxKind.PlusToken
)
) {
return undefined
}
const jsxAttributeName = isJsxAttributeValue(node)
if (jsxAttributeName) {
return USER_VISIBLE_JSX_ATTRIBUTES.has(jsxAttributeName)
? `jsx-attribute:${jsxAttributeName}`
: undefined
}
const ancestorAttributeName = ancestorJsxAttributeName(node)
if (ancestorAttributeName) {
return USER_VISIBLE_JSX_ATTRIBUTES.has(ancestorAttributeName)
? `jsx-attribute:${ancestorAttributeName}`
: undefined
}
if (ts.isJsxText(node)) {
return 'jsx-text'
}
const objectPropertyName = nearestObjectPropertyName(node)
if (objectPropertyName && !USER_VISIBLE_OBJECT_KEYS.has(objectPropertyName)) {
return undefined
}
const ancestorObjectPropertyName = nearestAncestorObjectPropertyName(node)
if (ancestorObjectPropertyName && !USER_VISIBLE_OBJECT_KEYS.has(ancestorObjectPropertyName)) {
return undefined
}
if (isRenderedJsxExpression(node)) {
return 'jsx-expression'
}
if (isUserVisibleCallArgument(node)) {
return 'user-visible-call'
}
if (objectPropertyName) {
return `object-property:${objectPropertyName}`
}
return undefined
}
function areaForFile(relativePath) {
const rendererPrefix = 'src/renderer/src/'
if (!relativePath.startsWith(rendererPrefix)) {
return relativePath.split('/').slice(0, 2).join('/')
}
const withoutPrefix = relativePath.slice(rendererPrefix.length)
const parts = withoutPrefix.split('/')
if (parts[0] === 'components' && parts[1]) {
return `renderer/${parts[1]}`
}
return `renderer/${parts[0] ?? 'root'}`
}
export function collectLocalizationCandidates(filePath, sourceText, root = process.cwd()) {
const sourceKind =
filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
sourceKind
)
const reports = []
const relativePath = normalizePath(root, filePath)
function pushReport(node, kind, text, dynamic = false) {
const value = compactText(text)
if (!hasHumanLanguageText(value) || isInsideLocalizationCall(node)) {
return
}
const position = lineAndColumn(sourceFile, node)
reports.push({
area: areaForFile(relativePath),
filePath: relativePath,
start: node.getStart(sourceFile),
end: node.getEnd(),
line: position.line,
column: position.column,
kind,
text: value,
dynamic
})
}
function visit(node) {
if (ts.isJsxText(node)) {
pushReport(node, 'jsx-text', node.text)
return
}
const kind = classifyStringNode(node)
if (kind) {
for (const part of stringParts(node)) {
pushReport(node, kind, part.text, part.dynamic)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return reports
}
function groupByArea(reports) {
const groups = new Map()
for (const report of reports) {
const group = groups.get(report.area) ?? { area: report.area, count: 0, files: new Map() }
group.count += 1
group.files.set(report.filePath, (group.files.get(report.filePath) ?? 0) + 1)
groups.set(report.area, group)
}
return [...groups.values()].sort((left, right) => right.count - left.count)
}
function formatReports(_root, reports) {
return reports
.map(
(report) =>
`${report.filePath}:${report.line}:${report.column} ${report.kind}: ${JSON.stringify(report.text)}`
)
.join('\n')
}
function formatMarkdownReport(reports) {
const groups = groupByArea(reports)
const lines = [
'# Localization Candidate Inventory',
'',
`Generated: ${new Date().toISOString()}`,
'',
`Total candidates: ${reports.length}`,
'',
'## Area Summary',
''
]
for (const group of groups) {
lines.push(`- ${group.area}: ${group.count} candidates across ${group.files.size} files`)
}
lines.push('', '## Candidates', '')
for (const group of groups) {
lines.push(`### ${group.area}`, '')
for (const report of reports.filter((entry) => entry.area === group.area)) {
lines.push(
`- \`${report.filePath}:${report.line}:${report.column}\` ${report.kind}: ${JSON.stringify(report.text)}`
)
}
lines.push('')
}
return lines.join('\n')
}
function parseArgs(argv) {
const options = {
allowlistPath: path.join('config', 'localization-coverage-allowlist.json'),
check: false,
format: 'summary',
outputPath: null,
sourceRoot: path.join('src', 'renderer', 'src')
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--json') {
options.format = 'json'
} else if (arg === '--markdown') {
options.format = 'markdown'
} else if (arg === '--check') {
options.check = true
} else if (arg === '--allowlist') {
options.allowlistPath = argv[index + 1] ?? options.allowlistPath
index += 1
} else if (arg === '--output') {
options.outputPath = argv[index + 1] ?? null
index += 1
} else if (arg === '--source-root') {
options.sourceRoot = argv[index + 1] ?? options.sourceRoot
index += 1
}
}
return options
}
function candidateSignature(candidate) {
return JSON.stringify({
filePath: candidate.filePath,
kind: candidate.kind,
text: candidate.text,
dynamic: candidate.dynamic
})
}
function countBySignature(reports) {
const counts = new Map()
for (const report of reports) {
const signature = candidateSignature(report)
counts.set(signature, (counts.get(signature) ?? 0) + 1)
}
return counts
}
async function readAllowlist(root, allowlistPath) {
const absolutePath = path.resolve(root, allowlistPath)
const raw = await fs.readFile(absolutePath, 'utf8')
return JSON.parse(raw)
}
function findNewCandidates(reports, allowlist) {
const allowedCounts = new Map(
allowlist.map((entry) => [
JSON.stringify({
filePath: entry.filePath,
kind: entry.kind,
text: entry.text,
dynamic: entry.dynamic
}),
entry.count
])
)
const seenCounts = countBySignature(reports)
const newCandidates = []
for (const report of reports) {
const signature = candidateSignature(report)
const seenCount = seenCounts.get(signature) ?? 0
const allowedCount = allowedCounts.get(signature) ?? 0
if (seenCount > allowedCount) {
newCandidates.push(report)
seenCounts.set(signature, seenCount - 1)
}
}
return newCandidates
}
export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
const options = parseArgs(argv)
const absoluteSourceRoot = path.resolve(root, options.sourceRoot)
const files = await collectSourceFiles(root, absoluteSourceRoot)
const reports = []
for (const filePath of files) {
const sourceText = await fs.readFile(filePath, 'utf8')
reports.push(...collectLocalizationCandidates(filePath, sourceText, root))
}
if (options.check) {
const allowlist = await readAllowlist(root, options.allowlistPath)
const newCandidates = findNewCandidates(reports, allowlist)
if (newCandidates.length > 0) {
console.error('New unlocalized renderer strings were found.')
console.error('Localize them or add a reviewed exclusion to the localization allowlist.')
console.error('')
console.error(formatReports(root, newCandidates))
return 1
}
console.log(`Localization coverage check passed with ${reports.length} allowlisted candidates.`)
return 0
}
const output =
options.format === 'json'
? `${JSON.stringify(reports, null, 2)}\n`
: options.format === 'markdown'
? `${formatMarkdownReport(reports)}\n`
: `${reports.length} localization candidates in ${files.length} files.\n${groupByArea(
reports
)
.map((group) => `${group.area}: ${group.count}`)
.join('\n')}\n`
if (options.outputPath) {
await fs.mkdir(path.dirname(path.resolve(root, options.outputPath)), { recursive: true })
await fs.writeFile(path.resolve(root, options.outputPath), output)
} else {
process.stdout.write(output)
}
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}