From 2e0cc20e8e96f794f80daa6e5b1b00b783bf3408 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:23:31 -0700 Subject: [PATCH] harden mobile localization analysis --- ...bile-localization-candidate-rules.test.mjs | 32 ++ .../mobile-localization-rendered-variable.mjs | 9 +- ...bile-localization-translation-bindings.mjs | 78 +++- ...localization-translation-bindings.test.mjs | 111 ++++++ .../mobile-localization-value-flow.mjs | 338 +++++++++++++++--- 5 files changed, 503 insertions(+), 65 deletions(-) diff --git a/config/scripts/mobile-localization-candidate-rules.test.mjs b/config/scripts/mobile-localization-candidate-rules.test.mjs index a06760ec232..d81c800e242 100644 --- a/config/scripts/mobile-localization-candidate-rules.test.mjs +++ b/config/scripts/mobile-localization-candidate-rules.test.mjs @@ -451,6 +451,38 @@ export const label = <>{box.tr('Member raw copy')}{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 = <>{snapshot('Raw snapshot copy')}{box.tr('Raw alias mutation')}{holder.inner.tr('Raw nested mutation')}{visible!}{'Direct non-null copy'!}{render!()} +` + 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) { diff --git a/config/scripts/mobile-localization-rendered-variable.mjs b/config/scripts/mobile-localization-rendered-variable.mjs index 87e57ece8bb..f82d1cfe891 100644 --- a/config/scripts/mobile-localization-rendered-variable.mjs +++ b/config/scripts/mobile-localization-rendered-variable.mjs @@ -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) } diff --git a/config/scripts/mobile-localization-translation-bindings.mjs b/config/scripts/mobile-localization-translation-bindings.mjs index 0cd9f0305b8..da1f41d9539 100644 --- a/config/scripts/mobile-localization-translation-bindings.mjs +++ b/config/scripts/mobile-localization-translation-bindings.mjs @@ -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 } diff --git a/config/scripts/mobile-localization-translation-bindings.test.mjs b/config/scripts/mobile-localization-translation-bindings.test.mjs index 995288d6b12..93313535d77 100644 --- a/config/scripts/mobile-localization-translation-bindings.test.mjs +++ b/config/scripts/mobile-localization-translation-bindings.test.mjs @@ -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) + }) }) diff --git a/config/scripts/mobile-localization-value-flow.mjs b/config/scripts/mobile-localization-value-flow.mjs index a8fe9352a86..856248996db 100644 --- a/config/scripts/mobile-localization-value-flow.mjs +++ b/config/scripts/mobile-localization-value-flow.mjs @@ -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,