diff --git a/packages/dev-test/package.json b/packages/dev-test/package.json index 010988f..7227ff3 100644 --- a/packages/dev-test/package.json +++ b/packages/dev-test/package.json @@ -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" diff --git a/packages/dev-test/src/packageInfo.ts b/packages/dev-test/src/packageInfo.ts index 359d24e..1225d35 100644 --- a/packages/dev-test/src/packageInfo.ts +++ b/packages/dev-test/src/packageInfo.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' }; diff --git a/packages/dev-ts/package.json b/packages/dev-ts/package.json index 8fd1b67..b70711e 100644 --- a/packages/dev-ts/package.json +++ b/packages/dev-ts/package.json @@ -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" diff --git a/packages/dev-ts/src/packageInfo.ts b/packages/dev-ts/src/packageInfo.ts index 6e133f6..4706747 100644 --- a/packages/dev-ts/src/packageInfo.ts +++ b/packages/dev-ts/src/packageInfo.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' }; diff --git a/packages/dev/config/eslint.directives.js b/packages/dev/config/eslint.directives.js new file mode 100644 index 0000000..1384fea --- /dev/null +++ b/packages/dev/config/eslint.directives.js @@ -0,0 +1,78 @@ +// Copyright 2017-2026 @pezkuwi/dev authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/** @type {Map} */ +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 +}; diff --git a/packages/dev/config/eslint.js b/packages/dev/config/eslint.js index ac36e78..82aea4f 100644 --- a/packages/dev/config/eslint.js +++ b/packages/dev/config/eslint.js @@ -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, diff --git a/packages/dev/config/eslint.typeExports.js b/packages/dev/config/eslint.typeExports.js new file mode 100644 index 0000000..1b3a6c0 --- /dev/null +++ b/packages/dev/config/eslint.typeExports.js @@ -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} */ (/** @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 } + } + } + }); +} diff --git a/packages/dev/package.json b/packages/dev/package.json index eff42da..030430e 100644 --- a/packages/dev/package.json +++ b/packages/dev/package.json @@ -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", diff --git a/packages/dev/scripts/pezkuwi-dev-run-lint.mjs b/packages/dev/scripts/pezkuwi-dev-run-lint.mjs index 3d6eb13..6f9de0a 100755 --- a/packages/dev/scripts/pezkuwi-dev-run-lint.mjs +++ b/packages/dev/scripts/pezkuwi-dev-run-lint.mjs @@ -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'); } diff --git a/packages/dev/src/directives.spec.ts b/packages/dev/src/directives.spec.ts new file mode 100644 index 0000000..d5faad6 --- /dev/null +++ b/packages/dev/src/directives.spec.ts @@ -0,0 +1,95 @@ +// Copyright 2017-2026 @pezkuwi/dev authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +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 { + 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 => { + 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 => { + 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);', + '' + ]); + }); +}); diff --git a/packages/dev/src/packageInfo.ts b/packages/dev/src/packageInfo.ts index 616e00f..f38d5a4 100644 --- a/packages/dev/src/packageInfo.ts +++ b/packages/dev/src/packageInfo.ts @@ -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' }; diff --git a/packages/dev/src/typeExports.spec.ts b/packages/dev/src/typeExports.spec.ts new file mode 100644 index 0000000..1bf9f4a --- /dev/null +++ b/packages/dev/src/typeExports.spec.ts @@ -0,0 +1,80 @@ +// 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.typeExports.js'); + +// only types, but loading it has an effect the importer relies on +const TYPES = `export interface Partial { name: string } + +(globalThis as Record)['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 { + 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 => { + 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 => { + 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 }] + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8fd139f..7099614 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"