mirror of
https://github.com/pezkuwichain/pezkuwi-dev.git
synced 2026-10-22 03:36:18 +00:00
Merge pull request #16 from pezkuwichain/fix/lint-tsc-exports-directives
Lint: real type check, no export-all rewrite, whole-line directive removal (0.86.4)
This commit is contained in:
13 files changed
+341
-17
No files matched your search
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"version": "0.86.3",
|
||||
"version": "0.86.4",
|
||||
"main": "./index.js",
|
||||
"exports": {
|
||||
"./globals.d.ts": "./src/globals.d.ts"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
|
||||
// Do not edit, auto-generated by @pezkuwi/dev
|
||||
|
||||
export const packageInfo = { name: '@pezkuwi/dev-test', path: 'auto', type: 'auto', version: '0.86.3' };
|
||||
export const packageInfo = { name: '@pezkuwi/dev-test', path: 'auto', type: 'auto', version: '0.86.4' };
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"version": "0.86.3",
|
||||
"version": "0.86.4",
|
||||
"main": "./index.js",
|
||||
"exports": {
|
||||
"./globals.d.ts": "./src/globals.d.ts"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
|
||||
// Do not edit, auto-generated by @pezkuwi/dev
|
||||
|
||||
export const packageInfo = { name: '@pezkuwi/dev-ts', path: 'auto', type: 'auto', version: '0.86.3' };
|
||||
export const packageInfo = { name: '@pezkuwi/dev-ts', path: 'auto', type: 'auto', version: '0.86.4' };
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright 2017-2026 @pezkuwi/dev authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/** @type {Map<string, string>} */
|
||||
const sources = new Map();
|
||||
|
||||
/**
|
||||
* Widens the fix eslint core gives an unused disable directive.
|
||||
*
|
||||
* Core removes only the comment and puts a single space in its place
|
||||
* (lib/linter/apply-disable-directives.js), so --fix turns
|
||||
*
|
||||
* const a = 1;
|
||||
* // eslint-disable-next-line no-console
|
||||
* const b = 2;
|
||||
*
|
||||
* into a whitespace-only line between the two statements, which the layout
|
||||
* rules then leave behind as a blank line. Measured on the consumers after the
|
||||
* eslint 10 move: 23 such lines in api alone, each removed by hand.
|
||||
*
|
||||
* Only a fix that removes a whole directive comment is changed: alone on its
|
||||
* line, the line goes; after code, the space before it goes; before code, the
|
||||
* space after it goes. Removing one rule name out of several is left as core
|
||||
* makes it, as is every message with a rule id.
|
||||
*
|
||||
* @param {import('eslint').Linter.LintMessage} message
|
||||
* @param {string} text the source the message was produced from
|
||||
* @returns {import('eslint').Linter.LintMessage}
|
||||
*/
|
||||
function widenDirectiveFix (message, text) {
|
||||
if (message.ruleId !== null || message.fix?.text.trim() !== '') {
|
||||
return message;
|
||||
}
|
||||
|
||||
let [start, end] = message.fix.range;
|
||||
|
||||
if (!/^\/[/*]/.test(text.slice(start, end))) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||
const newline = text.indexOf('\n', end);
|
||||
const lineEnd = newline === -1 ? text.length : newline;
|
||||
const aloneBefore = text.slice(lineStart, start).trim() === '';
|
||||
const aloneAfter = text.slice(end, lineEnd).trim() === '';
|
||||
|
||||
if (aloneBefore && aloneAfter) {
|
||||
return { ...message, fix: { range: [lineStart, newline === -1 ? text.length : newline + 1], text: '' } };
|
||||
} else if (aloneBefore) {
|
||||
while (end < lineEnd && /[ \t]/.test(text[end] ?? '')) {
|
||||
end++;
|
||||
}
|
||||
} else {
|
||||
while (start > lineStart && /[ \t]/.test(text[start - 1] ?? '')) {
|
||||
start--;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...message, fix: { range: [start, end], text: '' } };
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Linter.Processor} */
|
||||
export const directiveLines = {
|
||||
meta: { name: '@pezkuwi/dev/directive-lines' },
|
||||
postprocess (lists, filename) {
|
||||
const text = sources.get(filename) ?? '';
|
||||
|
||||
sources.delete(filename);
|
||||
|
||||
return lists.flat().map((m) => widenDirectiveFix(m, text));
|
||||
},
|
||||
preprocess (text, filename) {
|
||||
sources.set(filename, text);
|
||||
|
||||
return [text];
|
||||
},
|
||||
supportsAutofix: true
|
||||
};
|
||||
@@ -22,9 +22,11 @@ import simpleImportSortPlugin from 'eslint-plugin-simple-import-sort';
|
||||
import sortDestructureKeysPlugin from 'eslint-plugin-sort-destructure-keys';
|
||||
import globals from 'globals';
|
||||
|
||||
import { directiveLines } from './eslint.directives.js';
|
||||
import { overrideAll, overrideJs, overrideJsx, overrideSpec } from './eslint.rules.js';
|
||||
import { standardRules } from './eslint.standard.js';
|
||||
import { withoutContextualAssertions } from './eslint.typeAssertion.js';
|
||||
import { withoutExportAllFix } from './eslint.typeExports.js';
|
||||
|
||||
/**
|
||||
* eslint-plugin-header declares no options schema, and eslint 9+ rejects
|
||||
@@ -94,8 +96,8 @@ export default [
|
||||
},
|
||||
plugins: {
|
||||
'@stylistic': stylisticPlugin,
|
||||
// see eslint.typeAssertion.js
|
||||
'@typescript-eslint': withoutContextualAssertions(tsPlugin),
|
||||
// see eslint.typeAssertion.js and eslint.typeExports.js
|
||||
'@typescript-eslint': withoutExportAllFix(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
|
||||
@@ -126,6 +128,8 @@ export default [
|
||||
},
|
||||
{
|
||||
files: extsToGlobs(EXT_ALL),
|
||||
// see eslint.directives.js
|
||||
processor: directiveLines,
|
||||
rules: {
|
||||
...eslintJs.configs.recommended.rules,
|
||||
...standardRules,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"version": "0.86.3",
|
||||
"version": "0.86.4",
|
||||
"bin": {
|
||||
"pezkuwi-ci-ghact-build": "./scripts/pezkuwi-ci-ghact-build.mjs",
|
||||
"pezkuwi-ci-ghact-docs": "./scripts/pezkuwi-ci-ghact-docs.mjs",
|
||||
@@ -52,8 +52,8 @@
|
||||
"dependencies": {
|
||||
"@eslint/compat": "^2.1.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@pezkuwi/dev-test": "^0.86.3",
|
||||
"@pezkuwi/dev-ts": "^0.86.3",
|
||||
"@pezkuwi/dev-test": "^0.86.4",
|
||||
"@pezkuwi/dev-ts": "^0.86.4",
|
||||
"@rollup/plugin-alias": "^5.1.1",
|
||||
"@rollup/plugin-commonjs": "^29.0.3",
|
||||
"@rollup/plugin-dynamic-import-vars": "^2.1.5",
|
||||
|
||||
@@ -7,8 +7,6 @@ import yargs from 'yargs';
|
||||
|
||||
import { __dirname, execPm, GITHUB_REPO, logBin } from './util.mjs';
|
||||
|
||||
const TS_CONFIG_BUILD = true;
|
||||
|
||||
logBin('pezkuwi-dev-run-lint');
|
||||
|
||||
// Since yargs can also be a promise, we just relax the type here completely
|
||||
@@ -36,5 +34,11 @@ if (!argv['skip-eslint']) {
|
||||
}
|
||||
|
||||
if (!argv['skip-tsc']) {
|
||||
execPm(`pezkuwi-exec-tsc --noEmit --emitDeclarationOnly false --pretty${TS_CONFIG_BUILD ? ' --project tsconfig.build.json' : ''}`);
|
||||
// The root tsconfig.build.json has `files: []` and only references the
|
||||
// packages, so `tsc --project` on it checks no file at all. --build follows
|
||||
// the references; it cannot be combined with --noEmit, so it writes the
|
||||
// declarations the build would. Previous build output is removed first, since
|
||||
// package configs without an `include` would otherwise pick it up as source.
|
||||
execPm('pezkuwi-dev-clean-build');
|
||||
execPm('pezkuwi-exec-tsc --build tsconfig.build.json --pretty');
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2017-2026 @pezkuwi/dev authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import type { Linter } from 'eslint';
|
||||
|
||||
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 PROCESSOR = path.join(process.cwd(), 'packages/dev/config/eslint.directives.js');
|
||||
|
||||
const SOURCE = `const a = 1;
|
||||
// eslint-disable-next-line no-console
|
||||
const b = 2;
|
||||
|
||||
function f () {
|
||||
// eslint-disable-next-line no-console
|
||||
return a + b;
|
||||
}
|
||||
|
||||
f(); // eslint-disable-line no-console
|
||||
/* eslint-disable-next-line no-console */ export const c = 3;
|
||||
// eslint-disable-next-line no-alert, no-console
|
||||
console.log(c);
|
||||
`;
|
||||
|
||||
const FIXED = `const a = 1;
|
||||
const b = 2;
|
||||
|
||||
function f () {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
f();
|
||||
export const c = 3;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(c);
|
||||
`;
|
||||
|
||||
async function fix (processor?: Linter.Processor): Promise<string> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'directives-'));
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'sample.js'), SOURCE);
|
||||
|
||||
const eslint = new ESLint({
|
||||
cwd: dir,
|
||||
fix: true,
|
||||
overrideConfig: [{
|
||||
files: ['**/*.js'],
|
||||
linterOptions: { reportUnusedDisableDirectives: 'error' },
|
||||
...(processor ? { processor } : {}),
|
||||
rules: { 'no-alert': 'error', 'no-console': 'error' }
|
||||
}],
|
||||
overrideConfigFile: true
|
||||
});
|
||||
const [result] = await eslint.lintFiles(['sample.js']);
|
||||
|
||||
return result.output ?? SOURCE;
|
||||
} finally {
|
||||
fs.rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('directiveLines', (): void => {
|
||||
it('removes an unused directive with its line or the space around it', async (): Promise<void> => {
|
||||
const { directiveLines } = await import(PROCESSOR) as { directiveLines: Linter.Processor };
|
||||
|
||||
expect(await fix(directiveLines)).toEqual(FIXED);
|
||||
});
|
||||
|
||||
it('is needed: core leaves a space where each comment was', async (): Promise<void> => {
|
||||
expect((await fix()).split('\n')).toEqual([
|
||||
'const a = 1;',
|
||||
' ',
|
||||
'const b = 2;',
|
||||
'',
|
||||
'function f () {',
|
||||
' ',
|
||||
' return a + b;',
|
||||
'}',
|
||||
'',
|
||||
'f(); ',
|
||||
' export const c = 3;',
|
||||
'// eslint-disable-next-line no-console',
|
||||
'console.log(c);',
|
||||
''
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,4 +3,4 @@
|
||||
|
||||
// Do not edit, auto-generated by @pezkuwi/dev
|
||||
|
||||
export const packageInfo = { name: '@pezkuwi/dev', path: 'auto', type: 'auto', version: '0.86.3' };
|
||||
export const packageInfo = { name: '@pezkuwi/dev', path: 'auto', type: 'auto', version: '0.86.4' };
|
||||
@@ -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 }]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1198,7 +1198,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@pezkuwi/dev-test@npm:^0.86.3, @pezkuwi/dev-test@workspace:packages/dev-test":
|
||||
"@pezkuwi/dev-test@npm:^0.86.4, @pezkuwi/dev-test@workspace:packages/dev-test":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@pezkuwi/dev-test@workspace:packages/dev-test"
|
||||
dependencies:
|
||||
@@ -1214,7 +1214,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@pezkuwi/dev-ts@npm:^0.86.3, @pezkuwi/dev-ts@workspace:packages/dev-ts":
|
||||
"@pezkuwi/dev-ts@npm:^0.86.4, @pezkuwi/dev-ts@workspace:packages/dev-ts":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@pezkuwi/dev-ts@workspace:packages/dev-ts"
|
||||
dependencies:
|
||||
@@ -1230,8 +1230,8 @@ __metadata:
|
||||
dependencies:
|
||||
"@eslint/compat": "npm:^2.1.1"
|
||||
"@eslint/js": "npm:^10.0.1"
|
||||
"@pezkuwi/dev-test": "npm:^0.86.3"
|
||||
"@pezkuwi/dev-ts": "npm:^0.86.3"
|
||||
"@pezkuwi/dev-test": "npm:^0.86.4"
|
||||
"@pezkuwi/dev-ts": "npm:^0.86.4"
|
||||
"@rollup/plugin-alias": "npm:^5.1.1"
|
||||
"@rollup/plugin-commonjs": "npm:^29.0.3"
|
||||
"@rollup/plugin-dynamic-import-vars": "npm:^2.1.5"
|
||||
|
||||
Reference in new issue
Block a user