diff --git a/packages/eslint-plugin/docs/rules/prefer-readonly-parameter-types.md b/packages/eslint-plugin/docs/rules/prefer-readonly-parameter-types.md index b1e912abe37..703b16fa3f0 100644 --- a/packages/eslint-plugin/docs/rules/prefer-readonly-parameter-types.md +++ b/packages/eslint-plugin/docs/rules/prefer-readonly-parameter-types.md @@ -129,6 +129,101 @@ interface Foo { ## Options +### `allow` + +Some complex types cannot easily be made readonly, for example the `HTMLElement` type or the `JQueryStatic` type from `@types/jquery`. This option allows you to globally disable reporting of such types. + +Each item must be one of: + +- A type defined in a file (`{from: "file", name: "Foo", path: "src/foo-file.ts"}` with `path` being an optional path relative to the project root directory) +- A type from the default library (`{from: "lib", name: "Foo"}`) +- A type from a package (`{from: "package", name: "Foo", package: "foo-lib"}`, this also works for types defined in a typings package). + +Additionally, a type may be defined just as a simple string, which then matches the type independently of its origin. + +Examples of code for this rule with: + +```json +{ + "allow": [ + "$", + { "source": "file", "name": "Foo" }, + { "source": "lib", "name": "HTMLElement" }, + { "from": "package", "name": "Bar", "package": "bar-lib" } + ] +} +``` + + + +#### ❌ Incorrect + +```ts +interface ThisIsMutable { + prop: string; +} + +interface Wrapper { + sub: ThisIsMutable; +} + +interface WrapperWithOther { + readonly sub: Foo; + otherProp: string; +} + +function fn1(arg: ThisIsMutable) {} // Incorrect because ThisIsMutable is not readonly +function fn2(arg: Wrapper) {} // Incorrect because Wrapper.sub is not readonly +function fn3(arg: WrapperWithOther) {} // Incorrect because WrapperWithOther.otherProp is not readonly and not in the allowlist +``` + +```ts +import { Foo } from 'some-lib'; +import { Bar } from 'incorrect-lib'; + +interface HTMLElement { + prop: string; +} + +function fn1(arg: Foo) {} // Incorrect because Foo is not a local type +function fn2(arg: HTMLElement) {} // Incorrect because HTMLElement is not from the default library +function fn3(arg: Bar) {} // Incorrect because Bar is not from "bar-lib" +``` + +#### ✅ Correct + +```ts +interface Foo { + prop: string; +} + +interface Wrapper { + readonly sub: Foo; + readonly otherProp: string; +} + +function fn1(arg: Foo) {} // Works because Foo is allowed +function fn2(arg: Wrapper) {} // Works even when Foo is nested somewhere in the type, with other properties still being checked +``` + +```ts +import { Bar } from 'bar-lib'; + +interface Foo { + prop: string; +} + +function fn1(arg: Foo) {} // Works because Foo is a local type +function fn2(arg: HTMLElement) {} // Works because HTMLElement is from the default library +function fn3(arg: Bar) {} // Works because Bar is from "bar-lib" +``` + +```ts +import { Foo } from './foo'; + +function fn(arg: Foo) {} // Works because Foo is still a local type - it has to be in the same package +``` + ### `checkParameterProperties` This option allows you to enable or disable the checking of parameter properties. diff --git a/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts b/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts index af83179904d..e22ab9885e4 100644 --- a/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts +++ b/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts @@ -5,9 +5,11 @@ import * as util from '../util'; type Options = [ { + allow?: util.TypeOrValueSpecifier[]; checkParameterProperties?: boolean; ignoreInferredTypes?: boolean; - } & util.ReadonlynessOptions, + treatMethodsAsReadonly?: boolean; + }, ]; type MessageIds = 'shouldBeReadonly'; @@ -25,13 +27,15 @@ export default util.createRule({ type: 'object', additionalProperties: false, properties: { + allow: util.readonlynessOptionsSchema.properties.allow, checkParameterProperties: { type: 'boolean', }, ignoreInferredTypes: { type: 'boolean', }, - ...util.readonlynessOptionsSchema.properties, + treatMethodsAsReadonly: + util.readonlynessOptionsSchema.properties.treatMethodsAsReadonly, }, }, ], @@ -41,17 +45,25 @@ export default util.createRule({ }, defaultOptions: [ { + allow: util.readonlynessOptionsDefaults.allow, checkParameterProperties: true, ignoreInferredTypes: false, - ...util.readonlynessOptionsDefaults, + treatMethodsAsReadonly: + util.readonlynessOptionsDefaults.treatMethodsAsReadonly, }, ], create( context, - [{ checkParameterProperties, ignoreInferredTypes, treatMethodsAsReadonly }], + [ + { + allow, + checkParameterProperties, + ignoreInferredTypes, + treatMethodsAsReadonly, + }, + ], ) { const services = util.getParserServices(context); - const checker = services.program.getTypeChecker(); return { [[ @@ -94,8 +106,9 @@ export default util.createRule({ } const type = services.getTypeAtLocation(actualParam); - const isReadOnly = util.isTypeReadonly(checker, type, { + const isReadOnly = util.isTypeReadonly(services.program, type, { treatMethodsAsReadonly: treatMethodsAsReadonly!, + allow, }); if (!isReadOnly) { diff --git a/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts b/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts index 649b4970050..6d6a353c623 100644 --- a/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts @@ -401,6 +401,83 @@ ruleTester.run('prefer-readonly-parameter-types', rule, { }, ], }, + // Allowlist + { + code: ` + interface Foo { + readonly prop: RegExp; + } + + function foo(arg: Foo) {} + `, + options: [ + { + allow: [{ from: 'lib', name: 'RegExp' }], + }, + ], + }, + { + code: ` + interface Foo { + prop: RegExp; + } + + function foo(arg: Readonly) {} + `, + options: [ + { + allow: [{ from: 'lib', name: 'RegExp' }], + }, + ], + }, + { + code: ` + interface Foo { + prop: string; + } + + function foo(arg: Foo) {} + `, + options: [ + { + allow: [{ from: 'file', name: 'Foo' }], + }, + ], + }, + { + code: ` + interface Bar { + prop: string; + } + interface Foo { + readonly prop: Bar; + } + + function foo(arg: Foo) {} + `, + options: [ + { + allow: [{ from: 'file', name: 'Foo' }], + }, + ], + }, + { + code: ` + interface Bar { + prop: string; + } + interface Foo { + readonly prop: Bar; + } + + function foo(arg: Foo) {} + `, + options: [ + { + allow: [{ from: 'file', name: 'Bar' }], + }, + ], + }, ], invalid: [ // arrays @@ -885,5 +962,126 @@ ruleTester.run('prefer-readonly-parameter-types', rule, { `, errors: [{ line: 6, messageId: 'shouldBeReadonly' }], }, + // Allowlist + { + code: ` + function foo(arg: RegExp) {} + `, + options: [ + { + allow: [{ from: 'file', name: 'Foo' }], + }, + ], + errors: [ + { + messageId: 'shouldBeReadonly', + line: 2, + column: 22, + endColumn: 33, + }, + ], + }, + { + code: ` + interface Foo { + readonly prop: RegExp; + } + + function foo(arg: Foo) {} + `, + options: [ + { + allow: [{ from: 'file', name: 'Bar' }], + }, + ], + errors: [ + { + messageId: 'shouldBeReadonly', + line: 6, + column: 22, + endColumn: 30, + }, + ], + }, + { + code: ` + interface Foo { + readonly prop: RegExp; + } + + function foo(arg: Foo) {} + `, + options: [ + { + allow: [{ from: 'lib', name: 'Foo' }], + }, + ], + errors: [ + { + messageId: 'shouldBeReadonly', + line: 6, + column: 22, + endColumn: 30, + }, + ], + }, + { + code: ` + interface Foo { + readonly prop: RegExp; + } + + function foo(arg: Foo) {} + `, + options: [ + { + allow: [{ from: 'package', name: 'Foo', package: 'foo-lib' }], + }, + ], + errors: [ + { + messageId: 'shouldBeReadonly', + line: 6, + column: 22, + endColumn: 30, + }, + ], + }, + { + code: ` + function foo(arg: RegExp) {} + `, + options: [ + { + allow: [{ from: 'file', name: 'RegExp' }], + }, + ], + errors: [ + { + messageId: 'shouldBeReadonly', + line: 2, + column: 22, + endColumn: 33, + }, + ], + }, + { + code: ` + function foo(arg: RegExp) {} + `, + options: [ + { + allow: [{ from: 'package', name: 'RegExp', package: 'regexp-lib' }], + }, + ], + errors: [ + { + messageId: 'shouldBeReadonly', + line: 2, + column: 22, + endColumn: 33, + }, + ], + }, ], }); diff --git a/packages/type-utils/package.json b/packages/type-utils/package.json index 01e3a10867f..94397d3bf98 100644 --- a/packages/type-utils/package.json +++ b/packages/type-utils/package.json @@ -52,6 +52,7 @@ }, "devDependencies": { "@typescript-eslint/parser": "5.55.0", + "ajv": "^8.12.0", "typescript": "*" }, "peerDependencies": { diff --git a/packages/type-utils/src/TypeOrValueSpecifier.ts b/packages/type-utils/src/TypeOrValueSpecifier.ts new file mode 100644 index 00000000000..3ba6b02868d --- /dev/null +++ b/packages/type-utils/src/TypeOrValueSpecifier.ts @@ -0,0 +1,181 @@ +import path from 'path'; +import type * as ts from 'typescript'; + +interface FileSpecifier { + from: 'file'; + name: string | string[]; + path?: string; +} + +interface LibSpecifier { + from: 'lib'; + name: string | string[]; +} + +interface PackageSpecifier { + from: 'package'; + name: string | string[]; + package: string; +} + +export type TypeOrValueSpecifier = + | string + | FileSpecifier + | LibSpecifier + | PackageSpecifier; + +export const typeOrValueSpecifierSchema = { + oneOf: [ + { + type: 'string', + }, + { + type: 'object', + additionalProperties: false, + properties: { + from: { + type: 'string', + const: 'file', + }, + name: { + oneOf: [ + { + type: 'string', + }, + { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { + type: 'string', + }, + }, + ], + }, + path: { + type: 'string', + }, + }, + required: ['from', 'name'], + }, + { + type: 'object', + additionalProperties: false, + properties: { + from: { + type: 'string', + const: 'lib', + }, + name: { + oneOf: [ + { + type: 'string', + }, + { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { + type: 'string', + }, + }, + ], + }, + }, + required: ['from', 'name'], + }, + { + type: 'object', + additionalProperties: false, + properties: { + from: { + type: 'string', + const: 'package', + }, + name: { + oneOf: [ + { + type: 'string', + }, + { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { + type: 'string', + }, + }, + ], + }, + package: { + type: 'string', + }, + }, + required: ['from', 'name', 'package'], + }, + ], +}; + +function specifierNameMatches(type: ts.Type, name: string | string[]): boolean { + if (typeof name === 'string') { + name = [name]; + } + const symbol = type.getSymbol(); + if (symbol === undefined) { + return false; + } + return name.some(item => item === symbol.escapedName); +} + +function typeDeclaredInFile( + relativePath: string | undefined, + declarationFiles: ts.SourceFile[], + program: ts.Program, +): boolean { + if (relativePath === undefined) { + const cwd = program.getCurrentDirectory().toLowerCase(); + return declarationFiles.some(declaration => + declaration.fileName.toLowerCase().startsWith(cwd), + ); + } + const absolutePath = path + .join(program.getCurrentDirectory(), relativePath) + .toLowerCase(); + return declarationFiles.some( + declaration => declaration.fileName.toLowerCase() === absolutePath, + ); +} + +export function typeMatchesSpecifier( + type: ts.Type, + specifier: TypeOrValueSpecifier, + program: ts.Program, +): boolean { + if (typeof specifier === 'string') { + return specifierNameMatches(type, specifier); + } + if (!specifierNameMatches(type, specifier.name)) { + return false; + } + const declarationFiles = + type + .getSymbol() + ?.getDeclarations() + ?.map(declaration => declaration.getSourceFile()) ?? []; + switch (specifier.from) { + case 'file': + return typeDeclaredInFile(specifier.path, declarationFiles, program); + case 'lib': + return declarationFiles.some(declaration => + program.isSourceFileDefaultLibrary(declaration), + ); + case 'package': + return declarationFiles.some( + declaration => + declaration.fileName.includes(`node_modules/${specifier.package}/`) || + declaration.fileName.includes( + `node_modules/@types/${specifier.package}/`, + ), + ); + } +} diff --git a/packages/type-utils/src/index.ts b/packages/type-utils/src/index.ts index dde032e1770..9fc499aa8f3 100644 --- a/packages/type-utils/src/index.ts +++ b/packages/type-utils/src/index.ts @@ -11,6 +11,7 @@ export * from './isUnsafeAssignment'; export * from './predicates'; export * from './propertyTypes'; export * from './requiresQuoting'; +export * from './TypeOrValueSpecifier'; export * from './typeFlagUtils'; export { getDecorators, diff --git a/packages/type-utils/src/isTypeReadonly.ts b/packages/type-utils/src/isTypeReadonly.ts index 6a6e94cf814..16eeb73449c 100644 --- a/packages/type-utils/src/isTypeReadonly.ts +++ b/packages/type-utils/src/isTypeReadonly.ts @@ -3,6 +3,11 @@ import * as tsutils from 'ts-api-utils'; import * as ts from 'typescript'; import { getTypeOfPropertyOfType } from './propertyTypes'; +import type { TypeOrValueSpecifier } from './TypeOrValueSpecifier'; +import { + typeMatchesSpecifier, + typeOrValueSpecifierSchema, +} from './TypeOrValueSpecifier'; const enum Readonlyness { /** the type cannot be handled by the function */ @@ -15,6 +20,7 @@ const enum Readonlyness { export interface ReadonlynessOptions { readonly treatMethodsAsReadonly?: boolean; + readonly allow?: TypeOrValueSpecifier[]; } export const readonlynessOptionsSchema = { @@ -24,11 +30,16 @@ export const readonlynessOptionsSchema = { treatMethodsAsReadonly: { type: 'boolean', }, + allow: { + type: 'array', + items: typeOrValueSpecifierSchema, + }, }, }; export const readonlynessOptionsDefaults: ReadonlynessOptions = { treatMethodsAsReadonly: false, + allow: [], }; function hasSymbol(node: ts.Node): node is ts.Node & { symbol: ts.Symbol } { @@ -36,11 +47,12 @@ function hasSymbol(node: ts.Node): node is ts.Node & { symbol: ts.Symbol } { } function isTypeReadonlyArrayOrTuple( - checker: ts.TypeChecker, + program: ts.Program, type: ts.Type, options: ReadonlynessOptions, seenTypes: Set, ): Readonlyness { + const checker = program.getTypeChecker(); function checkTypeArguments(arrayType: ts.TypeReference): Readonlyness { const typeArguments = // getTypeArguments was only added in TS3.7 @@ -59,7 +71,7 @@ function isTypeReadonlyArrayOrTuple( if ( typeArguments.some( typeArg => - isTypeReadonlyRecurser(checker, typeArg, options, seenTypes) === + isTypeReadonlyRecurser(program, typeArg, options, seenTypes) === Readonlyness.Mutable, ) ) { @@ -93,11 +105,12 @@ function isTypeReadonlyArrayOrTuple( } function isTypeReadonlyObject( - checker: ts.TypeChecker, + program: ts.Program, type: ts.Type, options: ReadonlynessOptions, seenTypes: Set, ): Readonlyness { + const checker = program.getTypeChecker(); function checkIndexSignature(kind: ts.IndexKind): Readonlyness { const indexInfo = checker.getIndexInfoOfType(type, kind); if (indexInfo) { @@ -110,7 +123,7 @@ function isTypeReadonlyObject( } return isTypeReadonlyRecurser( - checker, + program, indexInfo.type, options, seenTypes, @@ -190,7 +203,7 @@ function isTypeReadonlyObject( } if ( - isTypeReadonlyRecurser(checker, propertyType, options, seenTypes) === + isTypeReadonlyRecurser(program, propertyType, options, seenTypes) === Readonlyness.Mutable ) { return Readonlyness.Mutable; @@ -213,13 +226,22 @@ function isTypeReadonlyObject( // a helper function to ensure the seenTypes map is always passed down, except by the external caller function isTypeReadonlyRecurser( - checker: ts.TypeChecker, + program: ts.Program, type: ts.Type, options: ReadonlynessOptions, seenTypes: Set, ): Readonlyness.Readonly | Readonlyness.Mutable { + const checker = program.getTypeChecker(); seenTypes.add(type); + if ( + options.allow?.some(specifier => + typeMatchesSpecifier(type, specifier, program), + ) + ) { + return Readonlyness.Readonly; + } + if (tsutils.isUnionType(type)) { // all types in the union must be readonly const result = tsutils @@ -227,7 +249,7 @@ function isTypeReadonlyRecurser( .every( t => seenTypes.has(t) || - isTypeReadonlyRecurser(checker, t, options, seenTypes) === + isTypeReadonlyRecurser(program, t, options, seenTypes) === Readonlyness.Readonly, ); const readonlyness = result ? Readonlyness.Readonly : Readonlyness.Mutable; @@ -242,7 +264,7 @@ function isTypeReadonlyRecurser( const allReadonlyParts = type.types.every( t => seenTypes.has(t) || - isTypeReadonlyRecurser(checker, t, options, seenTypes) === + isTypeReadonlyRecurser(program, t, options, seenTypes) === Readonlyness.Readonly, ); return allReadonlyParts ? Readonlyness.Readonly : Readonlyness.Mutable; @@ -250,7 +272,7 @@ function isTypeReadonlyRecurser( // Normal case. const isReadonlyObject = isTypeReadonlyObject( - checker, + program, type, options, seenTypes, @@ -266,7 +288,7 @@ function isTypeReadonlyRecurser( .every( t => seenTypes.has(t) || - isTypeReadonlyRecurser(checker, t, options, seenTypes) === + isTypeReadonlyRecurser(program, t, options, seenTypes) === Readonlyness.Readonly, ); @@ -289,7 +311,7 @@ function isTypeReadonlyRecurser( } const isReadonlyArray = isTypeReadonlyArrayOrTuple( - checker, + program, type, options, seenTypes, @@ -299,7 +321,7 @@ function isTypeReadonlyRecurser( } const isReadonlyObject = isTypeReadonlyObject( - checker, + program, type, options, seenTypes, @@ -317,12 +339,12 @@ function isTypeReadonlyRecurser( * Checks if the given type is readonly */ function isTypeReadonly( - checker: ts.TypeChecker, + program: ts.Program, type: ts.Type, options: ReadonlynessOptions = readonlynessOptionsDefaults, ): boolean { return ( - isTypeReadonlyRecurser(checker, type, options, new Set()) === + isTypeReadonlyRecurser(program, type, options, new Set()) === Readonlyness.Readonly ); } diff --git a/packages/type-utils/tests/TypeOrValueSpecifier.test.ts b/packages/type-utils/tests/TypeOrValueSpecifier.test.ts new file mode 100644 index 00000000000..d768911528a --- /dev/null +++ b/packages/type-utils/tests/TypeOrValueSpecifier.test.ts @@ -0,0 +1,288 @@ +import { parseForESLint } from '@typescript-eslint/parser'; +import type { TSESTree } from '@typescript-eslint/utils'; +import Ajv from 'ajv'; +import path from 'path'; + +import type { TypeOrValueSpecifier } from '../src/TypeOrValueSpecifier'; +import { + typeMatchesSpecifier, + typeOrValueSpecifierSchema, +} from '../src/TypeOrValueSpecifier'; + +describe('TypeOrValueSpecifier', () => { + describe('Schema', () => { + const ajv = new Ajv(); + const validate = ajv.compile(typeOrValueSpecifierSchema); + + function runTestPositive(data: unknown): void { + expect(validate(data)).toBe(true); + } + + function runTestNegative(data: unknown): void { + expect(validate(data)).toBe(false); + } + + it.each([['MyType'], ['myValue'], ['any'], ['void'], ['never']])( + 'matches a simple string specifier %s', + runTestPositive, + ); + + it.each([ + [42], + [false], + [null], + [undefined], + [['MyType']], + [(): void => {}], + ])("doesn't match any non-string basic type: %s", runTestNegative); + + it.each([ + [{ from: 'file', name: 'MyType' }], + [{ from: 'file', name: ['MyType', 'myValue'] }], + [{ from: 'file', name: 'MyType', path: './filename.js' }], + [{ from: 'file', name: ['MyType', 'myValue'], path: './filename.js' }], + ])('matches a file specifier: %s', runTestPositive); + + it.each([ + [{ from: 'file', name: 42 }], + [{ from: 'file', name: ['MyType', 42] }], + [{ from: 'file', name: ['MyType', 'MyType'] }], + [{ from: 'file', name: [] }], + [{ from: 'file', path: './filename.js' }], + [{ from: 'file', name: 'MyType', path: 42 }], + [{ from: 'file', name: ['MyType', 'MyType'], path: './filename.js' }], + [{ from: 'file', name: [], path: './filename.js' }], + [ + { + from: 'file', + name: ['MyType', 'myValue'], + path: ['./filename.js', './another-file.js'], + }, + ], + [{ from: 'file', name: 'MyType', unrelatedProperty: '' }], + ])("doesn't match a malformed file specifier: %s", runTestNegative); + + it.each([ + [{ from: 'lib', name: 'MyType' }], + [{ from: 'lib', name: ['MyType', 'myValue'] }], + ])('matches a lib specifier: %s', runTestPositive); + + it.each([ + [{ from: 'lib', name: 42 }], + [{ from: 'lib', name: ['MyType', 42] }], + [{ from: 'lib', name: ['MyType', 'MyType'] }], + [{ from: 'lib', name: [] }], + [{ from: 'lib' }], + [{ from: 'lib', name: 'MyType', unrelatedProperty: '' }], + ])("doesn't match a malformed lib specifier: %s", runTestNegative); + + it.each([ + [{ from: 'package', name: 'MyType', package: 'jquery' }], + [ + { + from: 'package', + name: ['MyType', 'myValue'], + package: 'jquery', + }, + ], + ])('matches a package specifier: %s', runTestPositive); + + it.each([ + [{ from: 'package', name: 42, package: 'jquery' }], + [{ from: 'package', name: ['MyType', 42], package: 'jquery' }], + [ + { + from: 'package', + name: ['MyType', 'MyType'], + package: 'jquery', + }, + ], + [{ from: 'package', name: [], package: 'jquery' }], + [{ from: 'package', name: 'MyType' }], + [{ from: 'package', package: 'jquery' }], + [{ from: 'package', name: 'MyType', package: 42 }], + [{ from: [], name: 'MyType' }], + [{ from: ['file'], name: 'MyType' }], + [{ from: ['lib'], name: 'MyType' }], + [{ from: ['package'], name: 'MyType' }], + [ + { + from: 'package', + name: ['MyType', 'myValue'], + package: ['jquery', './another-file.js'], + }, + ], + [ + { + from: 'package', + name: 'MyType', + package: 'jquery', + unrelatedProperty: '', + }, + ], + ])("doesn't match a malformed package specifier: %s", runTestNegative); + }); + + describe('typeMatchesSpecifier', () => { + function runTests( + code: string, + specifier: TypeOrValueSpecifier, + expected: boolean, + ): void { + const rootDir = path.join(__dirname, 'fixtures'); + const { ast, services } = parseForESLint(code, { + project: './tsconfig.json', + filePath: path.join(rootDir, 'file.ts'), + tsconfigRootDir: rootDir, + }); + const type = services + .program!.getTypeChecker() + .getTypeAtLocation( + services.esTreeNodeToTSNodeMap.get( + (ast.body[0] as TSESTree.TSTypeAliasDeclaration).id, + ), + ); + expect(typeMatchesSpecifier(type, specifier, services.program!)).toBe( + expected, + ); + } + + function runTestPositive( + code: string, + specifier: TypeOrValueSpecifier, + ): void { + runTests(code, specifier, true); + } + + function runTestNegative( + code: string, + specifier: TypeOrValueSpecifier, + ): void { + runTests(code, specifier, false); + } + + it.each<[string, TypeOrValueSpecifier]>([ + ['interface Foo {prop: string}; type Test = Foo;', 'Foo'], + ['type Test = RegExp;', 'RegExp'], + ])('matches a matching universal string specifier', runTestPositive); + + it.each<[string, TypeOrValueSpecifier]>([ + ['interface Foo {prop: string}; type Test = Foo;', 'Bar'], + ['interface Foo {prop: string}; type Test = Foo;', 'RegExp'], + ['type Test = RegExp;', 'Foo'], + ['type Test = RegExp;', 'BigInt'], + ])( + "doesn't match a mismatched universal string specifier", + runTestNegative, + ); + + it.each<[string, TypeOrValueSpecifier]>([ + [ + 'interface Foo {prop: string}; type Test = Foo;', + { from: 'file', name: 'Foo' }, + ], + [ + 'interface Foo {prop: string}; type Test = Foo;', + { from: 'file', name: ['Foo', 'Bar'] }, + ], + [ + 'interface Foo {prop: string}; type Test = Foo;', + { from: 'file', name: 'Foo', path: 'tests/fixtures/file.ts' }, + ], + [ + 'interface Foo {prop: string}; type Test = Foo;', + { + from: 'file', + name: ['Foo', 'Bar'], + path: 'tests/fixtures/file.ts', + }, + ], + ])('matches a matching file specifier: %s', runTestPositive); + + it.each<[string, TypeOrValueSpecifier]>([ + [ + 'interface Foo {prop: string}; type Test = Foo;', + { from: 'file', name: 'Bar' }, + ], + [ + 'interface Foo {prop: string}; type Test = Foo;', + { from: 'file', name: ['Bar', 'Baz'] }, + ], + [ + 'interface Foo {prop: string}; type Test = Foo;', + { from: 'file', name: 'Foo', path: 'tests/fixtures/wrong-file.ts' }, + ], + [ + 'interface Foo {prop: string}; type Test = Foo;', + { + from: 'file', + name: ['Foo', 'Bar'], + path: 'tests/fixtures/wrong-file.ts', + }, + ], + ])("doesn't match a mismatched file specifier: %s", runTestNegative); + + it.each<[string, TypeOrValueSpecifier]>([ + ['type Test = RegExp;', { from: 'lib', name: 'RegExp' }], + ['type Test = RegExp;', { from: 'lib', name: ['RegExp', 'BigInt'] }], + ])('matches a matching lib specifier: %s', runTestPositive); + + it.each<[string, TypeOrValueSpecifier]>([ + ['type Test = RegExp;', { from: 'lib', name: 'BigInt' }], + ['type Test = RegExp;', { from: 'lib', name: ['BigInt', 'Date'] }], + ])("doesn't match a mismatched lib specifier: %s", runTestNegative); + + it.each<[string, TypeOrValueSpecifier]>([ + [ + 'interface Foo {prop: string}; type Test = Foo;', + { from: 'lib', name: 'Foo' }, + ], + [ + 'interface Foo {prop: string}; type Test = Foo;', + { from: 'lib', name: ['Foo', 'Bar'] }, + ], + [ + 'interface Foo {prop: string}; type Test = Foo;', + { from: 'package', name: 'Foo', package: 'foo-package' }, + ], + [ + 'interface Foo {prop: string}; type Test = Foo;', + { from: 'package', name: ['Foo', 'Bar'], package: 'foo-package' }, + ], + [ + 'interface Foo {prop: string}; type Test = Foo;', + { from: 'package', name: 'Foo', package: 'foo-package' }, + ], + [ + 'interface Foo {prop: string}; type Test = Foo;', + { + from: 'package', + name: ['Foo', 'Bar'], + package: 'foo-package', + }, + ], + ['type Test = RegExp;', { from: 'file', name: 'RegExp' }], + ['type Test = RegExp;', { from: 'file', name: ['RegExp', 'BigInt'] }], + [ + 'type Test = RegExp;', + { from: 'file', name: 'RegExp', path: 'tests/fixtures/file.ts' }, + ], + [ + 'type Test = RegExp;', + { + from: 'file', + name: ['RegExp', 'BigInt'], + path: 'tests/fixtures/file.ts', + }, + ], + [ + 'type Test = RegExp;', + { from: 'package', name: 'RegExp', package: 'foo-package' }, + ], + [ + 'type Test = RegExp;', + { from: 'package', name: ['RegExp', 'BigInt'], package: 'foo-package' }, + ], + ])("doesn't match a mismatched specifier type: %s", runTestNegative); + }); +}); diff --git a/packages/type-utils/tests/isTypeReadonly.test.ts b/packages/type-utils/tests/isTypeReadonly.test.ts index 5c3da728ca4..2adfaaec7aa 100644 --- a/packages/type-utils/tests/isTypeReadonly.test.ts +++ b/packages/type-utils/tests/isTypeReadonly.test.ts @@ -15,7 +15,7 @@ describe('isTypeReadonly', () => { describe('TSTypeAliasDeclaration ', () => { function getType(code: string): { type: ts.Type; - checker: ts.TypeChecker; + program: ts.Program; } { const { ast, services } = parseForESLint(code, { project: './tsconfig.json', @@ -23,15 +23,15 @@ describe('isTypeReadonly', () => { tsconfigRootDir: rootDir, }); expectToHaveParserServices(services); - const checker = services.program.getTypeChecker(); + const program = services.program; const esTreeNodeToTSNodeMap = services.esTreeNodeToTSNodeMap; const declaration = ast.body[0] as TSESTree.TSTypeAliasDeclaration; return { - type: checker.getTypeAtLocation( - esTreeNodeToTSNodeMap.get(declaration.id), - ), - checker, + type: program + .getTypeChecker() + .getTypeAtLocation(esTreeNodeToTSNodeMap.get(declaration.id)), + program, }; } @@ -40,9 +40,9 @@ describe('isTypeReadonly', () => { options: ReadonlynessOptions | undefined, expected: boolean, ): void { - const { type, checker } = getType(code); + const { type, program } = getType(code); - const result = isTypeReadonly(checker, type, options); + const result = isTypeReadonly(program, type, options); expect(result).toBe(expected); } @@ -310,5 +310,52 @@ describe('isTypeReadonly', () => { ])('handles non fully readonly sets and maps', runTests); }); }); + + describe('allowlist', () => { + const options: ReadonlynessOptions = { + allow: [ + { + from: 'lib', + name: 'RegExp', + }, + { + from: 'file', + name: 'Foo', + }, + ], + }; + + function runTestIsReadonly(code: string): void { + runTestForAliasDeclaration(code, options, true); + } + + function runTestIsNotReadonly(code: string): void { + runTestForAliasDeclaration(code, options, false); + } + + describe('is readonly', () => { + it.each([ + [ + 'interface Foo {readonly prop: RegExp}; type Test = (arg: Foo) => void;', + ], + [ + 'interface Foo {prop: RegExp}; type Test = (arg: Readonly) => void;', + ], + ['interface Foo {prop: string}; type Test = (arg: Foo) => void;'], + ])('correctly marks allowlisted types as readonly', runTestIsReadonly); + }); + + describe('is not readonly', () => { + it.each([ + [ + 'interface Bar {prop: RegExp}; type Test = (arg: Readonly) => void;', + ], + ['interface Bar {prop: string}; type Test = (arg: Bar) => void;'], + ])( + 'correctly marks allowlisted types as readonly', + runTestIsNotReadonly, + ); + }); + }); }); }); diff --git a/yarn.lock b/yarn.lock index 8bf95c3ab2d..aa53921f868 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4108,6 +4108,16 @@ ajv@^8.0.0, ajv@^8.6.0, ajv@^8.8.0: require-from-string "^2.0.2" uri-js "^4.2.2" +ajv@^8.12.0: + version "8.12.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" + integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + algoliasearch-helper@^3.10.0: version "3.10.0" resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.10.0.tgz#59a0f645dd3c7e55cf01faa568d1af50c49d36f6"