diff --git a/packages/eslint-plugin/src/rules/no-unsafe-assignment.ts b/packages/eslint-plugin/src/rules/no-unsafe-assignment.ts index 6b3e47e3a3b4..7b4112d6f469 100644 --- a/packages/eslint-plugin/src/rules/no-unsafe-assignment.ts +++ b/packages/eslint-plugin/src/rules/no-unsafe-assignment.ts @@ -302,6 +302,14 @@ export default createRule({ return false; } + if ( + result.deep && + comparisonType === ComparisonType.Basic && + senderNode.type === AST_NODE_TYPES.ObjectExpression + ) { + return false; + } + const { receiver, sender } = result; context.report({ node: reportingNode, diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-argument.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-argument.test.ts index 46fc5f32118d..172baefc87f0 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-argument.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-argument.test.ts @@ -45,6 +45,11 @@ const x = [1, 2] as const; foo(...x); `, ` +declare const fromLib: { foo: number }; +declare function fn(x: { foo: number }): string; +fn(fromLib); + `, + ` declare function foo(arg: any, arg2: number): void; const x = [1 as any, 2] as const; foo(...x); @@ -131,6 +136,18 @@ foo(1 as any); }, { code: ` +declare const fromLib: { foo: any }; +declare function fn(x: { foo: number }): string; +fn(fromLib); + `, + errors: [ + { + messageId: 'unsafeArgument', + }, + ], + }, + { + code: ` declare function foo(arg: number): void; foo(error); `, diff --git a/packages/scope-manager/tests/lib.test.ts b/packages/scope-manager/tests/lib.test.ts index 4b84b6dc11ad..3d91acad0c08 100644 --- a/packages/scope-manager/tests/lib.test.ts +++ b/packages/scope-manager/tests/lib.test.ts @@ -1,3 +1,5 @@ +import type { AnalyzeOptions } from '../src/analyze'; + import { ImplicitLibVariable } from '../src'; import { parseAndAnalyze } from './test-utils'; @@ -121,8 +123,7 @@ describe('implicit lib definitions', () => { it('should throw if passed an unrecognized lib name', () => { expect(() => { parseAndAnalyze('var f = (a: Symbol) => a;', { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - lib: ['invalid+lib' as any], + lib: ['invalid+lib'] as unknown as AnalyzeOptions['lib'], }); }).toThrowError('invalid+lib'); }); diff --git a/packages/type-utils/src/isUnsafeAssignment.ts b/packages/type-utils/src/isUnsafeAssignment.ts index c54f7ef5b7c6..97374999d297 100644 --- a/packages/type-utils/src/isUnsafeAssignment.ts +++ b/packages/type-utils/src/isUnsafeAssignment.ts @@ -1,10 +1,67 @@ import type { TSESTree } from '@typescript-eslint/utils'; -import type * as ts from 'typescript'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import * as tsutils from 'ts-api-utils'; +import * as ts from 'typescript'; import { isTypeAnyType, isTypeUnknownType } from './predicates'; +import { getTypeOfPropertyOfName } from './propertyTypes'; + +function typeContainsAny( + type: ts.Type, + checker: ts.TypeChecker, + seen = new Set(), +): boolean { + if (seen.has(type)) { + return false; + } + seen.add(type); + + if (isTypeAnyType(type)) { + return true; + } + + if (tsutils.isUnionOrIntersectionType(type)) { + return type.types.some(t => typeContainsAny(t, checker, seen)); + } + + if (tsutils.isTypeReference(type)) { + const args = type.typeArguments ?? []; + return args.some(arg => typeContainsAny(arg, checker, seen)); + } + + if (checker.isArrayType(type) || checker.isTupleType(type)) { + return checker + .getTypeArguments(type) + .some(arg => typeContainsAny(arg, checker, seen)); + } + + if (tsutils.isObjectType(type)) { + const stringIndex = checker.getIndexInfoOfType(type, ts.IndexKind.String); + if (stringIndex && typeContainsAny(stringIndex.type, checker, seen)) { + return true; + } + + const numberIndex = checker.getIndexInfoOfType(type, ts.IndexKind.Number); + if (numberIndex && typeContainsAny(numberIndex.type, checker, seen)) { + return true; + } + + for (const property of type.getProperties()) { + const propertyType = getTypeOfPropertyOfName( + checker, + type, + property.getName(), + property.getEscapedName(), + ); + if (propertyType && typeContainsAny(propertyType, checker, seen)) { + return true; + } + } + } + + return false; +} /** * Does a simple check to see if there is an any being assigned to a non-any type. @@ -21,7 +78,7 @@ export function isUnsafeAssignment( receiver: ts.Type, checker: ts.TypeChecker, senderNode: TSESTree.Node | null, -): false | { receiver: ts.Type; sender: ts.Type } { +): false | { receiver: ts.Type; sender: ts.Type; deep?: boolean } { return isUnsafeAssignmentWorker( type, receiver, @@ -37,7 +94,28 @@ function isUnsafeAssignmentWorker( checker: ts.TypeChecker, senderNode: TSESTree.Node | null, visited: Map>, -): false | { receiver: ts.Type; sender: ts.Type } { +): false | { receiver: ts.Type; sender: ts.Type; deep?: boolean } { + function isFromDefaultLibrary(t: ts.Type): boolean { + const declarations = t.getSymbol()?.getDeclarations(); + return ( + declarations?.some(decl => { + const fileName = decl.getSourceFile().fileName; + return ( + decl.getSourceFile().hasNoDefaultLib || + fileName.includes('typescript/lib') + ); + }) ?? false + ); + } + + if (type === receiver) { + return false; + } + + if (!typeContainsAny(type, checker)) { + return false; + } + if (isTypeAnyType(type)) { // Allow assignment of any ==> unknown. if (isTypeUnknownType(receiver)) { @@ -45,7 +123,7 @@ function isUnsafeAssignmentWorker( } if (!isTypeAnyType(receiver)) { - return { receiver, sender: type }; + return { deep: true, receiver, sender: type }; } } @@ -60,6 +138,46 @@ function isUnsafeAssignmentWorker( visited.set(type, new Set([receiver])); } + if (checker.isTupleType(type) && checker.isTupleType(receiver)) { + const senderElements = checker.getTypeArguments(type); + const receiverElements = checker.getTypeArguments(receiver); + const length = Math.min(senderElements.length, receiverElements.length); + + for (let i = 0; i < length; i += 1) { + const unsafe = isUnsafeAssignmentWorker( + senderElements[i], + receiverElements[i], + checker, + senderNode, + visited, + ); + if (unsafe) { + return { deep: true, receiver, sender: type }; + } + } + + return false; + } + + if (checker.isArrayType(type) && checker.isArrayType(receiver)) { + const senderElementType = checker.getTypeArguments(type)[0]; + const receiverElementType = checker.getTypeArguments(receiver)[0]; + + const unsafe = isUnsafeAssignmentWorker( + senderElementType, + receiverElementType, + checker, + senderNode, + visited, + ); + + if (unsafe) { + return { deep: true, receiver, sender: type }; + } + + return false; + } + if (tsutils.isTypeReference(type) && tsutils.isTypeReference(receiver)) { // TODO - figure out how to handle cases like this, // where the types are assignable, but not the same type @@ -108,12 +226,130 @@ function isUnsafeAssignmentWorker( visited, ); if (unsafe) { - return { receiver, sender: type }; + return { deep: true, receiver, sender: type }; } } return false; } + if (tsutils.isUnionType(type)) { + for (const unionType of tsutils.unionConstituents(type)) { + const unsafe = isUnsafeAssignmentWorker( + unionType, + receiver, + checker, + senderNode, + visited, + ); + if (unsafe) { + return { deep: true, receiver, sender: type }; + } + } + return false; + } + + if (tsutils.isUnionType(receiver)) { + for (const receiverType of tsutils.unionConstituents(receiver)) { + const unsafe = isUnsafeAssignmentWorker( + type, + receiverType, + checker, + senderNode, + visited, + ); + if (unsafe) { + return { deep: true, receiver, sender: type }; + } + } + return false; + } + + if (tsutils.isObjectType(type) && tsutils.isObjectType(receiver)) { + if (isFromDefaultLibrary(receiver)) { + return false; + } + + const receiverStringIndex = checker.getIndexInfoOfType( + receiver, + ts.IndexKind.String, + ); + if (receiverStringIndex) { + const senderStringIndex = checker.getIndexInfoOfType( + type, + ts.IndexKind.String, + ); + if (senderStringIndex) { + const unsafe = isUnsafeAssignmentWorker( + senderStringIndex.type, + receiverStringIndex.type, + checker, + senderNode, + visited, + ); + if (unsafe) { + return { deep: true, receiver, sender: type }; + } + } + } + + const receiverNumberIndex = checker.getIndexInfoOfType( + receiver, + ts.IndexKind.Number, + ); + if (receiverNumberIndex) { + const senderNumberIndex = checker.getIndexInfoOfType( + type, + ts.IndexKind.Number, + ); + if (senderNumberIndex) { + const unsafe = isUnsafeAssignmentWorker( + senderNumberIndex.type, + receiverNumberIndex.type, + checker, + senderNode, + visited, + ); + if (unsafe) { + return { deep: true, receiver, sender: type }; + } + } + } + + for (const receiverProperty of receiver.getProperties()) { + const propertyName = receiverProperty.getName(); + const senderPropertyType = getTypeOfPropertyOfName( + checker, + type, + propertyName, + receiverProperty.getEscapedName(), + ); + if (!senderPropertyType) { + continue; + } + + const receiverPropertyType = getTypeOfPropertyOfName( + checker, + receiver, + propertyName, + receiverProperty.getEscapedName(), + ); + if (!receiverPropertyType) { + continue; + } + + const unsafe = isUnsafeAssignmentWorker( + senderPropertyType, + receiverPropertyType, + checker, + senderNode, + visited, + ); + if (unsafe) { + return { deep: true, receiver, sender: type }; + } + } + } + return false; } diff --git a/packages/type-utils/tests/isUnsafeAssignment.test.ts b/packages/type-utils/tests/isUnsafeAssignment.test.ts index 4919371ba08d..338911c4d788 100644 --- a/packages/type-utils/tests/isUnsafeAssignment.test.ts +++ b/packages/type-utils/tests/isUnsafeAssignment.test.ts @@ -7,7 +7,7 @@ describe(isUnsafeAssignment, () => { receiverStr: 'string', senderStr: 'any', }); - }); + }, 20_000); it('any in a generic position to a non-any', () => { expect('const test: Set = new Set();').toHaveTypes({ @@ -50,6 +50,118 @@ describe(isUnsafeAssignment, () => { senderStr: 'any', }); }); + + it('object property any to non-any (nested)', () => { + expect(` +type Sender = { foo: any }; +type Receiver = { foo: number }; +const test: Receiver = {} as Sender; + `).toHaveTypes({ + declarationIndex: 2, + receiverStr: 'Receiver', + senderStr: 'Sender', + }); + }); + + it('array element any to non-any element', () => { + expect('const test: number[] = [1] as any[];').toHaveTypes({ + receiverStr: 'number[]', + senderStr: 'any[]', + }); + }); + + it('tuple element any to non-any element', () => { + expect( + "const test: [string, number] = ['a', 1 as any] as [string, any];", + ).toHaveTypes({ + receiverStr: '[string, number]', + senderStr: '[string, any]', + }); + }); + + it('string index signature any to non-any', () => { + expect(` +type Sender = { [key: string]: any }; +type Receiver = { [key: string]: number }; +const test: Receiver = {} as Sender; + `).toHaveTypes({ + declarationIndex: 2, + receiverStr: 'Receiver', + senderStr: 'Sender', + }); + }); + + it('number index signature any to non-any', () => { + expect(` +type Sender = { [key: number]: any }; +type Receiver = { [key: number]: string }; +const test: Receiver = {} as Sender; + `).toHaveTypes({ + declarationIndex: 2, + receiverStr: 'Receiver', + senderStr: 'Sender', + }); + }); + + it('receiver union with any in sender', () => { + expect(` +type Sender = { kind: 'foo'; foo: any }; +type Receiver = { kind: 'foo'; foo: number } | { kind: 'bar'; bar: string }; +const test: Receiver = {} as Sender; + `).toHaveTypes({ + declarationIndex: 2, + receiverStr: 'Receiver', + senderStr: 'Sender', + }); + }); + + it('receiver union hit with non-union sender', () => { + expect(` +type Sender = { foo: any }; +type Receiver = { foo: number } | { bar: string }; +const test: Receiver = {} as Sender; + `).toHaveTypes({ + declarationIndex: 2, + receiverStr: 'Receiver', + senderStr: 'Sender', + }); + }); + + it('union containing any property to discriminated union without any', () => { + expect(` +type Sender = { kind: 'foo'; foo: any } | { kind: 'bar'; bar: string }; +type Receiver = { kind: 'foo'; foo: number } | { kind: 'bar'; bar: string }; +const test: Receiver = {} as Sender; + `).toHaveTypes({ + declarationIndex: 2, + receiverStr: 'Receiver', + senderStr: 'Sender', + }); + }); + + it('self-referential object with nested any', () => { + expect(` +type Sender = { self: Sender; value: any }; +type Receiver = { self: Receiver; value: number }; +const test: Receiver = {} as Sender; + `).toHaveTypes({ + declarationIndex: 2, + receiverStr: 'Receiver', + senderStr: 'Sender', + }); + }); + + it('tuple with recursive element containing any', () => { + expect(` +type Sender = [Sender, any]; +type Receiver = [Receiver, number]; +const test: Receiver = {} as Sender; + `).toHaveTypes({ + declarationIndex: 2, + receiverStr: 'Receiver', + senderStr: 'Sender', + }); + }); }); describe('safe', () => { @@ -119,5 +231,28 @@ describe(isUnsafeAssignment, () => { passSenderNode: true, }); }); + + it('object property with no any remains safe', () => { + expect(` +type Sender = { foo: number }; +type Receiver = { foo: number }; +const test: Receiver = {} as Sender; + `).toBeSafeAssignment({ declarationIndex: 2 }); + }); + + it('same type with any is treated as safe (type equality)', () => { + expect(` +type Both = { foo: any }; +const test: Both = {} as Both; + `).toBeSafeAssignment({ declarationIndex: 1 }); + }); + + it('intersection with any is currently treated as safe', () => { + expect(` +type Sender = { foo: any } & { bar: string }; +type Receiver = { foo: number; bar: string }; +const test: Receiver = {} as Sender; + `).toBeSafeAssignment({ declarationIndex: 2 }); + }); }); }); diff --git a/packages/typescript-eslint/tests/config-helper.test.ts b/packages/typescript-eslint/tests/config-helper.test.ts index 71a48383656f..e1f536a6ce51 100644 --- a/packages/typescript-eslint/tests/config-helper.test.ts +++ b/packages/typescript-eslint/tests/config-helper.test.ts @@ -74,8 +74,11 @@ describe('config helper', () => { rules: { rule: 'error' }, }, { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - extends: [undefined as any, extension, undefined as any], + extends: [ + undefined as unknown as TSESLint.FlatConfig.Config, + extension, + undefined as unknown as TSESLint.FlatConfig.Config, + ], files: ['common-file'], ignores: ['common-ignored'], name: 'my-config-2', @@ -101,8 +104,11 @@ describe('config helper', () => { rules: { rule: 'error' }, }, { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - extends: [undefined as any, extension, undefined as any], + extends: [ + undefined as unknown as TSESLint.FlatConfig.Config, + extension, + undefined as unknown as TSESLint.FlatConfig.Config, + ], files: ['common-file'], ignores: ['common-ignored'], rules: { rule: 'error' }, diff --git a/packages/typescript-estree/src/convert.ts b/packages/typescript-estree/src/convert.ts index 1f5778b94e2e..41a2d6997123 100644 --- a/packages/typescript-estree/src/convert.ts +++ b/packages/typescript-estree/src/convert.ts @@ -3490,7 +3490,7 @@ export class Converter { .filter(([key]) => !KEYS_TO_NOT_COPY.has(key)) .forEach(([key, value]) => { if (Array.isArray(value)) { - result[key] = this.convertChildren(value); + result[key] = this.convertChildren(value as ts.Node[]); } else if (value && typeof value === 'object' && value.kind) { // need to check node[key].kind to ensure we don't try to convert a symbol result[key] = this.convertChild(value as TSNode); diff --git a/packages/website/src/components/config/ConfigTypeScript.tsx b/packages/website/src/components/config/ConfigTypeScript.tsx index 16400a0aa2dd..97eff5a4afd4 100644 --- a/packages/website/src/components/config/ConfigTypeScript.tsx +++ b/packages/website/src/components/config/ConfigTypeScript.tsx @@ -47,8 +47,9 @@ function ConfigTypeScript(props: ConfigTypeScriptProps): React.JSX.Element { type: 'boolean', }); } else if (item.type instanceof Map) { + const enumValues = ['', ...[...item.type.keys()].map(String)]; group[category].fields.push({ - enum: ['', ...item.type.keys()], + enum: enumValues, key: item.name, label: item.description.message, type: 'string', diff --git a/packages/website/src/components/lib/jsonSchema.ts b/packages/website/src/components/lib/jsonSchema.ts index 697c349ec19e..4d8bb304ab94 100644 --- a/packages/website/src/components/lib/jsonSchema.ts +++ b/packages/website/src/components/lib/jsonSchema.ts @@ -185,18 +185,20 @@ export function getTypescriptJsonSchema(): JSONSchema4 { type: 'boolean', }; } else if (item.type === 'list' && item.element?.type instanceof Map) { + const enumValues = [...item.element.type.keys()].map(String); value = { description: item.description.message, items: { - enum: [...item.element.type.keys()], + enum: enumValues, type: 'string', }, type: 'array', }; } else if (item.type instanceof Map) { + const enumValues = [...item.type.keys()].map(String); value = { description: item.description.message, - enum: [...item.type.keys()], + enum: enumValues, type: 'string', }; }