diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index e2f586f6981b..272fd56e0b2d 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -61,6 +61,7 @@ "@types/marked": "*", "@types/natural-compare-lite": "^1.4.0", "@types/prettier": "*", + "ajv": "^6.12.6", "chalk": "^5.0.1", "cross-fetch": "^3.1.5", "grapheme-splitter": "^1.0.4", diff --git a/packages/eslint-plugin/src/rules/array-type.ts b/packages/eslint-plugin/src/rules/array-type.ts index f353207e7d5d..dcfcc9e3040d 100644 --- a/packages/eslint-plugin/src/rules/array-type.ts +++ b/packages/eslint-plugin/src/rules/array-type.ts @@ -111,8 +111,10 @@ export default util.createRule({ enum: ['array', 'generic', 'array-simple'], }, }, - prefixItems: [ + items: [ { + type: 'object', + additionalProperties: false, properties: { default: { $ref: '#/$defs/arrayOption', @@ -124,7 +126,6 @@ export default util.createRule({ 'The array type expected for readonly cases. If omitted, the value for `default` will be used.', }, }, - type: 'object', }, ], type: 'array', diff --git a/packages/eslint-plugin/src/rules/ban-ts-comment.ts b/packages/eslint-plugin/src/rules/ban-ts-comment.ts index 511a951280e7..b09cfc457268 100644 --- a/packages/eslint-plugin/src/rules/ban-ts-comment.ts +++ b/packages/eslint-plugin/src/rules/ban-ts-comment.ts @@ -59,8 +59,10 @@ export default util.createRule<[Options], MessageIds>({ ], }, }, - prefixItems: [ + items: [ { + type: 'object', + additionalProperties: false, properties: { 'ts-expect-error': { $ref: '#/$defs/directiveConfigSchema', @@ -73,7 +75,6 @@ export default util.createRule<[Options], MessageIds>({ default: defaultMinimumDescriptionLength, }, }, - additionalProperties: false, }, ], type: 'array', diff --git a/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts b/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts index 3fc42a956f68..9174a4c83c94 100644 --- a/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts +++ b/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts @@ -67,7 +67,7 @@ export default util.createRule({ $defs: { accessibilityLevel, }, - prefixItems: [ + items: [ { type: 'object', properties: { diff --git a/packages/eslint-plugin/src/rules/lines-between-class-members.ts b/packages/eslint-plugin/src/rules/lines-between-class-members.ts index 2f37b365daea..301407d45ae8 100644 --- a/packages/eslint-plugin/src/rules/lines-between-class-members.ts +++ b/packages/eslint-plugin/src/rules/lines-between-class-members.ts @@ -9,16 +9,20 @@ const baseRule = getESLintCoreRule('lines-between-class-members'); type Options = util.InferOptionsTypeFromRule; type MessageIds = util.InferMessageIdsTypeFromRule; -const schema = util.deepMerge( - { ...baseRule.meta.schema }, - { - 1: { - exceptAfterOverload: { - type: 'boolean', - default: true, +const schema = Object.values( + util.deepMerge( + { ...baseRule.meta.schema }, + { + 1: { + properties: { + exceptAfterOverload: { + type: 'boolean', + default: true, + }, + }, }, }, - }, + ), ); export default util.createRule({ diff --git a/packages/eslint-plugin/src/rules/no-misused-promises.ts b/packages/eslint-plugin/src/rules/no-misused-promises.ts index 8a7f2875170f..69eb6b41d008 100644 --- a/packages/eslint-plugin/src/rules/no-misused-promises.ts +++ b/packages/eslint-plugin/src/rules/no-misused-promises.ts @@ -83,6 +83,7 @@ export default util.createRule({ schema: [ { type: 'object', + additionalProperties: false, properties: { checksConditionals: { type: 'boolean', diff --git a/packages/eslint-plugin/src/rules/no-parameter-properties.ts b/packages/eslint-plugin/src/rules/no-parameter-properties.ts index 3952dfc581f1..f7ff81f8f510 100644 --- a/packages/eslint-plugin/src/rules/no-parameter-properties.ts +++ b/packages/eslint-plugin/src/rules/no-parameter-properties.ts @@ -51,7 +51,6 @@ export default util.createRule({ 'public readonly', ], }, - minItems: 1, }, }, additionalProperties: false, diff --git a/packages/eslint-plugin/src/rules/no-restricted-imports.ts b/packages/eslint-plugin/src/rules/no-restricted-imports.ts index c2c2b54aa721..0a1a2b6c2050 100644 --- a/packages/eslint-plugin/src/rules/no-restricted-imports.ts +++ b/packages/eslint-plugin/src/rules/no-restricted-imports.ts @@ -5,12 +5,13 @@ import type { } from 'eslint/lib/rules/no-restricted-imports'; import type { Ignore } from 'ignore'; import ignore from 'ignore'; +import type { JSONSchema4 } from 'json-schema'; import type { InferMessageIdsTypeFromRule, InferOptionsTypeFromRule, } from '../util'; -import { createRule, deepMerge } from '../util'; +import { createRule } from '../util'; import { getESLintCoreRule } from '../util/getESLintCoreRule'; const baseRule = getESLintCoreRule('no-restricted-imports'); @@ -18,48 +19,146 @@ const baseRule = getESLintCoreRule('no-restricted-imports'); export type Options = InferOptionsTypeFromRule; export type MessageIds = InferMessageIdsTypeFromRule; -const allowTypeImportsOptionSchema = { +// In some versions of eslint, the base rule has a completely incompatible schema +// This helper function is to safely try to get parts of the schema. If it's not +// possible, we'll fallback to less strict checks. +const tryAccess = (getter: () => T, fallback: T): T => { + try { + return getter(); + } catch { + return fallback; + } +}; + +const baseSchema = baseRule.meta.schema as { + anyOf: [ + unknown, + { + type: 'array'; + items: [ + { + type: 'object'; + properties: { + paths: { + type: 'array'; + items: { + anyOf: [ + { type: 'string' }, + { + type: 'object'; + properties: JSONSchema4['properties']; + required: string[]; + }, + ]; + }; + }; + patterns: { + anyOf: [ + { type: 'array'; items: { type: 'string' } }, + { + type: 'array'; + items: { + type: 'object'; + properties: JSONSchema4['properties']; + required: string[]; + }; + }, + ]; + }; + }; + }, + ]; + }, + ]; +}; + +const allowTypeImportsOptionSchema: JSONSchema4['properties'] = { allowTypeImports: { type: 'boolean', - default: false, }, }; -const schemaForMergeArrayOfStringsOrObjects = { + +const arrayOfStringsOrObjects: JSONSchema4 = { + type: 'array', items: { anyOf: [ - {}, + { type: 'string' }, { - properties: allowTypeImportsOptionSchema, + type: 'object', + properties: { + ...tryAccess( + () => + baseSchema.anyOf[1].items[0].properties.paths.items.anyOf[1] + .properties, + undefined, + ), + ...allowTypeImportsOptionSchema, + }, + required: tryAccess( + () => + baseSchema.anyOf[1].items[0].properties.paths.items.anyOf[1] + .required, + undefined, + ), }, ], }, + uniqueItems: true, }; -const schemaForMergeArrayOfStringsOrObjectPatterns = { + +const arrayOfStringsOrObjectPatterns: JSONSchema4 = { anyOf: [ - {}, { + type: 'array', items: { - properties: allowTypeImportsOptionSchema, + type: 'string', }, + uniqueItems: true, + }, + { + type: 'array', + items: { + type: 'object', + properties: { + ...tryAccess( + () => + baseSchema.anyOf[1].items[0].properties.patterns.anyOf[1].items + .properties, + undefined, + ), + ...allowTypeImportsOptionSchema, + }, + required: tryAccess( + () => + baseSchema.anyOf[1].items[0].properties.patterns.anyOf[1].items + .required, + [], + ), + }, + uniqueItems: true, }, ], }; -const schema = deepMerge( - { ...baseRule.meta.schema }, - { - anyOf: [ - schemaForMergeArrayOfStringsOrObjects, - { - items: { + +const schema: JSONSchema4 = { + anyOf: [ + arrayOfStringsOrObjects, + { + type: 'array', + items: [ + { + type: 'object', properties: { - paths: schemaForMergeArrayOfStringsOrObjects, - patterns: schemaForMergeArrayOfStringsOrObjectPatterns, + paths: arrayOfStringsOrObjects, + patterns: arrayOfStringsOrObjectPatterns, }, + additionalProperties: false, }, - }, - ], - }, -); + ], + additionalItems: false, + }, + ], +}; function isObjectOfPaths( obj: unknown, diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts index 38248f311235..4d311a3a8cb0 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts @@ -37,6 +37,7 @@ export default util.createRule({ schema: [ { type: 'object', + additionalProperties: false, properties: { typesToIgnore: { description: 'A list of type names to ignore.', diff --git a/packages/eslint-plugin/src/rules/parameter-properties.ts b/packages/eslint-plugin/src/rules/parameter-properties.ts index 32547d9650fc..2c2d18484205 100644 --- a/packages/eslint-plugin/src/rules/parameter-properties.ts +++ b/packages/eslint-plugin/src/rules/parameter-properties.ts @@ -52,22 +52,21 @@ export default util.createRule({ ], }, }, - prefixItems: [ + items: [ { type: 'object', + additionalProperties: false, properties: { allow: { type: 'array', items: { $ref: '#/$defs/modifier', }, - minItems: 1, }, prefer: { enum: ['class-property', 'parameter-property'], }, }, - additionalProperties: false, }, ], type: 'array', diff --git a/packages/eslint-plugin/src/rules/prefer-readonly.ts b/packages/eslint-plugin/src/rules/prefer-readonly.ts index 3a9e6cdca660..8a7aca65d62d 100644 --- a/packages/eslint-plugin/src/rules/prefer-readonly.ts +++ b/packages/eslint-plugin/src/rules/prefer-readonly.ts @@ -36,13 +36,13 @@ export default util.createRule({ }, schema: [ { - allowAdditionalProperties: false, + type: 'object', + additionalProperties: false, properties: { onlyInlineLambdas: { type: 'boolean', }, }, - type: 'object', }, ], type: 'suggestion', diff --git a/packages/eslint-plugin/src/rules/require-array-sort-compare.ts b/packages/eslint-plugin/src/rules/require-array-sort-compare.ts index 1a9a8b795654..84c3cd92c7a2 100644 --- a/packages/eslint-plugin/src/rules/require-array-sort-compare.ts +++ b/packages/eslint-plugin/src/rules/require-array-sort-compare.ts @@ -31,6 +31,7 @@ export default util.createRule({ schema: [ { type: 'object', + additionalProperties: false, properties: { ignoreStringArrays: { description: diff --git a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts index 485ba42378bc..e465f3b13651 100644 --- a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts +++ b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts @@ -33,6 +33,7 @@ export default util.createRule({ schema: [ { type: 'object', + additionalProperties: false, properties: { allowNumber: { description: diff --git a/packages/eslint-plugin/src/rules/sort-type-constituents.ts b/packages/eslint-plugin/src/rules/sort-type-constituents.ts index 848b2ce0722e..b1159cef8ad9 100644 --- a/packages/eslint-plugin/src/rules/sort-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/sort-type-constituents.ts @@ -124,6 +124,7 @@ export default util.createRule({ schema: [ { type: 'object', + additionalProperties: false, properties: { checkIntersections: { description: 'Whether to check intersection types.', diff --git a/packages/eslint-plugin/src/rules/sort-type-union-intersection-members.ts b/packages/eslint-plugin/src/rules/sort-type-union-intersection-members.ts index cbfa7a515940..c4e04b75d2e4 100644 --- a/packages/eslint-plugin/src/rules/sort-type-union-intersection-members.ts +++ b/packages/eslint-plugin/src/rules/sort-type-union-intersection-members.ts @@ -126,6 +126,7 @@ export default util.createRule({ schema: [ { type: 'object', + additionalProperties: false, properties: { checkIntersections: { description: 'Whether to check intersection types.', diff --git a/packages/eslint-plugin/src/rules/typedef.ts b/packages/eslint-plugin/src/rules/typedef.ts index dd1f6ed871b8..d9a0b8dbce6f 100644 --- a/packages/eslint-plugin/src/rules/typedef.ts +++ b/packages/eslint-plugin/src/rules/typedef.ts @@ -32,6 +32,7 @@ export default util.createRule<[Options], MessageIds>({ schema: [ { type: 'object', + additionalProperties: false, properties: { [OptionKeys.ArrayDestructuring]: { type: 'boolean' }, [OptionKeys.ArrowParameter]: { type: 'boolean' }, diff --git a/packages/eslint-plugin/tests/areOptionsValid.test.ts b/packages/eslint-plugin/tests/areOptionsValid.test.ts new file mode 100644 index 000000000000..1efdfabb0ae3 --- /dev/null +++ b/packages/eslint-plugin/tests/areOptionsValid.test.ts @@ -0,0 +1,34 @@ +import * as util from '../src/util'; +import { areOptionsValid } from './areOptionsValid'; + +const exampleRule = util.createRule<['value-a' | 'value-b'], never>({ + name: 'space-infix-ops', + meta: { + type: 'layout', + docs: { + description: 'Require spacing around infix operators', + recommended: false, + extendsBaseRule: true, + }, + schema: [{ enum: ['value-a', 'value-b'] }], + messages: {}, + }, + defaultOptions: ['value-a'], + create() { + return {}; + }, +}); + +test('returns true for valid options', () => { + expect(areOptionsValid(exampleRule, ['value-a'])).toBe(true); +}); + +describe('returns false for invalid options', () => { + test('bad enum value', () => { + expect(areOptionsValid(exampleRule, ['value-c'])).toBe(false); + }); + + test('bad type', () => { + expect(areOptionsValid(exampleRule, [true])).toBe(false); + }); +}); diff --git a/packages/eslint-plugin/tests/areOptionsValid.ts b/packages/eslint-plugin/tests/areOptionsValid.ts new file mode 100644 index 000000000000..e67a582d28c5 --- /dev/null +++ b/packages/eslint-plugin/tests/areOptionsValid.ts @@ -0,0 +1,43 @@ +import type { RuleModule } from '@typescript-eslint/utils/src/ts-eslint'; +import Ajv from 'ajv'; +import type { JSONSchema4 } from 'json-schema'; + +const ajv = new Ajv({ async: false }); + +export function areOptionsValid( + rule: RuleModule, + options: unknown, +): boolean { + const normalizedSchema = normalizeSchema(rule.meta.schema); + + const valid = ajv.validate(normalizedSchema, options); + if (typeof valid !== 'boolean') { + // Schema could not validate options synchronously. This is not allowed for ESLint rules. + return false; + } + + return valid; +} + +function normalizeSchema( + schema: JSONSchema4 | readonly JSONSchema4[], +): JSONSchema4 { + if (!Array.isArray(schema)) { + return schema; + } + + if (schema.length === 0) { + return { + type: 'array', + minItems: 0, + maxItems: 0, + }; + } + + return { + type: 'array', + items: schema, + minItems: 0, + maxItems: schema.length, + }; +} diff --git a/packages/eslint-plugin/tests/rules/array-type.test.ts b/packages/eslint-plugin/tests/rules/array-type.test.ts index 04ab47d0b8a0..0e0e6a992649 100644 --- a/packages/eslint-plugin/tests/rules/array-type.test.ts +++ b/packages/eslint-plugin/tests/rules/array-type.test.ts @@ -3,6 +3,7 @@ import { TSESLint } from '@typescript-eslint/utils'; import type { OptionString } from '../../src/rules/array-type'; import rule from '../../src/rules/array-type'; +import { areOptionsValid } from '../areOptionsValid'; import { RuleTester } from '../RuleTester'; const ruleTester = new RuleTester({ @@ -2156,3 +2157,19 @@ type BrokenArray = { ); }); }); + +describe('schema validation', () => { + // https://github.com/typescript-eslint/typescript-eslint/issues/6852 + test("array-type does not accept 'simple-array' option", () => { + if (areOptionsValid(rule, [{ default: 'simple-array' }])) { + throw new Error(`Options succeeded validation for bad options`); + } + }); + + // https://github.com/typescript-eslint/typescript-eslint/issues/6892 + test('array-type does not accept non object option', () => { + if (areOptionsValid(rule, ['array'])) { + throw new Error(`Options succeeded validation for bad options`); + } + }); +}); diff --git a/packages/eslint-plugin/tests/schemas.test.ts b/packages/eslint-plugin/tests/schemas.test.ts new file mode 100644 index 000000000000..86e6b32a1a93 --- /dev/null +++ b/packages/eslint-plugin/tests/schemas.test.ts @@ -0,0 +1,26 @@ +import eslintPlugin from '../src'; +import { areOptionsValid } from './areOptionsValid'; + +describe("Validating rule's schemas", () => { + // These two have defaults which cover multiple arguments that are incompatible + const overrideOptions: Record = { + semi: ['never'], + 'func-call-spacing': ['never'], + }; + + for (const [ruleName, rule] of Object.entries(eslintPlugin.rules)) { + test(`${ruleName} must accept valid arguments`, () => { + if ( + !areOptionsValid(rule, overrideOptions[ruleName] ?? rule.defaultOptions) + ) { + throw new Error(`Options failed validation against rule's schema`); + } + }); + + test(`${ruleName} rejects arbitrary arguments`, () => { + if (areOptionsValid(rule, [{ 'arbitrary-schemas.test.ts': true }])) { + throw new Error(`Options succeeded validation for arbitrary options`); + } + }); + } +}); diff --git a/packages/utils/src/ts-eslint/Rule.ts b/packages/utils/src/ts-eslint/Rule.ts index ff3053863219..2b9b5c3f1be0 100644 --- a/packages/utils/src/ts-eslint/Rule.ts +++ b/packages/utils/src/ts-eslint/Rule.ts @@ -74,8 +74,11 @@ interface RuleMetaData { replacedBy?: readonly string[]; /** * The options schema. Supply an empty array if there are no options. + * $ref is not supported in arrays, so is omitted. + * See: https://github.com/typescript-eslint/typescript-eslint/pull/5531 + * See: https://eslint.org/docs/latest/extend/custom-rules#options-schemas */ - schema: JSONSchema4 | readonly JSONSchema4[]; + schema: JSONSchema4 | readonly Omit[]; } interface RuleFix { diff --git a/packages/website/plugins/generated-rule-docs.ts b/packages/website/plugins/generated-rule-docs.ts index 2e5a32587003..aecfaf897b54 100644 --- a/packages/website/plugins/generated-rule-docs.ts +++ b/packages/website/plugins/generated-rule-docs.ts @@ -265,7 +265,9 @@ export const generatedRuleDocs: Plugin = () => { ...(meta.schema.$defs ? { $defs: (meta.schema as JSONSchema7).$defs } : {}), - ...(meta.schema.prefixItems as [JSONSchema])[0], + ...(meta.schema.items + ? (meta.schema.items[0] as JSONSchema) + : undefined), } : meta.schema; diff --git a/tests/integration/integration-test-base.ts b/tests/integration/integration-test-base.ts index f49a4e90a232..a460ec563cb9 100644 --- a/tests/integration/integration-test-base.ts +++ b/tests/integration/integration-test-base.ts @@ -100,6 +100,7 @@ export function integrationTest(testFilename: string, filesGlob: string): void { // lint, outputting to a JSON file const outFile = await tmpFile(); + let stderr = ''; try { await execFile( 'yarn', @@ -120,6 +121,11 @@ export function integrationTest(testFilename: string, filesGlob: string): void { ); } catch (ex) { // we expect eslint will "fail" because we have intentional lint errors + + // useful for debugging + if (typeof ex === 'object' && ex != null && 'stderr' in ex) { + stderr = String(ex.stderr); + } } // console.log('Lint complete.'); @@ -134,7 +140,9 @@ export function integrationTest(testFilename: string, filesGlob: string): void { const lintOutput = JSON.parse(lintOutputRAW); expect(lintOutput).toMatchSnapshot(); } catch { - throw lintOutputRAW; + throw new Error( + `Lint output could not be parsed as JSON: \`${lintOutputRAW}\`. The error logs from eslint were: \`${stderr}\``, + ); } }); diff --git a/yarn.lock b/yarn.lock index 62ada2f23453..6ae4be438b34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4302,7 +4302,7 @@ ajv-keywords@^5.0.0: dependencies: fast-deep-equal "^3.1.3" -ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5, ajv@~6.12.6: +ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5, ajv@^6.12.6, ajv@~6.12.6: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==