diff --git a/packages/dev/config/eslint.js b/packages/dev/config/eslint.js index 0bf0b35..ac36e78 100644 --- a/packages/dev/config/eslint.js +++ b/packages/dev/config/eslint.js @@ -24,6 +24,7 @@ import globals from 'globals'; import { overrideAll, overrideJs, overrideJsx, overrideSpec } from './eslint.rules.js'; import { standardRules } from './eslint.standard.js'; +import { withoutContextualAssertions } from './eslint.typeAssertion.js'; /** * eslint-plugin-header declares no options schema, and eslint 9+ rejects @@ -93,7 +94,8 @@ export default [ }, plugins: { '@stylistic': stylisticPlugin, - '@typescript-eslint': tsPlugin, + // see eslint.typeAssertion.js + '@typescript-eslint': withoutContextualAssertions(tsPlugin), // eslint-plugin-header reports at the first token after the leading // comments, so a generated file's /* eslint-disable */ covers it; the // maintained fork reports at line 1 instead. The compat layer supplies the diff --git a/packages/dev/config/eslint.typeAssertion.js b/packages/dev/config/eslint.typeAssertion.js new file mode 100644 index 0000000..3fe184a --- /dev/null +++ b/packages/dev/config/eslint.typeAssertion.js @@ -0,0 +1,65 @@ +// Copyright 2017-2026 @pezkuwi/dev authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +const RULE = 'no-unnecessary-type-assertion'; +const ASSERTIONS = ['TSAsExpression', 'TSTypeAssertion']; + +/** + * typescript-eslint 8 added a second check to no-unnecessary-type-assertion: + * an `x as T` is reported as unnecessary when the place it is passed to would + * accept `x` as it is ("the receiver accepts the original type"), and the + * autofix deletes the assertion. That is unsound when the assertion also + * drives inference around it: in + * + * const { validators } = found || { validators: {} as ValPoints }; + * + * deleting `as ValPoints` leaves `validators` typed `{}`, and every use of it + * after that is an unresolved type. It also deletes assertions kept on + * purpose for another environment's typings (a Ledger transport under + * esm.sh). Measured across the consumers before this was added: 45 such + * reports, the first of them breaking the types of api-derive. + * + * This keeps the rule as it was before that check: an assertion to the type + * the expression already has is still reported and fixed, and so are non-null + * assertions the receiver makes unnecessary. Only the contextual report on + * `as` / `` assertions is dropped. + * + * @template {object} P + * @param {P} plugin the @typescript-eslint plugin + * @returns {P} + */ +export function withoutContextualAssertions (plugin) { + const rules = /** @type {Record} */ (/** @type {{ rules: unknown }} */ (plugin).rules); + const rule = rules[RULE]; + + return /** @type {P} */ ({ + ...plugin, + rules: { + ...rules, + [RULE]: { + ...rule, + /** @param {import('eslint').Rule.RuleContext} context */ + create (context) { + const filtered = Object.create(context, { + report: { + value: (/** @type {import('eslint').Rule.ReportDescriptor} */ descriptor) => { + if ( + 'messageId' in descriptor && + descriptor.messageId === 'contextuallyUnnecessary' && + 'node' in descriptor && + ASSERTIONS.includes(descriptor.node.type) + ) { + return; + } + + context.report(descriptor); + } + } + }); + + return rule.create(filtered); + } + } + } + }); +} diff --git a/packages/dev/src/typeAssertion.spec.ts b/packages/dev/src/typeAssertion.spec.ts new file mode 100644 index 0000000..bb7f5e6 --- /dev/null +++ b/packages/dev/src/typeAssertion.spec.ts @@ -0,0 +1,86 @@ +// Copyright 2017-2026 @pezkuwi/dev authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { ESLint as ESLintType } from 'eslint'; + +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import { ESLint } from 'eslint'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Loaded by path: the config directory is plain JavaScript outside this +// package's TypeScript root. +const WRAPPER = path.join(process.cwd(), 'packages/dev/config/eslint.typeAssertion.js'); + +const SOURCE = `interface ValPoints { [id: string]: number } + +declare const found: { validators: ValPoints } | undefined; +declare const text: string; +declare function take (value: string): void; + +// contextual: the assertion types \`validators\` below +const { validators } = found || { validators: {} as ValPoints }; + +export const first = validators['a']; + +// unchanged: the expression already has this type +export const same = text as string; + +let target: string | undefined; + +// contextual non-null: assigning to it needs no assertion +target! = text; + +take(target ?? ''); +`; + +async function lint (plugin: ESLintType.Plugin): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'type-assertion-')); + + try { + fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, target: 'es2022' }, include: ['*.ts'] })); + fs.writeFileSync(path.join(dir, 'sample.ts'), SOURCE); + + const eslint = new ESLint({ + cwd: dir, + overrideConfig: [{ + files: ['**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { project: './tsconfig.json', tsconfigRootDir: dir } + }, + plugins: { '@typescript-eslint': plugin }, + rules: { '@typescript-eslint/no-unnecessary-type-assertion': 'error' } + }], + overrideConfigFile: true + }); + const [result] = await eslint.lintFiles(['sample.ts']); + + return result.messages.map(({ line, messageId }) => `${line}:${messageId ?? ''}`); + } finally { + fs.rmSync(dir, { force: true, recursive: true }); + } +} + +describe('withoutContextualAssertions', (): void => { + it('drops the contextual report on an assertion, keeps the others', async (): Promise => { + const { withoutContextualAssertions } = await import(WRAPPER) as { withoutContextualAssertions: (p: ESLintType.Plugin) => ESLintType.Plugin }; + + expect(await lint(withoutContextualAssertions(tsPlugin as unknown as ESLintType.Plugin))).toEqual([ + '13:unnecessaryAssertion', + '18:contextuallyUnnecessary' + ]); + }); + + it('is needed: the plugin as published reports the assertion that types validators', async (): Promise => { + expect(await lint(tsPlugin as unknown as ESLintType.Plugin)).toEqual([ + '8:contextuallyUnnecessary', + '13:unnecessaryAssertion', + '18:contextuallyUnnecessary' + ]); + }); +});