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 b1e912abe379..703b16fa3f00 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 af83179904d8..e22ab9885e4a 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 649b49700507..6d6a353c623e 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 ee906b874777..f7b7dafaf0e3 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 000000000000..3ba6b02868d9 --- /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 dde032e1770c..9fc499aa8f31 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 6a6e94cf8149..16eeb73449c8 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 000000000000..d768911528a3 --- /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 5c3da728ca48..2adfaaec7aa8 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 b759e466c22f..ad16aa5ca493 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4115,6 +4115,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" pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy