mirror of
https://github.com/pezkuwichain/pezkuwi-dev.git
synced 2026-11-09 08:47:07 +00:00
consistent-type-exports: offer the export * fix as a suggestion
`export *` loads the module; `export type *` is erased. The autofix rewrote one into the other whenever a module exported only types, so a module that registers something when loaded stopped being loaded (the api typegen lost its Handlebars partial this way). The report stays an error; the rewrite is now a suggestion, which --fix does not apply. Named re-exports are unaffected: under verbatimModuleSyntax their type-only form is already a compile error.
This commit is contained in:
1 parent
aa231ca6b9
commit
e972491c5e
3 files changed
+146
-2
No files matched your search
@@ -25,6 +25,7 @@ import globals from 'globals';
|
|||||||
import { overrideAll, overrideJs, overrideJsx, overrideSpec } from './eslint.rules.js';
|
import { overrideAll, overrideJs, overrideJsx, overrideSpec } from './eslint.rules.js';
|
||||||
import { standardRules } from './eslint.standard.js';
|
import { standardRules } from './eslint.standard.js';
|
||||||
import { withoutContextualAssertions } from './eslint.typeAssertion.js';
|
import { withoutContextualAssertions } from './eslint.typeAssertion.js';
|
||||||
|
import { withoutExportAllFix } from './eslint.typeExports.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* eslint-plugin-header declares no options schema, and eslint 9+ rejects
|
* eslint-plugin-header declares no options schema, and eslint 9+ rejects
|
||||||
@@ -94,8 +95,8 @@ export default [
|
|||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'@stylistic': stylisticPlugin,
|
'@stylistic': stylisticPlugin,
|
||||||
// see eslint.typeAssertion.js
|
// see eslint.typeAssertion.js and eslint.typeExports.js
|
||||||
'@typescript-eslint': withoutContextualAssertions(tsPlugin),
|
'@typescript-eslint': withoutExportAllFix(withoutContextualAssertions(tsPlugin)),
|
||||||
// eslint-plugin-header reports at the first token after the leading
|
// eslint-plugin-header reports at the first token after the leading
|
||||||
// comments, so a generated file's /* eslint-disable */ covers it; the
|
// comments, so a generated file's /* eslint-disable */ covers it; the
|
||||||
// maintained fork reports at line 1 instead. The compat layer supplies the
|
// maintained fork reports at line 1 instead. The compat layer supplies the
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Copyright 2017-2026 @pezkuwi/dev authors & contributors
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
const RULE = 'consistent-type-exports';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* consistent-type-exports reports an `export * from './x'` whose module only
|
||||||
|
* exports types, and its autofix turns it into `export type * from './x'`.
|
||||||
|
* That changes what runs: `export *` loads the module, `export type *` is
|
||||||
|
* erased, so a module that only exports types but registers something when it
|
||||||
|
* loads (a Handlebars partial, an interface augmentation) silently stops being
|
||||||
|
* loaded. Measured on the consumers: the api typegen lost its `docs` partial
|
||||||
|
* this way and the build failed only at template time.
|
||||||
|
*
|
||||||
|
* Named re-exports do not have this problem: under verbatimModuleSyntax a
|
||||||
|
* type re-exported without `type` is already a compile error (TS1205).
|
||||||
|
*
|
||||||
|
* This keeps the report as it is and moves the `export *` fix into a
|
||||||
|
* suggestion, so --fix no longer applies it and an editor still offers it.
|
||||||
|
* Whoever accepts it decides whether the module needs a side-effect import.
|
||||||
|
*
|
||||||
|
* @template {object} P
|
||||||
|
* @param {P} plugin the @typescript-eslint plugin
|
||||||
|
* @returns {P}
|
||||||
|
*/
|
||||||
|
export function withoutExportAllFix (plugin) {
|
||||||
|
const rules = /** @type {Record<string, import('eslint').Rule.RuleModule>} */ (/** @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 ('node' in descriptor && descriptor.node.type === 'ExportAllDeclaration' && descriptor.fix) {
|
||||||
|
const { fix, ...rest } = descriptor;
|
||||||
|
|
||||||
|
context.report({
|
||||||
|
...rest,
|
||||||
|
suggest: [{ desc: 'Use `export type *` (the module is no longer loaded: add a side-effect import if it registers anything)', fix }]
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.report(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rule.create(filtered);
|
||||||
|
},
|
||||||
|
meta: { ...rule.meta, hasSuggestions: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Copyright 2017-2026 @pezkuwi/dev authors & contributors
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||||
|
|
||||||
|
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.typeExports.js');
|
||||||
|
|
||||||
|
// only types, but loading it has an effect the importer relies on
|
||||||
|
const TYPES = `export interface Partial { name: string }
|
||||||
|
|
||||||
|
(globalThis as Record<string, unknown>)['registered'] = true;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SOURCE = `export * from './types.js';
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface Result { fixed: string; messages: { fix: boolean; line: number; messageId: string; suggestions: number }[] }
|
||||||
|
|
||||||
|
async function lint (plugin: ESLintType.Plugin): Promise<Result> {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'type-exports-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify({ compilerOptions: { module: 'esnext', moduleResolution: 'bundler', strict: true, target: 'es2022' }, include: ['*.ts'] }));
|
||||||
|
fs.writeFileSync(path.join(dir, 'types.ts'), TYPES);
|
||||||
|
fs.writeFileSync(path.join(dir, 'index.ts'), SOURCE);
|
||||||
|
|
||||||
|
const options = (fix: boolean): ESLintType.Options => ({
|
||||||
|
cwd: dir,
|
||||||
|
fix,
|
||||||
|
overrideConfig: [{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: { project: './tsconfig.json', tsconfigRootDir: dir }
|
||||||
|
},
|
||||||
|
plugins: { '@typescript-eslint': plugin },
|
||||||
|
rules: { '@typescript-eslint/consistent-type-exports': 'error' }
|
||||||
|
}],
|
||||||
|
overrideConfigFile: true
|
||||||
|
});
|
||||||
|
const [reported] = await new ESLint(options(false)).lintFiles(['index.ts']);
|
||||||
|
const [fixed] = await new ESLint(options(true)).lintFiles(['index.ts']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fixed: fixed.output ?? SOURCE,
|
||||||
|
messages: reported.messages.map(({ fix, line, messageId, suggestions }) => ({ fix: !!fix, line, messageId: messageId ?? '', suggestions: suggestions?.length ?? 0 }))
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('withoutExportAllFix', (): void => {
|
||||||
|
it('keeps the report on a types-only export *, offers the change only as a suggestion', async (): Promise<void> => {
|
||||||
|
const { withoutExportAllFix } = await import(WRAPPER) as { withoutExportAllFix: (p: ESLintType.Plugin) => ESLintType.Plugin };
|
||||||
|
|
||||||
|
expect(await lint(withoutExportAllFix(tsPlugin as unknown as ESLintType.Plugin))).toEqual({
|
||||||
|
fixed: SOURCE,
|
||||||
|
messages: [{ fix: false, line: 1, messageId: 'typeOverValue', suggestions: 1 }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is needed: the plugin as published rewrites it to export type *, which no longer loads the module', async (): Promise<void> => {
|
||||||
|
expect(await lint(tsPlugin as unknown as ESLintType.Plugin)).toEqual({
|
||||||
|
fixed: "export type * from './types.js';\n",
|
||||||
|
messages: [{ fix: true, line: 1, messageId: 'typeOverValue', suggestions: 0 }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user