harden mobile localization analysis

This commit is contained in:
Brennan Benson
2026-08-01 11:23:31 -07:00
parent 5fc1f26eb3
commit 2e0cc20e8e
5 changed files with 503 additions and 65 deletions
@@ -451,6 +451,38 @@ export const label = <><Text>{box.tr('Member raw copy')}</Text><Text>{logical('L
])
})
it('reports non-null renders and raw calls after snapshot or member mutations', () => {
const source = `
import { t } from '@/i18n/mobile-i18n'
const raw = (value) => value
let source = raw
const snapshot = source
source = t
const box = { tr: t }
const alias = box
alias.tr = raw
const holder = { inner: { tr: t } }
holder.inner.tr = raw
const visible = 'Visible non-null copy'
function render() { return 'Helper non-null copy' }
export const label = <><Text>{snapshot('Raw snapshot copy')}</Text><Text>{box.tr('Raw alias mutation')}</Text><Text>{holder.inner.tr('Raw nested mutation')}</Text><Text>{visible!}</Text><Text>{'Direct non-null copy'!}</Text><Text>{render!()}</Text></>
`
const candidates = collectLocalizationCandidates(
'/repo/mobile/src/components/Example.tsx',
source,
'/repo'
)
expect(candidates.map((candidate) => candidate.text)).toEqual([
'Visible non-null copy',
'Helper non-null copy',
'Raw snapshot copy',
'Raw alias mutation',
'Raw nested mutation',
'Direct non-null copy'
])
})
it('finds user-visible subject fallbacks in returned rows', () => {
const source = `
export function toRow(item) {
@@ -13,7 +13,8 @@ function isDirectDisplayExpressionParent(parent, child) {
if (
ts.isParenthesizedExpression(parent) ||
ts.isAsExpression(parent) ||
ts.isSatisfiesExpression(parent)
ts.isSatisfiesExpression(parent) ||
ts.isNonNullExpression(parent)
) {
return parent.expression === child
}
@@ -44,6 +45,9 @@ export function isRenderedJsxExpression(node) {
if (
ts.isConditionalExpression(current) ||
ts.isParenthesizedExpression(current) ||
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current) ||
ts.isNonNullExpression(current) ||
ts.isTemplateExpression(current) ||
ts.isNoSubstitutionTemplateLiteral(current)
) {
@@ -175,7 +179,8 @@ function unwrapAliasExpression(node) {
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node)
ts.isSatisfiesExpression(node) ||
ts.isNonNullExpression(node)
) {
return unwrapAliasExpression(node.expression)
}
@@ -113,28 +113,50 @@ function expressionResolution(node, use, bindings, mode, seen = new Set()) {
) {
return { descriptors: new Set([stableDescriptor.descriptor]), unknown: false }
}
const potentialDescriptors = binding ? bindings.potentialDescriptors?.get(binding) : undefined
if (mode === 'all' && potentialDescriptors?.size) {
return { descriptors: new Set(potentialDescriptors), unknown: false }
}
if (!binding || seen.has(binding)) {
return emptyResolution(true)
}
if (mode === 'all') {
const cached = bindings.allResolutionCache?.get(binding)
if (cached) {
return cached
}
if (bindings.allResolutionInProgress?.has(binding)) {
return emptyResolution(true)
}
bindings.allResolutionInProgress ??= new Set()
bindings.allResolutionInProgress.add(binding)
}
const nextSeen = new Set(seen).add(binding)
const sources = bindings.valueFlow.valueSources(expression, use, mode === 'all')
if (sources.length === 0) {
bindings.allResolutionInProgress?.delete(binding)
return emptyResolution(true)
}
return mergeResolutions(
const resolution = mergeResolutions(
sources.map((source) =>
source.propertyName
? propertyResolution(
source.expression,
source.propertyName,
use,
source.node,
bindings,
mode,
nextSeen
)
: expressionResolution(source.expression, use, bindings, mode, nextSeen)
: expressionResolution(source.expression, source.node, bindings, mode, nextSeen)
)
)
if (mode === 'all') {
bindings.allResolutionInProgress.delete(binding)
bindings.allResolutionCache ??= new Map()
bindings.allResolutionCache.set(binding, resolution)
}
return resolution
}
const member = memberParts(expression)
@@ -196,11 +218,12 @@ function collectStableDescriptors(valueFlow, rootDescriptors, resolveBinding) {
[...rootDescriptors].map(([binding, descriptor]) => [binding, { descriptor, writes: [] }])
)
const dependents = new Map()
for (const [binding, writes] of valueFlow.bindingWrites()) {
if (writes.length !== 1 || writes[0].propertyName || writes[0].conditional) {
for (const [binding] of valueFlow.bindingWrites()) {
const write = valueFlow.stableBindingWrite(binding)
if (!write || write.propertyName) {
continue
}
const source = valueFlow.unwrapExpression(writes[0].expression)
const source = valueFlow.unwrapExpression(write.expression)
if (!ts.isIdentifier(source)) {
continue
}
@@ -211,7 +234,7 @@ function collectStableDescriptors(valueFlow, rootDescriptors, resolveBinding) {
const entries = dependents.get(sourceBinding) ?? []
entries.push({
binding,
write: writes[0],
write,
always: valueFlow.isImmutableBinding(binding)
})
dependents.set(sourceBinding, entries)
@@ -235,6 +258,42 @@ function collectStableDescriptors(valueFlow, rootDescriptors, resolveBinding) {
return descriptors
}
function collectPotentialDescriptors(valueFlow, rootDescriptors, resolveBinding) {
const descriptors = new Map(
[...rootDescriptors].map(([binding, descriptor]) => [binding, new Set([descriptor])])
)
const dependents = new Map()
for (const [binding, writes] of valueFlow.bindingWrites()) {
for (const write of writes) {
if (write.propertyName) {
continue
}
const source = valueFlow.unwrapExpression(write.expression)
const sourceBinding = ts.isIdentifier(source) ? resolveBinding(source) : undefined
if (!sourceBinding) {
continue
}
const entries = dependents.get(sourceBinding) ?? new Set()
entries.add(binding)
dependents.set(sourceBinding, entries)
}
}
const queue = [...descriptors.keys()]
for (let index = 0; index < queue.length; index += 1) {
const source = queue[index]
for (const target of dependents.get(source) ?? []) {
const targetDescriptors = descriptors.get(target) ?? new Set()
const previousSize = targetDescriptors.size
descriptors.get(source).forEach((descriptor) => targetDescriptors.add(descriptor))
descriptors.set(target, targetDescriptors)
if (targetDescriptors.size > previousSize) {
queue.push(target)
}
}
}
return descriptors
}
function bindingName(binding) {
return binding.name && ts.isIdentifier(binding.name) ? binding.name.text : undefined
}
@@ -339,6 +398,11 @@ export function collectMobileTranslationBindings(sourceFile) {
rootDescriptors,
bindings.resolveBinding
)
bindings.potentialDescriptors = collectPotentialDescriptors(
bindings.valueFlow,
rootDescriptors,
bindings.resolveBinding
)
collectPotentialBindingNames(bindings)
return bindings
}
@@ -84,6 +84,28 @@ export const labels = [replaced('Raw visible copy'), prefixed('title')]
expect(calls.map((call) => call.keys)).toEqual([['second.title']])
})
it('evaluates alias snapshots at their assignment sites', () => {
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
const raw = (value) => value
let source = raw
const snapshot = source
source = t
const box = { tr: raw }
const { tr: memberSnapshot } = box
box.tr = t
export const labels = [
snapshot('Raw binding snapshot'),
memberSnapshot('Raw member snapshot'),
source('example.binding'),
box.tr('example.member')
]
`
const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
expect(calls.map((call) => call.keys)).toEqual([['example.binding'], ['example.member']])
})
it('tracks member and logical assignments at each call', () => {
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
@@ -107,6 +129,65 @@ export const labels = [
expect(calls.map((call) => call.keys)).toEqual([['example.element'], ['example.logical']])
})
it('tracks aliased and nested property writes', () => {
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
const raw = (value) => value
const box = { tr: t }
const alias = box
alias['tr'] = raw
const holder = { inner: { tr: t } }
holder.inner.tr('example.nested')
holder.inner.tr = raw
const assigned = { inner: {} }
assigned.inner.tr = t
export const labels = [
box.tr('Raw aliased mutation'),
holder.inner.tr('Raw nested mutation'),
assigned.inner.tr('example.nestedAssigned')
]
`
const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
expect(calls.map((call) => call.keys)).toEqual([['example.nested'], ['example.nestedAssigned']])
})
it('applies statically determined logical assignments', () => {
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
const raw = (value) => value
let nullishKeep = t
nullishKeep ??= raw
let orKeep = t
orKeep ||= raw
let nullishAssign = undefined
nullishAssign ??= t
let andReplace = t
andReplace &&= raw
const propertyKeep = { tr: t }
propertyKeep.tr ??= raw
const propertyAssign = {}
propertyAssign.tr ??= t
export const labels = [
nullishKeep('example.nullishKeep'),
orKeep('example.orKeep'),
nullishAssign('example.nullishAssign'),
propertyKeep.tr('example.propertyKeep'),
propertyAssign.tr('example.propertyAssign'),
andReplace('Raw logical replacement')
]
`
const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
expect(calls.map((call) => call.keys)).toEqual([
['example.nullishKeep'],
['example.orKeep'],
['example.nullishAssign'],
['example.propertyKeep'],
['example.propertyAssign']
])
})
it('unwraps non-null translator expressions', () => {
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
@@ -130,6 +211,18 @@ export const labels = next.map((value) => value.label)
expect(calls).toEqual([])
})
it('keeps translator identity across self-referential member writes', () => {
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
const holder = { current: t }
holder.current = holder.current
export const label = holder.current('example.selfReference')
`
const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
expect(calls.map((call) => call.keys)).toEqual([['example.selfReference']])
})
it('resolves long mutable reverse alias chains without whole-file fixed-point rescans', () => {
const aliases = Array.from({ length: 1500 }, (_, index) =>
index === 1499 ? `let alias${index} = t` : `let alias${index} = alias${index + 1}`
@@ -137,6 +230,7 @@ export const labels = next.map((value) => value.label)
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
${aliases}
alias1499 ??= (value) => value
export const label = alias0('example.scaled')
`
const startedAt = performance.now()
@@ -145,4 +239,21 @@ export const label = alias0('example.scaled')
expect(calls.map((call) => call.keys)).toEqual([['example.scaled']])
expect(performance.now() - startedAt).toBeLessThan(1000)
})
it('collects long potential alias chains after a changing logical write', () => {
const aliases = Array.from({ length: 1500 }, (_, index) =>
index === 1499 ? `let alias${index} = t` : `let alias${index} = alias${index + 1}`
).join('\n')
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
${aliases}
alias1499 &&= (value) => value
export const label = alias0('Raw potential alias')
`
const startedAt = performance.now()
const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
expect(calls).toEqual([])
expect(performance.now() - startedAt).toBeLessThan(1000)
})
})
+282 -56
View File
@@ -7,6 +7,11 @@ const ASSIGNMENT_KINDS = new Set([
ts.SyntaxKind.BarBarEqualsToken,
ts.SyntaxKind.QuestionQuestionEqualsToken
])
const LOGICAL_ASSIGNMENT_KINDS = new Set([
ts.SyntaxKind.AmpersandAmpersandEqualsToken,
ts.SyntaxKind.BarBarEqualsToken,
ts.SyntaxKind.QuestionQuestionEqualsToken
])
function unwrapExpression(node) {
if (
@@ -30,6 +35,21 @@ function propertyNameText(name) {
return undefined
}
function memberParts(node) {
const expression = unwrapExpression(node)
if (ts.isPropertyAccessExpression(expression)) {
return { object: expression.expression, propertyName: expression.name.text }
}
if (
ts.isElementAccessExpression(expression) &&
expression.argumentExpression &&
ts.isStringLiteralLike(expression.argumentExpression)
) {
return { object: expression.expression, propertyName: expression.argumentExpression.text }
}
return undefined
}
function statementContainer(node) {
let current = node
while (current.parent) {
@@ -57,10 +77,7 @@ function isConstBinding(binding) {
)
}
function isDefiniteBefore(write, use) {
if (write.conditional) {
return false
}
function isOrderedBefore(write, use) {
const writeStatement = statementContainer(write.node)
const useStatement = statementContainer(use)
return Boolean(
@@ -71,6 +88,10 @@ function isDefiniteBefore(write, use) {
)
}
function isDefiniteBefore(write, use) {
return !write.conditional && isOrderedBefore(write, use)
}
function destructuredTargets(name) {
const target = unwrapExpression(name)
if (ts.isObjectBindingPattern(target)) {
@@ -101,77 +122,57 @@ function destructuredTargets(name) {
export function createMobileLocalizationValueFlow(sourceFile, bindings) {
const writes = new Map()
const propertyWrites = new Map()
const pendingPropertyWrites = []
function addWrite(identifier, expression, node, propertyName, conditional = false) {
function addWrite(identifier, expression, node, propertyName, operator) {
const binding = bindings.resolveBinding(identifier)
if (!binding) {
return
}
const entries = writes.get(binding) ?? []
entries.push({
conditional,
conditional: LOGICAL_ASSIGNMENT_KINDS.has(operator),
expression,
node,
operator,
position: node.getStart(sourceFile),
propertyName
})
writes.set(binding, entries)
}
function addTargetWrites(name, expression, node, conditional = false) {
function addTargetWrites(name, expression, node, operator) {
const target = unwrapExpression(name)
if (ts.isIdentifier(target)) {
addWrite(target, expression, node, undefined, conditional)
addWrite(target, expression, node, undefined, operator)
return
}
for (const entry of destructuredTargets(target)) {
addWrite(entry.identifier, expression, node, entry.propertyName, conditional)
addWrite(entry.identifier, expression, node, entry.propertyName, operator)
}
}
function memberWriteTarget(node) {
const target = unwrapExpression(node)
if (ts.isPropertyAccessExpression(target)) {
const object = unwrapExpression(target.expression)
return ts.isIdentifier(object)
? { identifier: object, propertyName: target.name.text }
: undefined
}
if (
ts.isElementAccessExpression(target) &&
target.argumentExpression &&
ts.isStringLiteralLike(target.argumentExpression)
) {
const object = unwrapExpression(target.expression)
return ts.isIdentifier(object)
? { identifier: object, propertyName: target.argumentExpression.text }
: undefined
}
return undefined
}
function addPropertyWrite(target, expression, node, conditional) {
const binding = bindings.resolveBinding(target.identifier)
if (!binding) {
return
}
const byName = propertyWrites.get(binding) ?? new Map()
const entries = byName.get(target.propertyName) ?? []
entries.push({ conditional, expression, node, position: node.getStart(sourceFile) })
byName.set(target.propertyName, entries)
propertyWrites.set(binding, byName)
const member = memberParts(node)
return member ? { ...member, target: unwrapExpression(node) } : undefined
}
function visit(node) {
if (ts.isVariableDeclaration(node) && node.initializer) {
addTargetWrites(node.name, node.initializer, node)
} else if (ts.isBinaryExpression(node) && ASSIGNMENT_KINDS.has(node.operatorToken.kind)) {
const conditional = node.operatorToken.kind !== ts.SyntaxKind.EqualsToken
const memberTarget = memberWriteTarget(node.left)
if (memberTarget) {
addPropertyWrite(memberTarget, node.right, node, conditional)
pendingPropertyWrites.push({
...memberTarget,
conditional: LOGICAL_ASSIGNMENT_KINDS.has(node.operatorToken.kind),
expression: node.right,
node,
operator: node.operatorToken.kind,
position: node.getStart(sourceFile)
})
} else {
addTargetWrites(node.left, node.right, node, conditional)
addTargetWrites(node.left, node.right, node, node.operatorToken.kind)
}
}
ts.forEachChild(node, visit)
@@ -181,10 +182,72 @@ export function createMobileLocalizationValueFlow(sourceFile, bindings) {
for (const entries of writes.values()) {
entries.sort((left, right) => left.position - right.position)
}
for (const byName of propertyWrites.values()) {
for (const entries of byName.values()) {
entries.sort((left, right) => left.position - right.position)
function staticValueState(node) {
const expression = unwrapExpression(node)
if (
expression.kind === ts.SyntaxKind.NullKeyword ||
ts.isVoidExpression(expression) ||
(ts.isIdentifier(expression) &&
expression.text === 'undefined' &&
!bindings.resolveBinding(expression))
) {
return { nullish: true, truthy: false }
}
if (expression.kind === ts.SyntaxKind.TrueKeyword) {
return { nullish: false, truthy: true }
}
if (expression.kind === ts.SyntaxKind.FalseKeyword) {
return { nullish: false, truthy: false }
}
if (ts.isStringLiteralLike(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
return { nullish: false, truthy: expression.text.length > 0 }
}
if (ts.isNumericLiteral(expression)) {
return { nullish: false, truthy: Number(expression.text) !== 0 }
}
if (ts.isBigIntLiteral(expression)) {
return { nullish: false, truthy: expression.text !== '0n' }
}
if (
ts.isArrowFunction(expression) ||
ts.isFunctionExpression(expression) ||
ts.isClassExpression(expression) ||
ts.isObjectLiteralExpression(expression) ||
ts.isArrayLiteralExpression(expression) ||
ts.isNewExpression(expression)
) {
return { nullish: false, truthy: true }
}
if (ts.isIdentifier(expression)) {
const binding = bindings.resolveBinding(expression)
if (binding && bindings.rootDescriptors?.has(binding)) {
return { nullish: false, truthy: true }
}
}
return { nullish: undefined, truthy: undefined }
}
function logicalWriteAction(operator, expressions, knownEmpty = false) {
const values = expressions.length > 0 ? expressions : knownEmpty ? [undefined] : []
const actions = new Set()
for (const expression of values) {
const state =
expression === undefined ? { nullish: true, truthy: false } : staticValueState(expression)
const applies =
operator === ts.SyntaxKind.QuestionQuestionEqualsToken
? state.nullish
: operator === ts.SyntaxKind.BarBarEqualsToken
? state.truthy === undefined
? undefined
: !state.truthy
: state.truthy
actions.add(applies === true ? 'assign' : applies === false ? 'keep' : 'unknown')
}
return actions.size === 1 ? [...actions][0] : 'unknown'
}
function isUninitializedBinding(binding) {
return ts.isVariableDeclaration(binding) && binding.initializer === undefined
}
function selectedWrites(identifier, use, all) {
@@ -197,15 +260,145 @@ export function createMobileLocalizationValueFlow(sourceFile, bindings) {
const available = entries.filter((entry) => entry.position < usePosition)
let selected = []
for (const entry of available) {
if (isDefiniteBefore(entry, use)) {
const action = entry.conditional
? logicalWriteAction(
entry.operator,
selected.flatMap((write) => (write.propertyName ? [] : [write.expression])),
selected.length === 0 && isUninitializedBinding(binding)
)
: 'assign'
if (!isOrderedBefore(entry, use)) {
if (action !== 'keep') {
selected.push(entry)
}
continue
}
if (!entry.conditional) {
selected = [entry]
} else {
continue
}
if (action === 'assign') {
selected = [entry]
} else if (action === 'unknown') {
selected.push(entry)
}
}
return selected
}
function locationKey(location) {
return `${location.binding.pos}:${location.path.join('\0')}`
}
function objectLocations(node, use = node, seen = new Set()) {
const expression = unwrapExpression(node)
if (ts.isIdentifier(expression)) {
const binding = bindings.resolveBinding(expression)
if (!binding) {
return []
}
const direct = [{ binding, path: [] }]
if (seen.has(binding)) {
return direct
}
const nextSeen = new Set(seen).add(binding)
const sources = selectedWrites(expression, use, false)
const resolved = sources.flatMap((source) => {
const locations = objectLocations(source.expression, source.node, nextSeen)
return source.propertyName
? locations.map((location) => ({
binding: location.binding,
path: [...location.path, source.propertyName]
}))
: locations
})
const locations = resolved.length > 0 ? resolved : direct
return [...new Map(locations.map((location) => [locationKey(location), location])).values()]
}
const member = memberParts(expression)
if (!member) {
return []
}
return objectLocations(member.object, use, seen).map((location) => ({
binding: location.binding,
path: [...location.path, member.propertyName]
}))
}
function indexedPropertyWrites(location) {
const byPath = propertyWrites.get(location.binding) ?? new Map()
propertyWrites.set(location.binding, byPath)
const key = location.path.join('\0')
const entries = byPath.get(key) ?? []
byPath.set(key, entries)
return entries
}
for (const entry of pendingPropertyWrites) {
const targetLocations = objectLocations(entry.object, entry.node).map((location) => ({
binding: location.binding,
path: [...location.path, entry.propertyName]
}))
const rightMember = memberParts(entry.expression)
const rightLocations = rightMember
? objectLocations(rightMember.object, entry.node).map((location) => ({
binding: location.binding,
path: [...location.path, rightMember.propertyName]
}))
: []
const rightKeys = new Set(rightLocations.map(locationKey))
const selfAssignment =
targetLocations.length > 0 &&
targetLocations.every((location) => rightKeys.has(locationKey(location)))
const indexed = { ...entry, selfAssignment }
for (const location of targetLocations) {
indexedPropertyWrites(location).push(indexed)
}
}
for (const byPath of propertyWrites.values()) {
for (const entries of byPath.values()) {
entries.sort((left, right) => left.position - right.position)
}
}
function propertyWriteEntries(node, propertyName, use, all) {
const usePosition = use.getStart(sourceFile)
const entries = objectLocations(node, use).flatMap((location) => {
const key = [...location.path, propertyName].join('\0')
return propertyWrites.get(location.binding)?.get(key) ?? []
})
return [
...new Set(
entries.filter((entry) => !entry.selfAssignment && (all || entry.position < usePosition))
)
].sort((left, right) => left.position - right.position)
}
function applyPropertyWrites(resolved, known, node, propertyName, use, all) {
for (const entry of propertyWriteEntries(node, propertyName, use, all)) {
if (all) {
resolved.push(entry.expression)
continue
}
const action = entry.conditional
? logicalWriteAction(entry.operator, resolved, known && resolved.length === 0)
: 'assign'
if (!isOrderedBefore(entry, use)) {
if (action !== 'keep') {
resolved.push(entry.expression)
}
continue
}
if (action === 'assign') {
resolved.splice(0, resolved.length, entry.expression)
known = true
} else if (action === 'unknown') {
resolved.push(entry.expression)
}
}
return known
}
function propertyValues(node, propertyName, use = node, seen = new Set(), all = false) {
const expression = unwrapExpression(node)
if (ts.isIdentifier(expression)) {
@@ -227,18 +420,29 @@ export function createMobileLocalizationValueFlow(sourceFile, bindings) {
resolved.push(...matches)
}
}
const entries = propertyWrites.get(binding)?.get(propertyName) ?? []
const usePosition = use.getStart(sourceFile)
for (const entry of all ? entries : entries.filter((value) => value.position < usePosition)) {
if (all) {
resolved.push(entry.expression)
} else if (isDefiniteBefore(entry, use)) {
resolved.splice(0, resolved.length, entry.expression)
known = true
known = applyPropertyWrites(resolved, known, expression, propertyName, use, all)
return known || (all && resolved.length > 0) ? resolved : undefined
}
const member = memberParts(expression)
if (member) {
const objectValues = propertyValues(
member.object,
member.propertyName,
use,
new Set(seen),
all
)
const resolved = []
let known = objectValues !== undefined
for (const value of objectValues ?? []) {
const matches = propertyValues(value, propertyName, use, new Set(seen), all)
if (matches === undefined) {
known = false
} else {
resolved.push(entry.expression)
resolved.push(...matches)
}
}
known = applyPropertyWrites(resolved, known, expression, propertyName, use, all)
return known || (all && resolved.length > 0) ? resolved : undefined
}
if (!ts.isObjectLiteralExpression(expression)) {
@@ -307,6 +511,27 @@ export function createMobileLocalizationValueFlow(sourceFile, bindings) {
return selectedWrites(identifier, use, all)
}
function stableBindingWrite(binding) {
let source
for (const entry of writes.get(binding) ?? []) {
if (!entry.conditional) {
if (source) {
return undefined
}
source = entry
continue
}
if (
!source ||
source.propertyName ||
logicalWriteAction(entry.operator, [source.expression]) !== 'keep'
) {
return undefined
}
}
return source
}
function bindingWrites() {
return writes.entries()
}
@@ -320,6 +545,7 @@ export function createMobileLocalizationValueFlow(sourceFile, bindings) {
bindingWrites,
isImmutableBinding: isConstBinding,
propertyValues,
stableBindingWrite,
unwrapExpression,
valueExpressions,
valueSources,