Skip to content

Commit c9427b7

Browse files
marekdedicbradzacherJoshuaKGoldberg
authored
feat(eslint-plugin): [prefer-readonly-parameter-types] added an optional type allowlist (#4436)
* feat(eslint-plugin): [prefer-readonly-parameter-types] Added an optional type whitelist * chore(eslint-plugin): [prefer-readonly-parameter-types] whitelist -> allowlist * feat(eslint-plugin): [prefer-readonly-parameter-types] Split the allowlist between internal and configurable * fix(eslint-plugin): [prefer-readonly-parameter-types] Fixed lint issue with non-null assertion * fix(eslint-plugin): [prefer-readonly-parameter-types] Using allowlist everywhere in deep readonlyness checks * fix(eslint-plugin): [prefer-readonly-parameter-types] Passing internal allowlist from rule * fix(eslint-plugin): [prefer-readonly-parameter-types] Decoupled options and schema of rule and util * feat(eslint-plugin): [prefer-readonly-parameter-types] Added tests * fix(eslint-plugin): [prefer-readonly-parameter-types] Added missing docs for option treatMethodsAsReadonly * docs(eslint-plugin): [prefer-readonly-parameter-types] Added docs for allowlist * fix(eslint-plugin): [prefer-readonly-parameter-types] Fixed regressions from merging main * feat(eslint-plugin): [prefer-readonly-parameter-types] Merged exceptions and internalExceptions together to create a universal allowlist API * feat(eslint-plugin): [prefer-readonly-parameter-types] Added a schema for type allowlist * chore(eslint-plugin): [prefer-readonly-parameter-types] Split TypeAllowlistItem out into own file * docs(eslint-plugin): [prefer-readonly-parameter-types] Updated docs for the more sophisticated allowlist * docs(eslint-plugin): [prefer-readonly-parameter-types] Fixed allowlist option type * test(eslint-plugin): [prefer-readonly-parameter-types] Added tests for type allowlist with wrong kinds of types * chore(eslint-plugin): [prefer-readonly-parameter-types] Deduplicated default configuration * fix(eslint-plugin): [prefer-readonly-parameter-types] Added back readonlynessOptionsSchema * chore(eslint-plugin): [prefer-readonly-parameter-types] Removed default allowlist * docs(eslint-plugin): [prefer-readonly-parameter-types] Fixed default allowlist in docs * test(eslint-plugin): [prefer-readonly-parameter-types] Not using DOM in tests * chore(eslint-plugin): [prefer-readonly-parameter-types] Using property shorthand * feat(eslint-plugin): [prefer-readonly-parameter-types] TypeAllowlistItem is now a discriminated union * docs(eslint-plugin): [prefer-readonly-parameter-types] TypeAllowlistItem is now a discriminated union - docs update * test(type-utils): [prefer-readonly-parameter-types] Added rudimentary test for allowlist * test(type-utils): [prefer-readonly-parameter-types] Added test for allowlist containing local definition * Update packages/type-utils/src/TypeAllowListItem.ts to use enum in JSON schema Co-authored-by: Brad Zacher <[email protected]> * fix(eslint-plugin): [prefer-readonly-parameter-types] Added trainling slash to package path check * fix(eslint-plugin) Fixed type imports not being separated * feat(type-utils): Added TypeOrValueSpecifier, its schema and test for the schema * feat(type-utils): Added typeMatchesSpecifier() and switched isTypeReadonly over to TypeOrValueSpecifier * fix(eslint-plugin): [prefer-readonly-parameter-types] Fixed tests having old allowlist format * fix(type-utils): Removed unneeded function isTypeExcepted * feat(type-utils): Added source file checking to typeMatchesFileSpecifier() * docs(eslint-plugin): [prefer-readonly-parameter-types] Updated docs to use TypeOrValueSpecifier allowlist style * docs(eslint-plugin): [prefer-readonly-parameter-types] Typo fix * docs(eslint-plugin): [prefer-readonly-parameter-types] Typo fix 2 * feat(type-utils): Added tests for typeMatchesSpecifier() * fix(type-utils): Using node path joining typeMatchesSpecifier() * feat(type-utils): Removed MultiSourceSpecifier * chore(type-utils): Simplified typeMatchesSpecifier() * feat(type-utils): Added more tests for typeMatchesSpecifier() * docs(prefer-readonly-parameter-types) more legible docs Co-authored-by: Josh Goldberg <[email protected]> * fix(eslint-plugin): [prefer-readonly-parameter-types] Fixed missing end of code listing in docs * chore(type-utils): Simplified typeDeclaredInFile() * chore(type-utils): Using unknown instead of any in tests * test(type-utils): grammar fix in test specifications * chore: Reset yarn.lock * chore: renamed readonlyness allowlist to just allow * fix(type-utils): fixed services.program now being optional and not checked in tests * test(type-utils): negative tests for isTypeReadonly * fix(eslint-plugin): bracket style array notation Co-authored-by: Josh Goldberg <[email protected]> * fix(type-utils): Fixed array style * fix(type-utils): Not fetching symbol repeatedly * fix(type-utils): Remove ManySpecifiers format from TypeOrValueSpecifier schema * docs(eslint-plugin): [prefer-readonly-parameter-types] described file specifier path as being relative * path and package * Update docs too * Update docs too (again) * Added test name helpers, and fixed test data * test(type-utils): fixed package schema tests * test(eslint-plugin): fixed type whitelist schema in prefer-readonly-parameter-types tests * Applied lowercasing to typeDeclaredInFile --------- Co-authored-by: Brad Zacher <[email protected]> Co-authored-by: Josh Goldberg <[email protected]>
1 parent 6652ebe commit c9427b7

File tree

10 files changed

+884
-28
lines changed

10 files changed

+884
-28
lines changed

packages/eslint-plugin/docs/rules/prefer-readonly-parameter-types.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,101 @@ interface Foo {
129129

130130
## Options
131131

132+
### `allow`
133+
134+
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.
135+
136+
Each item must be one of:
137+
138+
- 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)
139+
- A type from the default library (`{from: "lib", name: "Foo"}`)
140+
- A type from a package (`{from: "package", name: "Foo", package: "foo-lib"}`, this also works for types defined in a typings package).
141+
142+
Additionally, a type may be defined just as a simple string, which then matches the type independently of its origin.
143+
144+
Examples of code for this rule with:
145+
146+
```json
147+
{
148+
"allow": [
149+
"$",
150+
{ "source": "file", "name": "Foo" },
151+
{ "source": "lib", "name": "HTMLElement" },
152+
{ "from": "package", "name": "Bar", "package": "bar-lib" }
153+
]
154+
}
155+
```
156+
157+
<!--tabs-->
158+
159+
#### ❌ Incorrect
160+
161+
```ts
162+
interface ThisIsMutable {
163+
prop: string;
164+
}
165+
166+
interface Wrapper {
167+
sub: ThisIsMutable;
168+
}
169+
170+
interface WrapperWithOther {
171+
readonly sub: Foo;
172+
otherProp: string;
173+
}
174+
175+
function fn1(arg: ThisIsMutable) {} // Incorrect because ThisIsMutable is not readonly
176+
function fn2(arg: Wrapper) {} // Incorrect because Wrapper.sub is not readonly
177+
function fn3(arg: WrapperWithOther) {} // Incorrect because WrapperWithOther.otherProp is not readonly and not in the allowlist
178+
```
179+
180+
```ts
181+
import { Foo } from 'some-lib';
182+
import { Bar } from 'incorrect-lib';
183+
184+
interface HTMLElement {
185+
prop: string;
186+
}
187+
188+
function fn1(arg: Foo) {} // Incorrect because Foo is not a local type
189+
function fn2(arg: HTMLElement) {} // Incorrect because HTMLElement is not from the default library
190+
function fn3(arg: Bar) {} // Incorrect because Bar is not from "bar-lib"
191+
```
192+
193+
#### ✅ Correct
194+
195+
```ts
196+
interface Foo {
197+
prop: string;
198+
}
199+
200+
interface Wrapper {
201+
readonly sub: Foo;
202+
readonly otherProp: string;
203+
}
204+
205+
function fn1(arg: Foo) {} // Works because Foo is allowed
206+
function fn2(arg: Wrapper) {} // Works even when Foo is nested somewhere in the type, with other properties still being checked
207+
```
208+
209+
```ts
210+
import { Bar } from 'bar-lib';
211+
212+
interface Foo {
213+
prop: string;
214+
}
215+
216+
function fn1(arg: Foo) {} // Works because Foo is a local type
217+
function fn2(arg: HTMLElement) {} // Works because HTMLElement is from the default library
218+
function fn3(arg: Bar) {} // Works because Bar is from "bar-lib"
219+
```
220+
221+
```ts
222+
import { Foo } from './foo';
223+
224+
function fn(arg: Foo) {} // Works because Foo is still a local type - it has to be in the same package
225+
```
226+
132227
### `checkParameterProperties`
133228

134229
This option allows you to enable or disable the checking of parameter properties.

packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import * as util from '../util';
55

66
type Options = [
77
{
8+
allow?: util.TypeOrValueSpecifier[];
89
checkParameterProperties?: boolean;
910
ignoreInferredTypes?: boolean;
10-
} & util.ReadonlynessOptions,
11+
treatMethodsAsReadonly?: boolean;
12+
},
1113
];
1214
type MessageIds = 'shouldBeReadonly';
1315

@@ -25,13 +27,15 @@ export default util.createRule<Options, MessageIds>({
2527
type: 'object',
2628
additionalProperties: false,
2729
properties: {
30+
allow: util.readonlynessOptionsSchema.properties.allow,
2831
checkParameterProperties: {
2932
type: 'boolean',
3033
},
3134
ignoreInferredTypes: {
3235
type: 'boolean',
3336
},
34-
...util.readonlynessOptionsSchema.properties,
37+
treatMethodsAsReadonly:
38+
util.readonlynessOptionsSchema.properties.treatMethodsAsReadonly,
3539
},
3640
},
3741
],
@@ -41,17 +45,25 @@ export default util.createRule<Options, MessageIds>({
4145
},
4246
defaultOptions: [
4347
{
48+
allow: util.readonlynessOptionsDefaults.allow,
4449
checkParameterProperties: true,
4550
ignoreInferredTypes: false,
46-
...util.readonlynessOptionsDefaults,
51+
treatMethodsAsReadonly:
52+
util.readonlynessOptionsDefaults.treatMethodsAsReadonly,
4753
},
4854
],
4955
create(
5056
context,
51-
[{ checkParameterProperties, ignoreInferredTypes, treatMethodsAsReadonly }],
57+
[
58+
{
59+
allow,
60+
checkParameterProperties,
61+
ignoreInferredTypes,
62+
treatMethodsAsReadonly,
63+
},
64+
],
5265
) {
5366
const services = util.getParserServices(context);
54-
const checker = services.program.getTypeChecker();
5567

5668
return {
5769
[[
@@ -94,8 +106,9 @@ export default util.createRule<Options, MessageIds>({
94106
}
95107

96108
const type = services.getTypeAtLocation(actualParam);
97-
const isReadOnly = util.isTypeReadonly(checker, type, {
109+
const isReadOnly = util.isTypeReadonly(services.program, type, {
98110
treatMethodsAsReadonly: treatMethodsAsReadonly!,
111+
allow,
99112
});
100113

101114
if (!isReadOnly) {

packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,83 @@ ruleTester.run('prefer-readonly-parameter-types', rule, {
401401
},
402402
],
403403
},
404+
// Allowlist
405+
{
406+
code: `
407+
interface Foo {
408+
readonly prop: RegExp;
409+
}
410+
411+
function foo(arg: Foo) {}
412+
`,
413+
options: [
414+
{
415+
allow: [{ from: 'lib', name: 'RegExp' }],
416+
},
417+
],
418+
},
419+
{
420+
code: `
421+
interface Foo {
422+
prop: RegExp;
423+
}
424+
425+
function foo(arg: Readonly<Foo>) {}
426+
`,
427+
options: [
428+
{
429+
allow: [{ from: 'lib', name: 'RegExp' }],
430+
},
431+
],
432+
},
433+
{
434+
code: `
435+
interface Foo {
436+
prop: string;
437+
}
438+
439+
function foo(arg: Foo) {}
440+
`,
441+
options: [
442+
{
443+
allow: [{ from: 'file', name: 'Foo' }],
444+
},
445+
],
446+
},
447+
{
448+
code: `
449+
interface Bar {
450+
prop: string;
451+
}
452+
interface Foo {
453+
readonly prop: Bar;
454+
}
455+
456+
function foo(arg: Foo) {}
457+
`,
458+
options: [
459+
{
460+
allow: [{ from: 'file', name: 'Foo' }],
461+
},
462+
],
463+
},
464+
{
465+
code: `
466+
interface Bar {
467+
prop: string;
468+
}
469+
interface Foo {
470+
readonly prop: Bar;
471+
}
472+
473+
function foo(arg: Foo) {}
474+
`,
475+
options: [
476+
{
477+
allow: [{ from: 'file', name: 'Bar' }],
478+
},
479+
],
480+
},
404481
],
405482
invalid: [
406483
// arrays
@@ -885,5 +962,126 @@ ruleTester.run('prefer-readonly-parameter-types', rule, {
885962
`,
886963
errors: [{ line: 6, messageId: 'shouldBeReadonly' }],
887964
},
965+
// Allowlist
966+
{
967+
code: `
968+
function foo(arg: RegExp) {}
969+
`,
970+
options: [
971+
{
972+
allow: [{ from: 'file', name: 'Foo' }],
973+
},
974+
],
975+
errors: [
976+
{
977+
messageId: 'shouldBeReadonly',
978+
line: 2,
979+
column: 22,
980+
endColumn: 33,
981+
},
982+
],
983+
},
984+
{
985+
code: `
986+
interface Foo {
987+
readonly prop: RegExp;
988+
}
989+
990+
function foo(arg: Foo) {}
991+
`,
992+
options: [
993+
{
994+
allow: [{ from: 'file', name: 'Bar' }],
995+
},
996+
],
997+
errors: [
998+
{
999+
messageId: 'shouldBeReadonly',
1000+
line: 6,
1001+
column: 22,
1002+
endColumn: 30,
1003+
},
1004+
],
1005+
},
1006+
{
1007+
code: `
1008+
interface Foo {
1009+
readonly prop: RegExp;
1010+
}
1011+
1012+
function foo(arg: Foo) {}
1013+
`,
1014+
options: [
1015+
{
1016+
allow: [{ from: 'lib', name: 'Foo' }],
1017+
},
1018+
],
1019+
errors: [
1020+
{
1021+
messageId: 'shouldBeReadonly',
1022+
line: 6,
1023+
column: 22,
1024+
endColumn: 30,
1025+
},
1026+
],
1027+
},
1028+
{
1029+
code: `
1030+
interface Foo {
1031+
readonly prop: RegExp;
1032+
}
1033+
1034+
function foo(arg: Foo) {}
1035+
`,
1036+
options: [
1037+
{
1038+
allow: [{ from: 'package', name: 'Foo', package: 'foo-lib' }],
1039+
},
1040+
],
1041+
errors: [
1042+
{
1043+
messageId: 'shouldBeReadonly',
1044+
line: 6,
1045+
column: 22,
1046+
endColumn: 30,
1047+
},
1048+
],
1049+
},
1050+
{
1051+
code: `
1052+
function foo(arg: RegExp) {}
1053+
`,
1054+
options: [
1055+
{
1056+
allow: [{ from: 'file', name: 'RegExp' }],
1057+
},
1058+
],
1059+
errors: [
1060+
{
1061+
messageId: 'shouldBeReadonly',
1062+
line: 2,
1063+
column: 22,
1064+
endColumn: 33,
1065+
},
1066+
],
1067+
},
1068+
{
1069+
code: `
1070+
function foo(arg: RegExp) {}
1071+
`,
1072+
options: [
1073+
{
1074+
allow: [{ from: 'package', name: 'RegExp', package: 'regexp-lib' }],
1075+
},
1076+
],
1077+
errors: [
1078+
{
1079+
messageId: 'shouldBeReadonly',
1080+
line: 2,
1081+
column: 22,
1082+
endColumn: 33,
1083+
},
1084+
],
1085+
},
8881086
],
8891087
});

packages/type-utils/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
},
5353
"devDependencies": {
5454
"@typescript-eslint/parser": "5.55.0",
55+
"ajv": "^8.12.0",
5556
"typescript": "*"
5657
},
5758
"peerDependencies": {

0 commit comments

Comments
 (0)