mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
harden mobile localization analysis
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user