Skip to content

Commit 691769f

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix: isolate selected async values and namespace members
1 parent 18ca63a commit 691769f

3 files changed

Lines changed: 82 additions & 5 deletions

File tree

scripts/design-diff/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ does not establish equivalence of arbitrary interactive behavior.
150150

151151
The resolver supports immutable constants, object properties, arrays, primitive template
152152
strings, simple arithmetic, conditional branches, static imports/re-exports, namespace
153-
imports, workspace exports and project `paths` aliases. It records CVA bases, variants,
153+
imports, workspace exports and project `paths` aliases. Static array selections and awaited
154+
`Promise.all` results trace the selected value independently; arbitrary promise failure and
155+
scheduling effects are not modeled. It records CVA bases, variants,
154156
defaults, compound variants and selections; runtime selections remain symbolic. Recognized
155157
`cn`/`clsx` helpers are interpreted as data. The trusted EMCN `cn` merge convention includes
156158
the repository's custom font-size groups. A helper with an unrecognized origin is not trusted

scripts/design-diff/resolve.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,13 @@ export class Resolver {
332332
}
333333
return initial
334334
}
335+
if (binding?.path.isImportNamespaceSpecifier()) {
336+
const declaration = binding.path.parentPath
337+
if (declaration.isImportDeclaration()) {
338+
const target = this.tree.resolve(file, declaration.node.source.value)
339+
if (target) return this.exported(target, key, depth + 1)
340+
}
341+
}
335342
if (binding?.path.isImportSpecifier() || binding?.path.isImportDefaultSpecifier()) {
336343
const declaration = binding.path.parentPath
337344
if (declaration.isImportDeclaration()) {
@@ -348,6 +355,31 @@ export class Resolver {
348355
}
349356
}
350357
}
358+
if (path.isAwaitExpression()) {
359+
const awaited = child(path, 'argument')
360+
if (awaited.isCallExpression()) {
361+
const callee = child(awaited, 'callee')
362+
if (
363+
callee.isMemberExpression() &&
364+
!callee.node.computed &&
365+
t.isIdentifier(callee.node.object, { name: 'Promise' }) &&
366+
!callee.scope.getBinding('Promise') &&
367+
t.isIdentifier(callee.node.property, { name: 'all' })
368+
) {
369+
const array = children(awaited, 'arguments')[0]
370+
if (array?.isArrayExpression() && !array.node.elements.some(t.isSpreadElement)) {
371+
const selected = this.selected(array, key, file, depth + 1, seen)
372+
if (selected !== undefined) return { $await: selected }
373+
}
374+
}
375+
}
376+
}
377+
if (path.isArrayExpression() && /^(?:0|[1-9]\d*)$/.test(key)) {
378+
const elements = children(path, 'elements')
379+
const index = Number(key)
380+
if (index < elements.length && !elements.slice(0, index + 1).some((p) => p.isSpreadElement()))
381+
return this.value(elements[index], file, depth + 1)
382+
}
351383
if (path.isObjectExpression()) {
352384
for (const prop of children(path, 'properties').reverse()) {
353385
if (prop.isObjectMethod() && !prop.node.computed && propertyName(prop.node.key) === key)
@@ -357,7 +389,8 @@ export class Resolver {
357389
if (prop.isSpreadElement()) {
358390
const selected = this.selected(child(prop, 'argument'), key, file, depth + 1, seen)
359391
if (selected !== undefined) return selected
360-
return undefined // An unknown later spread can override an earlier property.
392+
/** An unknown later spread can override an earlier property. */
393+
return undefined
361394
}
362395
}
363396
}
@@ -465,7 +498,10 @@ export class Resolver {
465498
if (!selection) return undefined
466499
const init = child(binding, 'init')
467500
const [first, ...rest] = selection.keys
468-
let value = typeof first === 'string' ? this.selected(init, first, file, depth + 1) : undefined
501+
let value =
502+
typeof first === 'string' || typeof first === 'number'
503+
? this.selected(init, String(first), file, depth + 1)
504+
: undefined
469505
const keys = value === undefined ? selection.keys : rest
470506
if (value === undefined) value = this.value(init, file, depth + 1)
471507
for (const key of keys) {
@@ -916,8 +952,8 @@ export class Resolver {
916952
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
917953
const key = node.computed ? read('property') : propertyName(node.property)
918954
const selected =
919-
typeof key === 'string'
920-
? this.selected(child(path, 'object'), key, file, depth + 1)
955+
typeof key === 'string' || typeof key === 'number'
956+
? this.selected(child(path, 'object'), String(key), file, depth + 1)
921957
: undefined
922958
if (selected !== undefined) return selected
923959
const base = read('object')

scripts/design-diff/tests/precision.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,42 @@ it('isolates unrelated mutable object fields such as telemetry warmup state', as
344344
)
345345
expect(report.flagged).toBe(false)
346346
})
347+
348+
it.each(['[live, unrelated]', 'await Promise.all([live, unrelated])'])(
349+
'projects an individual array result from %s',
350+
async (expression) => {
351+
const source = (colour: string, telemetry: number) =>
352+
`export const live='${colour}';export const unrelated=${telemetry}`
353+
const files = {
354+
[data]: source('red', 1),
355+
[view]: `import {live,unrelated} from './data';export async function Page(){const [colour]=${expression};return <span style={{color:colour}}/>}`,
356+
}
357+
expect((await compareFiles(files, { [data]: source('red', 2) }, settings)).flagged).toBe(false)
358+
expect((await compareFiles(files, { [data]: source('blue', 1) }, settings)).flagged).toBe(true)
359+
}
360+
)
361+
362+
it('keeps shadowed Promise.all conservative', async () => {
363+
const source = (n: number) => `export const unrelated=${n}`
364+
const report = await compareFiles(
365+
{
366+
[data]: source(1),
367+
[view]: `import {unrelated} from './data';export async function Page({Promise}){const [colour]=await Promise.all(['red',unrelated]);return <span style={{color:colour}}/>}`,
368+
},
369+
{ [data]: source(2) },
370+
settings
371+
)
372+
expect(report.flagged).toBe(true)
373+
})
374+
375+
it('projects namespace members even when an outer expression reaches its resolution limit', async () => {
376+
const source = (colour: string, unused: number) =>
377+
`export const colour='${colour}';export const unused=${unused}`
378+
const files = {
379+
[data]: source('red', 1),
380+
[view]: `import * as palette from './data';export const Page=()=> <span style={{color:unknown(unknown(unknown(palette.colour)))}}/>`,
381+
}
382+
const bounded = { ...settings, limits: { ...settings.limits, resolutionDepth: 2 } }
383+
expect((await compareFiles(files, { [data]: source('red', 2) }, bounded)).flagged).toBe(false)
384+
expect((await compareFiles(files, { [data]: source('blue', 1) }, bounded)).flagged).toBe(true)
385+
})

0 commit comments

Comments
 (0)