diff --git a/.changeset/local-schema-error-reads.md b/.changeset/local-schema-error-reads.md new file mode 100644 index 00000000..890cc2ba --- /dev/null +++ b/.changeset/local-schema-error-reads.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +Detect removed Zod error members on local results of schemas imported through repository modules, including awaited safeParseAsync calls, so their `.errors` reads can migrate to `.issues`. diff --git a/packages/core/src/adapters/typescript/usages.test.ts b/packages/core/src/adapters/typescript/usages.test.ts index aff0dd83..fb6e9de1 100644 --- a/packages/core/src/adapters/typescript/usages.test.ts +++ b/packages/core/src/adapters/typescript/usages.test.ts @@ -358,6 +358,83 @@ describe('findUsages under Node16/NodeNext', () => { }); describe('findUsages on values the import does not visibly reach', () => { + it('resolves common members on local results of imported schemas without guessing by name', async () => { + const dir = tempRepo( + (d) => { + const pkg = join(d, 'node_modules/validation'); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(pkg, 'package.json'), + JSON.stringify({ name: 'validation', version: '1.0.0', types: 'index.d.ts' }), + ); + writeFileSync( + join(pkg, 'index.d.ts'), + `export interface ValidationError { errors: string[] } +export declare const schema: { + safeParse(value: unknown): { success: false; error: ValidationError }; + safeParseAsync(value: unknown): Promise<{ success: false; error: ValidationError }>; +}; +${Array.from({ length: 9 }, (_, i) => `export interface Other${i} { errors: string[] }`).join('\n')} +`, + ); + writeFileSync( + join(d, 'src/schema.ts'), + "import { schema } from 'validation';\nexport const documentSchema = schema;\n", + ); + writeFileSync(join(d, 'src/barrel.ts'), "export * from './schema';\n"); + writeFileSync( + join(d, 'src/reader.ts'), + `import { documentSchema } from '@/barrel'; +const result = await documentSchema.safeParseAsync({}); +const alias = result; +const error = alias.error; +result.error.errors; +alias.error?.errors; +error!.errors; +const sync = documentSchema.safeParse({}); +sync.error.errors; +const form = { errors: [] }; +form.errors; +const loose: any = result; +loose.errors; +function shadow(documentSchema: any) { + const result = documentSchema.safeParse({}); + return result.error.errors; +} +const a = b; +const b = a; +a.errors; +`, + ); + }, + { + ...BASE_TSCONFIG, + compilerOptions: { + ...BASE_TSCONFIG.compilerOptions, + baseUrl: '.', + paths: { '@/*': ['src/*'] }, + }, + }, + ); + const surface = await adapter.extractSurface({ + name: 'validation', + version: '1.0.0', + dir: join(dir, 'node_modules/validation'), + }); + expect(surface.symbols.filter((s) => s.path.endsWith('#errors')).length).toBeGreaterThan(8); + const found = (await adapter.findUsages({ dir }, 'validation', surface)).usages; + expect( + found + .filter((u) => u.file === 'src/reader.ts' && u.symbolPath.endsWith('#errors')) + .map((u) => `${u.line} ${u.symbolPath} ${u.via}`), + ).toEqual([ + '5 ValidationError#errors direct', + '6 ValidationError#errors direct', + '7 ValidationError#errors direct', + '9 ValidationError#errors direct', + ]); + }); + it('resolves a distinctive member on a value another module returned, and infers the one meaning on an any-typed receiver', async () => { const dir = tempRepo( (d) => { diff --git a/packages/core/src/adapters/typescript/usages.ts b/packages/core/src/adapters/typescript/usages.ts index 7214b46f..4823ad76 100644 --- a/packages/core/src/adapters/typescript/usages.ts +++ b/packages/core/src/adapters/typescript/usages.ts @@ -283,8 +283,12 @@ function isUntyped(expression: Node): boolean { } } -/** Whether a member access chain (`a.b().c`, `A.B`) starts at an import binding. */ -function rootedAtImport(access: Node, extraRoots: Set = new Set()): boolean { +/** Whether a member access chain starts at an import, following local initializers too. */ +function rootedAtImport( + access: Node, + extraRoots: Set = new Set(), + seen: Set = new Set(), +): boolean { let node: Node = access; for (;;) { if (Node.isPropertyAccessExpression(node) || Node.isElementAccessExpression(node)) { @@ -301,8 +305,14 @@ function rootedAtImport(access: Node, extraRoots: Set = new Set()): } if (!Node.isIdentifier(node)) return false; const symbol = node.getSymbol(); - if (!symbol) return false; - return (symbol.getFlags() & ts.SymbolFlags.Alias) !== 0 || extraRoots.has(symbol); + if (!symbol || seen.has(symbol)) return false; + if ((symbol.getFlags() & ts.SymbolFlags.Alias) !== 0 || extraRoots.has(symbol)) return true; + seen.add(symbol); + const declaration = symbol.getDeclarations()[0]; + const initializer = Node.isVariableDeclaration(declaration) + ? declaration.getInitializer() + : undefined; + return initializer !== undefined && rootedAtImport(initializer, extraRoots, seen); } /** The innermost receiver of `a.b().c`: an identifier, a call, or whatever starts the chain. */