Files
orca/config/scripts/mobile-localization-candidate-rules.mjs
T

584 lines
16 KiB
JavaScript

// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
'ariaLabel',
'aria-label',
'aria-description',
'accessibilityHint',
'accessibilityLabel',
'alt',
'description',
'emptyText',
'helperText',
'keywords',
'label',
'message',
'placeholder',
'subtitle',
'text',
'title',
'toggleDescription',
'tooltip'
])
const USER_VISIBLE_OBJECT_KEYS = new Set([
'ariaLabel',
'accessibilityHint',
'accessibilityLabel',
'badge',
'description',
'emptyText',
'error',
'helperText',
'keywords',
'label',
'message',
'placeholder',
'subtitle',
'title',
'toggleDescription',
'tooltip'
])
const MOBILE_USER_VISIBLE_JSX_ATTRIBUTES = new Set(['fallback'])
const MOBILE_USER_VISIBLE_OBJECT_KEYS = new Set([
'desc',
'detail',
'hint',
'instructions',
'question'
])
const MOBILE_USER_VISIBLE_PROPERTY_SUFFIX_RE =
/(?:Caption|Description|Heading|Hint|Label|Message|Notice|Placeholder|Subtitle|Summary|Text|Title|Tooltip|Warning)$/
const USER_VISIBLE_COLLECTION_SUFFIX_RE =
/(?:Captions|Descriptions|Headings|Hints|Labels|Messages|Notices|Placeholders|Subtitles|Summaries|Text|Titles|Tooltips|Warnings)$|_(?:CAPTIONS|DESCRIPTIONS|HEADINGS|HINTS|LABELS|MESSAGES|NOTICES|PLACEHOLDERS|SUBTITLES|SUMMARIES|TEXT|TITLES|TOOLTIPS|WARNINGS)$/
const MOBILE_USER_VISIBLE_CALL_ARGUMENTS = new Map([
['assertRpcOk', new Set([1])],
['browserErrorMessage', new Set([1])],
['failureState', new Set([0])],
['onError', new Set([0])],
['onFailure', new Set([0])],
['onSendError', new Set([0])],
['onToast', new Set([0])],
['previewError', new Set([0])],
['reportEngineError', new Set([0])],
['reportNativeEngineError', new Set([0])],
['sendMobileHostedReviewGitMutation', new Set([3])]
])
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'])
const USER_VISIBLE_VARIABLE_NAMES = new Set([
'caption',
'description',
'detail',
'emptyLabel',
'error',
'explanation',
'heading',
'hint',
'label',
'message',
'notice',
'placeholder',
'statusText',
'subtitle',
'summary',
'text',
'title',
'warning'
])
const USER_VISIBLE_VARIABLE_SUFFIX_RE =
/(?:Caption|Description|EmptyLabel|Error|Explanation|Heading|Hint|Label|Message|Notice|Placeholder|StatusText|Subtitle|Summary|Title|Warning)$|_(?:CAPTION|DESCRIPTION|EMPTY_LABEL|ERROR|EXPLANATION|HEADING|HINT|LABEL|MESSAGE|NOTICE|PLACEHOLDER|STATUS_TEXT|SUBTITLE|SUMMARY|TITLE|WARNING)$/
const USER_VISIBLE_RETURN_FUNCTIONS = new Set([
'accessibilityLabelForLine',
'autoRestoreSummary',
'commentSourceLabel',
'composerLabel',
'describeScope',
'discussionSummary',
'formatAgo',
'formatBranchLabel',
'formatCommitTime',
'formatDurationSeconds',
'formatMobileHostedReviewCreateError',
'formatPrCommentRelativeTime',
'formatTimeAgo',
'formatUpdatedAt',
'getGitHubChecksLabel',
'getGitHubMergeLabel',
'getHostedMergeConfirmMessage',
'getHostedReviewMergeMethodLabel',
'getHostedStateConfirmLabel',
'getHostedStateConfirmMessage',
'getHostedStateConfirmTitle',
'getMobileSessionTabTitle',
'getProjectGitHubMergeConfirmMessage',
'getPrComposeDisabledReason',
'getWorktreeLabel',
'gitHubStatusLabel',
'gitLabStatusLabel',
'gitLabTodoTargetLabel',
'hostedReviewMergeTargetLabel',
'labelForEmpty',
'mobileAiVaultResumeTargetBlockMessage',
'mobileConflictAbortLabel',
'mobileConnectionPathLabel',
'mobileReviewScopeLabel',
'projectFieldValueLabel',
'projectRowStatusLabel',
'repositoryCount',
'saveErrorMessageFromPreviewResult',
'sendSheetMessage',
'setupSourceLabel',
'taskExternalOpenLabel',
'taskKindLabel',
'taskStatusActionLabel',
'workspaceAgentLabel',
'workspaceSshStatusLabel'
])
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 findAncestor(node, predicate) {
let current = node.parent
while (current) {
if (predicate(current)) {
return current
}
current = current.parent
}
return undefined
}
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)) {
const operator = current.operatorToken.kind
if (
![
ts.SyntaxKind.PlusToken,
ts.SyntaxKind.AmpersandAmpersandToken,
ts.SyntaxKind.BarBarToken,
ts.SyntaxKind.QuestionQuestionToken
].includes(operator) ||
(operator !== ts.SyntaxKind.PlusToken && current.left === node)
) {
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 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 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 isDirectDisplayExpressionParent(parent, child) {
if (
ts.isParenthesizedExpression(parent) ||
ts.isAsExpression(parent) ||
ts.isSatisfiesExpression(parent)
) {
return parent.expression === child
}
if (ts.isConditionalExpression(parent)) {
return parent.whenTrue === child || parent.whenFalse === child
}
if (ts.isBinaryExpression(parent)) {
return [
ts.SyntaxKind.PlusToken,
ts.SyntaxKind.BarBarToken,
ts.SyntaxKind.QuestionQuestionToken
].includes(parent.operatorToken.kind)
}
return ts.isTemplateSpan(parent) || ts.isTemplateExpression(parent)
}
function directDisplayVariableName(node) {
let current = node
while (current.parent && isDirectDisplayExpressionParent(current.parent, current)) {
current = current.parent
}
const declaration = current.parent
if (
declaration &&
(ts.isVariableDeclaration(declaration) ||
ts.isBindingElement(declaration) ||
ts.isParameter(declaration)) &&
declaration.initializer === current &&
ts.isIdentifier(declaration.name)
) {
return declaration.name.text
}
return undefined
}
function isUserVisibleVariableValue(node) {
const name = directDisplayVariableName(node)
return Boolean(
name && (USER_VISIBLE_VARIABLE_NAMES.has(name) || USER_VISIBLE_VARIABLE_SUFFIX_RE.test(name))
)
}
function directDisplayCollectionName(node) {
let current = node
while (current.parent) {
if (isDirectDisplayExpressionParent(current.parent, current)) {
current = current.parent
continue
}
if (
(ts.isPropertyAssignment(current.parent) && current.parent.initializer === current) ||
ts.isObjectLiteralExpression(current.parent) ||
ts.isArrayLiteralExpression(current.parent)
) {
current = current.parent
continue
}
break
}
const declaration = current.parent
return declaration &&
ts.isVariableDeclaration(declaration) &&
declaration.initializer === current &&
ts.isIdentifier(declaration.name)
? declaration.name.text
: undefined
}
function functionName(node) {
if (node.name && ts.isIdentifier(node.name)) {
return node.name.text
}
const parent = node.parent
return (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
parent &&
ts.isVariableDeclaration(parent) &&
ts.isIdentifier(parent.name)
? parent.name.text
: undefined
}
function directReturnFunctionName(node) {
let current = node
while (current.parent && isDirectDisplayExpressionParent(current.parent, current)) {
current = current.parent
}
const parent = current.parent
if (parent && ts.isReturnStatement(parent) && parent.expression === current) {
const owner = findAncestor(parent, (ancestor) => ts.isFunctionLike(ancestor))
return owner ? functionName(owner) : undefined
}
return parent && ts.isArrowFunction(parent) && parent.body === current
? functionName(parent)
: undefined
}
function isDisplayFormatterArgument(node) {
const call = findAncestor(node, ts.isCallExpression)
const name = call ? expressionNameText(call.expression)?.split('.').at(-1) : undefined
return Boolean(name && USER_VISIBLE_RETURN_FUNCTIONS.has(name))
}
function directCallArgumentIndex(call, node) {
let current = node
while (current.parent && current.parent !== call) {
if (!isDirectDisplayExpressionParent(current.parent, current)) {
return -1
}
current = current.parent
}
return call.arguments.indexOf(current)
}
function isUserVisibleCallArgument(node, userVisibleErrorSource) {
const call = findAncestor(node, ts.isCallExpression)
const expressionName = call ? expressionNameText(call.expression) : undefined
if (!call || !expressionName) {
return false
}
const parts = expressionName.split('.')
const methodName = parts.at(-1) ?? ''
const objectName = parts.at(-2)
const mobileArgumentIndexes = MOBILE_USER_VISIBLE_CALL_ARGUMENTS.get(methodName)
return (
(userVisibleErrorSource && expressionName === 'Error') ||
mobileArgumentIndexes?.has(directCallArgumentIndex(call, node)) ||
/^set[A-Za-z0-9]*(Error|Message|Notice|Warning)$/.test(methodName) ||
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 isUserVisibleErrorArgument(node, userVisibleErrorSource) {
if (!userVisibleErrorSource) {
return false
}
const expression = findAncestor(node, ts.isNewExpression)
return Boolean(
expression && ts.isIdentifier(expression.expression) && expression.expression.text === 'Error'
)
}
function hasDisqualifyingBinaryAncestor(node) {
let current = node.parent
while (current) {
if (ts.isFunctionLike(current)) {
return false
}
if (
ts.isBinaryExpression(current) &&
![
ts.SyntaxKind.PlusToken,
ts.SyntaxKind.AmpersandAmpersandToken,
ts.SyntaxKind.BarBarToken,
ts.SyntaxKind.QuestionQuestionToken
].includes(current.operatorToken.kind)
) {
return true
}
current = current.parent
}
return false
}
function isComparisonOperand(node) {
let current = node
while (
current.parent &&
(ts.isParenthesizedExpression(current.parent) ||
ts.isAsExpression(current.parent) ||
ts.isSatisfiesExpression(current.parent))
) {
current = current.parent
}
return Boolean(
current.parent &&
ts.isBinaryExpression(current.parent) &&
![
ts.SyntaxKind.PlusToken,
ts.SyntaxKind.AmpersandAmpersandToken,
ts.SyntaxKind.BarBarToken,
ts.SyntaxKind.QuestionQuestionToken
].includes(current.parent.operatorToken.kind)
)
}
export function classifyMobileStringNode(node, userVisibleErrorSource) {
if (
hasAncestorObjectPropertyName(node, new Set(['className', 'classNames'])) ||
isComparisonOperand(node)
) {
return undefined
}
const jsxAttributeName = isJsxAttributeValue(node)
if (jsxAttributeName) {
return USER_VISIBLE_JSX_ATTRIBUTES.has(jsxAttributeName) ||
MOBILE_USER_VISIBLE_JSX_ATTRIBUTES.has(jsxAttributeName) ||
MOBILE_USER_VISIBLE_PROPERTY_SUFFIX_RE.test(jsxAttributeName)
? `jsx-attribute:${jsxAttributeName}`
: undefined
}
const ancestorAttributeName = ancestorJsxAttributeName(node)
if (ancestorAttributeName) {
return USER_VISIBLE_JSX_ATTRIBUTES.has(ancestorAttributeName) ||
MOBILE_USER_VISIBLE_JSX_ATTRIBUTES.has(ancestorAttributeName) ||
MOBILE_USER_VISIBLE_PROPERTY_SUFFIX_RE.test(ancestorAttributeName)
? `jsx-attribute:${ancestorAttributeName}`
: undefined
}
if (ts.isJsxText(node)) {
return 'jsx-text'
}
const objectPropertyName = nearestObjectPropertyName(node)
const userVisibleObjectProperty =
objectPropertyName &&
(USER_VISIBLE_OBJECT_KEYS.has(objectPropertyName) ||
MOBILE_USER_VISIBLE_OBJECT_KEYS.has(objectPropertyName) ||
MOBILE_USER_VISIBLE_PROPERTY_SUFFIX_RE.test(objectPropertyName))
if (
isUserVisibleCallArgument(node, userVisibleErrorSource) &&
(!objectPropertyName || objectPropertyName === 'text' || userVisibleObjectProperty)
) {
return 'user-visible-call'
}
const collectionName = directDisplayCollectionName(node)
if (collectionName && USER_VISIBLE_COLLECTION_SUFFIX_RE.test(collectionName)) {
return 'user-visible-collection'
}
if (objectPropertyName && !userVisibleObjectProperty) {
return undefined
}
if (isDisplayFormatterArgument(node)) {
return undefined
}
const ancestorObjectPropertyName = nearestAncestorObjectPropertyName(node)
if (
ancestorObjectPropertyName &&
!USER_VISIBLE_OBJECT_KEYS.has(ancestorObjectPropertyName) &&
!MOBILE_USER_VISIBLE_OBJECT_KEYS.has(ancestorObjectPropertyName) &&
!MOBILE_USER_VISIBLE_PROPERTY_SUFFIX_RE.test(ancestorObjectPropertyName)
) {
return undefined
}
if (ancestorObjectPropertyName) {
return `object-property:${ancestorObjectPropertyName}`
}
if (hasDisqualifyingBinaryAncestor(node)) {
return undefined
}
if (isUserVisibleVariableValue(node)) {
return 'user-visible-variable'
}
const returnFunctionName = directReturnFunctionName(node)
if (
returnFunctionName &&
(USER_VISIBLE_RETURN_FUNCTIONS.has(returnFunctionName) ||
USER_VISIBLE_VARIABLE_SUFFIX_RE.test(returnFunctionName))
) {
return 'user-visible-return'
}
if (isUserVisibleErrorArgument(node, userVisibleErrorSource)) {
return 'user-visible-error'
}
if (isRenderedJsxExpression(node)) {
return 'jsx-expression'
}
if (isUserVisibleCallArgument(node, userVisibleErrorSource)) {
return 'user-visible-call'
}
return objectPropertyName ? `object-property:${objectPropertyName}` : undefined
}