import parser from '@typescript-eslint/parser'; import { RuleTester } from '@typescript-eslint/rule-tester'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import dedent from 'dedent'; import { noUnsafeReadonlyMutableAssignment } from './no-unsafe-readonly-mutable-assignment.mjs'; const ruleTester = new RuleTester({ languageOptions: { parserOptions: { sourceType: 'module', project: './tsconfig.tests.json', }, parser, }, }); ruleTester.run( 'no-unsafe-readonly-mutable-assignment', noUnsafeReadonlyMutableAssignment, { valid: [ /** Call expressions */ // zero parameters { filename: 'file.ts', code: dedent` const foo = () => { return undefined; }; foo(); `, }, // zero parameters with extra argument (TypeScript will catch this so we don't flag it) { filename: 'file.ts', code: dedent` const foo = () => { return undefined; }; foo(""); `, }, // non-object parameter { filename: 'file.ts', code: dedent` const foo = (a: string) => { return undefined; }; foo("a"); `, }, // missing arguments (TypeScript will catch this so we don't flag it) { filename: 'file.ts', code: dedent` const foo = (a: string) => { return undefined; }; foo(); `, }, // readonly -> readonly (type doesn't change) { filename: 'file.ts', code: dedent` type ReadonlyA = { readonly a: string }; const func = (param: ReadonlyA): void => { return undefined; }; const readonlyA: ReadonlyA = { a: "" }; func(readonlyA); `, }, // readonly -> readonly (nested object; type doesn't change) { filename: 'file.ts', code: dedent` type ReadonlyA = { readonly a: { readonly b: string } }; const func = (param: ReadonlyA): void => { return undefined; }; const readonlyA: ReadonlyA = { a: { b: "" } }; func(readonlyA); `, }, // mutable -> mutable (type doesn't change) { filename: 'file.ts', code: dedent` type MutableA = {a: string}; const foo = (mut: MutableA) => { mut.a = "whoops"; }; const mut: MutableA = { a: "" }; foo(mut); `, }, // object literal -> mutable (no reference to object retained) { filename: 'file.ts', code: dedent` type MutableA = {a: string}; const foo = (mut: MutableA) => { mut.a = "whoops"; }; foo({ a: "" }); `, }, // object literal -> mutable (mutable reference to property retained) { filename: 'file.ts', code: dedent` type MutableB = { b: string }; type MutableA = { readonly a: MutableB }; const func = (param: MutableA): void => { return undefined; }; const b: MutableB = { b: "" }; func({ a: b }); `, }, // object literal -> readonly (mutable reference to property retained) { filename: 'file.ts', code: dedent` type MutableB = { b: string }; type ReadonlyA = { readonly a: { readonly b: string } }; const func = (param: ReadonlyA): void => { return undefined; }; const b: MutableB = { b: "" }; func({ a: b }); `, }, // object literal -> readonly (readonly reference to property retained) { filename: 'file.ts', code: dedent` type ReadonlyB = { readonly b: string }; type ReadonlyA = { readonly a: ReadonlyB }; const func = (param: ReadonlyA): void => { return undefined; }; const b: ReadonlyB = { b: "" }; func({ a: b }); `, }, // object literal -> readonly (no reference to object or its property retained) { filename: 'file.ts', code: dedent` type ReadonlyB = { readonly b: string }; type ReadonlyA = { readonly a: ReadonlyB }; const func = (param: ReadonlyA): void => { return undefined; }; func({ a: { b: "" } }); `, }, // mutable (union) -> mutable { filename: 'file.ts', code: dedent` type MutableA = {a: string}; const foo = (mut: MutableA) => { mut.a = "whoops"; }; const mut: MutableA | number = { a: "" }; foo(mut); `, }, // mutable -> mutable (union) { filename: 'file.ts', code: dedent` type MutableA = {a: string}; const foo = (mut: MutableA | number): void => { return; }; const mut: MutableA = { a: "" }; foo(mut); `, }, // multiple type signatures (readonly -> readonly) { filename: 'file.ts', code: dedent` type ReadonlyA = { readonly a: string }; export function func(a: number): number; export function func(a: ReadonlyA): ReadonlyA; export function func(a: any): any { return a; } const readonlyA: ReadonlyA = { a: "" }; func(readonlyA); `, }, // multiple type signatures (no matching signature) // we don't bother flagging this because TypeScript itself will catch it { filename: 'file.ts', code: dedent` type ReadonlyA = { readonly a: string }; export function func(a: number): number; export function func(a: string): string; export function func(a: any): any { return a; } const readonlyA: ReadonlyA = { a: "" }; func(readonlyA); `, }, // readonly array concat. { filename: 'file.ts', code: dedent` const arr: ReadonlyArray = []; const foo = arr.concat(arr, arr); `, }, // mutable array concat. { filename: 'file.ts', code: dedent` const arr: Array = []; const foo = arr.concat(arr, arr); `, }, // Mixed mutable and readonly array concat. // TODO this should be invalid. { filename: 'file.ts', code: dedent` const ro: ReadonlyArray = []; const mut: Array = []; const foo = ro.concat(ro, mut); `, }, // mixed (union) -> mixed (union) // The `readonly`s align and `mutable`s align, so no surprising mutation can arise. { filename: 'file.ts', code: dedent` type MutableA = { a: string }; type ReadonlyB = { readonly b: string }; const func = (foo: MutableA | ReadonlyB): void => { return; }; const foo: MutableA | ReadonlyB = Date.now() > 0 ? { a: "" } : { b: "" }; func(foo); `, }, // Recursive type (linting must terminate) { filename: 'file.ts', code: dedent` type Foo = ReadonlyArray; const func = (foo: Foo): void => { return; }; const foo: Foo = [[]]; func(foo); `, }, { filename: 'file.ts', code: dedent` const foo = document.createElement("div"); `, }, // readonly array of readonly object -> readonly array of readonly object { filename: 'file.ts', code: dedent` type Obj = { readonly foo: string }; const foo = (a: ReadonlyArray): number => a.length; const arr: ReadonlyArray = []; foo(arr); `, }, /** Assignment expressions */ // TODO /** Return statement */ // mutable -> mutable (function return) { filename: 'file.ts', code: dedent` type MutableA = { a: string }; type ReadonlyA = { readonly a: string }; function foo(): MutableA { const ma: MutableA = { a: "" }; return ma; } `, }, // readonly -> readonly (function return) { filename: 'file.ts', code: dedent` type MutableA = { a: string }; type ReadonlyA = { readonly a: string }; function foo(): ReadonlyA { const ma: ReadonlyA = { a: "" }; return ma; } `, }, // void (function return) { filename: 'file.ts', code: dedent` function foo(): void { return; } `, }, // https://github.com/danielnixon/eslint-plugin-total-functions/issues/577 { filename: 'file.ts', code: dedent` const arr : readonly number[] = []; const obj : {} = arr; const it1 : Iterable = arr; const it2 : Readonly> = arr; `, }, // multiple call signatures, recursive // TODO fix Stack Overflow properly instead of just terminating iteration arbitrarily { filename: 'file.ts', code: dedent` interface Foo { (b: Foo & { b: Foo }): Foo; >(b: Foo & { b: Foo }): Foo; } declare const a: Foo<{ a: string }>; const b = a as Foo<{ b: string }>; `, }, { filename: 'file.ts', code: dedent` interface Foo { >(b: Foo & { b: Foo }): Foo; } declare const a: Foo<{ a: string }>; const b = a as Foo<{ b: string }>; `, }, { filename: 'file.ts', code: dedent` declare const foo: string; const bar: string | { a: string } = foo; `, }, { filename: 'file.ts', code: dedent` const a: readonly string[] = ["a"] as const; `, }, // Literal (immutable) assigned to mutable but safe because no surprising mutation can arise // see https://github.com/danielnixon/eslint-plugin-total-functions/issues/754 { filename: 'file.ts', code: dedent` const o: Record = {}; `, }, ], invalid: [ // object literal -> mutable (readonly reference to property retained) // this can lead to surprising mutation in the readonly reference that is retained { filename: 'file.ts', code: dedent` type MutableB = { b: string }; type ReadonlyB = { readonly b: string }; type MutableA = { readonly a: MutableB }; const func = (param: MutableA): void => { return undefined; }; const b: ReadonlyB = { b: "" }; func({ a: b } as const); `, errors: [ { messageId: 'errorStringGeneric', type: AST_NODE_TYPES.TSAsExpression, }, ], }, // readonly -> mutable (rest parameter) { filename: 'file.ts', code: dedent` type MutableA = { a: string }; type ReadonlyA = { readonly a: string }; const foo = (...as: readonly MutableA[]): void => { return; }; const ma: MutableA = { a: "" }; const ra: ReadonlyA = { a: "" }; foo(ma, ra); `, errors: [ { messageId: 'errorStringGeneric', type: AST_NODE_TYPES.Identifier, }, ], }, // readonly (union) -> mutable (union) { filename: 'file.ts', code: dedent` type MutableA = { a: string }; type ReadonlyA = { readonly a: string }; type MutableB = { b: string }; type ReadonlyB = { readonly b: string }; const mutate = (mut: MutableA | MutableB): void => { return; }; const ro: ReadonlyA | ReadonlyB = { a: "" }; mutate(ro); `, errors: [ { messageId: 'errorStringGeneric', type: AST_NODE_TYPES.Identifier, }, ], }, // readonly (union) -> mixed (union) { filename: 'file.ts', code: dedent` type ReadonlyA = { readonly a: string }; type MutableB = { b: string }; type ReadonlyB = { readonly b: string }; const mutate = (mut: ReadonlyA | MutableB): void => { return; }; const ro: ReadonlyA | ReadonlyB = Date.now() > 0 ? { a: "" } : { b: "" }; mutate(ro); `, errors: [ { messageId: 'errorStringGeneric', type: AST_NODE_TYPES.Identifier, }, ], }, /** Assignment expressions */ // readonly -> mutable { filename: 'file.ts', code: dedent` type MutableA = { a: string }; type ReadonlyA = { readonly a: string }; const readonlyA: ReadonlyA = { a: "readonly?" }; let mutableA: MutableA; mutableA = readonlyA; `, errors: [ { messageId: 'errorStringGeneric', type: AST_NODE_TYPES.AssignmentExpression, }, ], }, // readonly -> mutable (short-circuiting assignment) { filename: 'file.ts', code: dedent` type MutableA = { a: string }; type ReadonlyA = { readonly a: string }; const readonlyA: ReadonlyA = { a: "readonly?" }; let mutableA: MutableA | undefined; mutableA ??= readonlyA; `, errors: [ { messageId: 'errorStringGeneric', type: AST_NODE_TYPES.AssignmentExpression, }, ], }, /** Variable declaration */ // readonly (type) -> mutable { filename: 'file.ts', code: dedent` type MutableA = { a: string }; type ReadonlyA = { readonly a: string }; const readonlyA: ReadonlyA = { a: "readonly?" }; const mutableA: MutableA = readonlyA; `, errors: [ { messageId: 'errorStringGeneric', type: AST_NODE_TYPES.VariableDeclaration, }, ], }, // readonly (class) -> mutable // this is arguably worse than the above because instead of surprise mutation it results in a TypeError { filename: 'file.ts', code: dedent` class Box { get area(): number { return 42; } } type Area = { area: number; }; const a: Area = new Box(); `, errors: [ { messageId: 'errorStringGeneric', type: AST_NODE_TYPES.VariableDeclaration, }, ], }, // TODO // is-immutable-type is (correctly) infering that both MutableA // and ReadonlyA are mutable, so sadly this isn't being flagged. // readonly (string index type) -> mutable (string index type) // { // filename: "file.ts", // code: ` // type MutableA = Record; // type ReadonlyA = Record; // declare const readonlyA: ReadonlyA; // const mutableA: MutableA = readonlyA; // `, // errors: [ // { // messageId: "errorStringGeneric", // type: AST_NODE_TYPES.VariableDeclaration, // }, // ], // }, // readonly (string index signature) -> mutable (string index signature) (recursive types) { filename: 'file.ts', code: dedent` type MutableA = { [P in string]: MutableA; }; type ReadonlyA = { readonly [P in string]: ReadonlyA; }; const readonlyA: ReadonlyA = {}; const mutableA: MutableA = readonlyA; `, errors: [ { messageId: 'errorStringGeneric', type: AST_NODE_TYPES.VariableDeclaration, }, ], }, // readonly (number index signature) -> mutable (number index signature) (recursive types) { filename: 'file.ts', code: dedent` type MutableA = { [P in number]: MutableA; }; type ReadonlyA = { readonly [P in number]: ReadonlyA; }; const readonlyA: ReadonlyA = {}; const mutableA: MutableA = readonlyA; `, errors: [ { messageId: 'errorStringGeneric', type: AST_NODE_TYPES.VariableDeclaration, }, ], }, /** Return statement */ // readonly -> mutable (function return) { filename: 'file.ts', code: dedent` type MutableA = { a: string }; type ReadonlyA = { readonly a: string }; function foo(): MutableA { const ma: ReadonlyA = { a: "" }; return ma; } `, errors: [ { messageId: 'errorStringGeneric', type: AST_NODE_TYPES.ReturnStatement, }, ], }, ], } as const, );