From 8b5799b65255fb4a6d42a48acb402b8aa939b737 Mon Sep 17 00:00:00 2001 From: mainframev Date: Sun, 16 Aug 2026 03:13:40 +0200 Subject: [PATCH 1/3] ci: add fork-only lint performance benchmark job --- .github/workflows/pr.yml | 116 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 85d83e704cf12e..b3dbc8c7bfdfb4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -207,3 +207,119 @@ jobs: apps/*/cypress/screenshots/**/*.png packages/**/cypress/screenshots/**/*.png retention-days: 1 + + # --------------------------------------------------------------------------- + # Lint performance benchmark (fork only). + # + # Measures the wall-clock cost of a full, uncached workspace lint so that the + # delta introduced by a new ESLint rule can be quantified. Runs only in the + # `mainframev` fork; every job above is gated on `microsoft` and therefore + # skips there, leaving this job the whole runner. + # --------------------------------------------------------------------------- + lint-benchmark: + name: Lint benchmark + if: ${{ github.repository_owner == 'mainframev' }} + runs-on: ubuntu-latest + timeout-minutes: 300 + permissions: + contents: 'read' + actions: 'read' + + env: + # ubuntu-latest is a 4-core runner; the workflow-level default of 6 is tuned + # for macos-14-xlarge. Pinned so both sides of the benchmark match exactly. + NX_PARALLEL: 4 + # the workflow-level default of `true` adds I/O overhead and log noise + NX_VERBOSE_LOGGING: false + LINT_BENCHMARK_RUNS: 3 + TIMING_PACKAGE: packages/react-components/react-button/library + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + cache: 'yarn' + node-version: '22' + + - run: yarn install --immutable + + - name: Record environment + run: | + { + echo "## Lint benchmark environment" + echo "" + echo "| Property | Value |" + echo "| --- | --- |" + echo "| Commit | \`${GITHUB_SHA}\` |" + echo "| Runner | \`${RUNNER_OS}\` / \`$(getconf _NPROCESSORS_ONLN)\` CPUs |" + echo "| Node | \`$(node --version)\` |" + echo "| ESLint | \`$(yarn run -T eslint --version)\` |" + echo "| Nx parallelism | \`${NX_PARALLEL}\` |" + echo "| Nx cache | disabled (\`--skipNxCache\`) |" + echo "| Task dependencies | excluded (\`--excludeTaskDependencies\`) |" + echo "| Projects with a \`lint\` target | \`$(yarn nx show projects --with-target lint | wc -l | tr -d ' ')\` |" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Full workspace lint (timed, uncached) + run: | + { + echo "## Full workspace lint" + echo "" + echo "\`yarn nx run-many -t lint --all --parallel=${NX_PARALLEL} --skipNxCache --excludeTaskDependencies\`" + echo "" + echo "| Run | Duration (s) | Exit code |" + echo "| --- | --- | --- |" + } >> "$GITHUB_STEP_SUMMARY" + + best="" + + for run in $(seq 1 "$LINT_BENCHMARK_RUNS"); do + started_at=$(date +%s) + + set +e + yarn nx run-many -t lint --all --parallel="$NX_PARALLEL" --skipNxCache --excludeTaskDependencies + status=$? + set -e + + duration=$(( $(date +%s) - started_at )) + echo "| ${run} | ${duration} | ${status} |" >> "$GITHUB_STEP_SUMMARY" + + if [ -z "$best" ] || [ "$duration" -lt "$best" ]; then + best=$duration + fi + done + + { + echo "" + echo "**Fastest run: ${best}s**" + echo "" + echo "> A non-zero exit code is expected when the benchmarked rule reports" + echo "> violations. It does not affect the timing: no \`--nxBail\` is used, so" + echo "> every project is linted on every run." + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Per-rule timing (single package) + working-directory: ${{ env.TIMING_PACKAGE }} + run: | + set +e + TIMING=25 yarn run -T eslint src 2>&1 | tee eslint-timing.log + set -e + + { + echo "## Per-rule timing — \`${TIMING_PACKAGE}\`" + echo "" + echo "Runner-independent attribution from ESLint's built-in profiler" + echo "(\`TIMING=25 eslint src\`)." + echo "" + echo '```' + sed -n '/^Rule/,$p' eslint-timing.log + echo '```' + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + rm -f eslint-timing.log From 5bc1e5434b43fcba9d40408553e87d128b990d8a Mon Sep 17 00:00:00 2001 From: mainframev Date: Sun, 16 Aug 2026 05:37:32 +0200 Subject: [PATCH 2/3] ci: build eslint plugin before lint benchmark and log timings to stdout --- .github/workflows/pr.yml | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b3dbc8c7bfdfb4..79691dbf597a4e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -246,6 +246,13 @@ jobs: - run: yarn install --immutable + # NOT timed. `packages/eslint-plugin/src/configs/react/config.js` requires the + # *built* `@fluentui/eslint-plugin-react-components`, so without this every + # package consuming the react config dies with "Cannot find module" before + # linting a single file. Nx builds this project's dependencies transitively. + - name: Build lint prerequisites (not timed) + run: yarn nx run-many -t build -p eslint-plugin-react-components + - name: Record environment run: | { @@ -271,8 +278,8 @@ jobs: echo "" echo "\`yarn nx run-many -t lint --all --parallel=${NX_PARALLEL} --skipNxCache --excludeTaskDependencies\`" echo "" - echo "| Run | Duration (s) | Exit code |" - echo "| --- | --- | --- |" + echo "| Run | Duration (s) | Failed tasks | Exit code |" + echo "| --- | --- | --- | --- |" } >> "$GITHUB_STEP_SUMMARY" best="" @@ -281,18 +288,31 @@ jobs: started_at=$(date +%s) set +e - yarn nx run-many -t lint --all --parallel="$NX_PARALLEL" --skipNxCache --excludeTaskDependencies - status=$? + yarn nx run-many -t lint --all --parallel="$NX_PARALLEL" --skipNxCache --excludeTaskDependencies \ + 2>&1 | tee "lint-run-${run}.log" + status=${PIPESTATUS[0]} set -e duration=$(( $(date +%s) - started_at )) - echo "| ${run} | ${duration} | ${status} |" >> "$GITHUB_STEP_SUMMARY" + + # A high count means projects died before linting anything (e.g. a missing + # build), which would make the duration meaningless. Surfaced so the + # measurement can be sanity-checked rather than trusted blindly. + failed_tasks=$(grep -cE '^- \S+:lint$' "lint-run-${run}.log" || true) + + # echoed to stdout as well: step summaries are not retrievable via the API + echo "BENCHMARK_RESULT run=${run} duration_seconds=${duration} failed_tasks=${failed_tasks} exit=${status}" + echo "| ${run} | ${duration} | ${failed_tasks} | ${status} |" >> "$GITHUB_STEP_SUMMARY" + + rm -f "lint-run-${run}.log" if [ -z "$best" ] || [ "$duration" -lt "$best" ]; then best=$duration fi done + echo "BENCHMARK_BEST duration_seconds=${best}" + { echo "" echo "**Fastest run: ${best}s**" From 55fc7dd9e7924728c6baecda13918c5e37ee2b03 Mon Sep 17 00:00:00 2001 From: mainframev Date: Sun, 16 Aug 2026 03:12:51 +0200 Subject: [PATCH 3/3] feat(tools): add prefer-direct-reexport eslint rule --- packages/eslint-plugin/src/internal.js | 1 + tools/eslint-rules/index.ts | 2 + .../rules/prefer-direct-reexport.spec.ts | 1098 +++++++++++++++++ .../rules/prefer-direct-reexport.ts | 433 +++++++ 4 files changed, 1534 insertions(+) create mode 100644 tools/eslint-rules/rules/prefer-direct-reexport.spec.ts create mode 100644 tools/eslint-rules/rules/prefer-direct-reexport.ts diff --git a/packages/eslint-plugin/src/internal.js b/packages/eslint-plugin/src/internal.js index 323c79840c04d8..777ed66bb09e45 100644 --- a/packages/eslint-plugin/src/internal.js +++ b/packages/eslint-plugin/src/internal.js @@ -37,6 +37,7 @@ const __internal = { '@nx/workspace-consistent-callback-type': 'error', '@nx/workspace-base-hook-signature': 'error', '@nx/workspace-base-hook-no-forbidden-runtime': 'error', + '@nx/workspace-prefer-direct-reexport': 'error', '@nx/workspace-no-restricted-globals': restrictedGlobals.react, '@nx/workspace-no-missing-jsx-pragma': ['error', { runtime: 'automatic' }], }, diff --git a/tools/eslint-rules/index.ts b/tools/eslint-rules/index.ts index b0ed1faac661ed..ccbcc974c61011 100644 --- a/tools/eslint-rules/index.ts +++ b/tools/eslint-rules/index.ts @@ -9,6 +9,7 @@ import { RULE_NAME as baseHookNoForbiddenRuntimeName, rule as baseHookNoForbiddenRuntime, } from './rules/base-hook-no-forbidden-runtime'; +import { RULE_NAME as preferDirectReexportName, rule as preferDirectReexport } from './rules/prefer-direct-reexport'; /** * Import your custom workspace rules at the top of this file. @@ -39,6 +40,7 @@ module.exports = { [consistentCallbackTypeName]: consistentCallbackType, [baseHookSignatureName]: baseHookSignature, [baseHookNoForbiddenRuntimeName]: baseHookNoForbiddenRuntime, + [preferDirectReexportName]: preferDirectReexport, [noRestrictedGlobalsName]: noRestrictedGlobals, [noMissingJsxPragmaName]: noMissingJsxPragma, }, diff --git a/tools/eslint-rules/rules/prefer-direct-reexport.spec.ts b/tools/eslint-rules/rules/prefer-direct-reexport.spec.ts new file mode 100644 index 00000000000000..622b3887e2b349 --- /dev/null +++ b/tools/eslint-rules/rules/prefer-direct-reexport.spec.ts @@ -0,0 +1,1098 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { rule, RULE_NAME } from './prefer-direct-reexport'; + +const ruleTester = new RuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: ` + export type { BaseProps as Props } from 'pkg'; + `, + }, + { + code: ` + export { renderBase as render } from 'pkg'; + `, + }, + { + code: ` + import type { BaseProps } from 'pkg'; + export interface Props extends BaseProps {} + `, + }, + { + code: ` + import type { BaseProps } from 'pkg'; + type OtherProps = { other: boolean }; + export type Props = BaseProps & OtherProps; + `, + }, + { + code: ` + import type { BaseProps } from 'pkg'; + export type Props = BaseProps; + `, + }, + { + code: ` + import type { BaseProps } from 'pkg'; + export type Props = BaseProps; + `, + }, + { + code: ` + import * as upstream from 'pkg'; + export const render = upstream.renderBase; + `, + }, + { + code: ` + import * as local from 'pkg'; + export default local; + `, + }, + { + code: ` + import * as local from 'pkg'; + export { local as default }; + `, + }, + { + code: ` + import type { BaseProps } from 'pkg'; + type LocalBaseProps = BaseProps; + export type Props = LocalBaseProps; + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const [render] = [renderBase]; + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + type PublicSignature = typeof renderBase; + export const render: PublicSignature = renderBase; + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + type PublicType = typeof renderBase; + export const render: PublicType = props => renderBase(props); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export let render = renderBase; + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export let render = props => renderBase(props); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = (value: unknown) => renderBase(value); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = async props => renderBase(props); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export function* render(props) { + return renderBase(props); + } + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export function render(value): unknown { + return renderBase(value); + } + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = function (value: T): T { + return renderBase(value); + }; + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + const otherRender = props => props; + export function render(props) { + return renderBase(props); + } + render = otherRender; + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + const otherRender = props => props; + function render(props) { + return renderBase(props); + } + render = otherRender; + export { render }; + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = (props = defaultProps) => renderBase(props); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = (...args) => renderBase(...args); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = ({ props }) => renderBase(props); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = (first, second) => renderBase(second, first); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = first => renderBase(first, 1); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = (first, second) => renderBase(first); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = props => renderBase.call(undefined, props); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + const before = () => {}; + export const render = props => { + before(); + return renderBase(props); + }; + `, + }, + { + code: ` + import type { BaseProps } from 'pkg'; + import { renderBase } from 'pkg'; + export const render = props => renderBase(props as BaseProps); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = props => renderBase(props!); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = props => renderBase(props); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + export const render = renderBase => renderBase(renderBase); + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + const render = renderBase; + render.displayName = 'render'; + export { renderBase as publicRender } from 'pkg'; + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + function render(props) { + return renderBase(props); + } + render.displayName = 'render'; + export { renderBase as publicRender } from 'pkg'; + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + const sharedRender = renderBase; + const render = sharedRender; + const alsoRender = sharedRender; + export { renderBase as publicRender } from 'pkg'; + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + const localRender = renderBase; + export { renderBase as publicRender } from 'pkg'; + `, + }, + ], + invalid: [ + // Prefer: export { default as render } from 'pkg'; + { + code: ` + import renderBase, { keep } from 'pkg'; + export const render = renderBase; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'default', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { default as render } from 'pkg'; + { + code: ` + import { default as local, keep } from 'pkg'; + export const render = local; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'default', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { renderBase as default } from 'pkg'; + { + code: ` + import { keep, renderBase } from 'pkg'; + export default renderBase; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'default', + }, + }, + ], + }, + // Prefer: export { renderBase as default } from 'pkg'; + { + code: ` + import { keep, /* remove this comment with renderBase */ renderBase } from 'pkg'; + void keep; + export default renderBase; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'default', + }, + }, + ], + }, + // Prefer: export { default } from 'pkg'; + { + code: ` + import renderBase, { keep } from 'pkg'; + export default renderBase; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'default', + exportedName: 'default', + }, + }, + ], + }, + // Prefer: export * as publicName from 'pkg'; + { + code: ` + import * as local from 'pkg'; + export { local as publicName }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: '*', + exportedName: 'publicName', + }, + }, + ], + }, + // Prefer: export * as publicName from 'pkg'; + { + code: ` + import * as local from 'pkg'; + export const publicName = local; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: '*', + exportedName: 'publicName', + }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + const localRender = renderBase; + export const render = localRender; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + const localRender = renderBase; + const render = localRender; + export { render }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + const localRender = renderBase; + export const render = props => localRender(props); + `, + errors: [ + { + messageId: 'preferFunctionReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export type { BaseProps as Props } from 'pkg'; + { + code: ` + import type { BaseProps } from 'pkg'; + export type Props = BaseProps; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'Props', + }, + }, + ], + }, + // Prefer: export type { BaseProps as Props } from 'pkg'; + { + code: ` + import { BaseProps as UpstreamBaseProps } from 'pkg'; + export type Props = UpstreamBaseProps; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'Props', + }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + export const render = renderBase; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + export { renderBase as render }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { keep, renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + export { keep } from 'pkg'; + export { renderBase as render }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { keep, renderBase as render } from 'pkg'; + { + code: ` + export { keep } from 'pkg'; + import { renderBase } from 'pkg'; + export { renderBase as render }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + export {} from 'pkg'; + export { renderBase as render }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { keep, type BaseProps as Props } from 'pkg'; + { + code: ` + import type { BaseProps } from 'pkg'; + export { keep } from 'pkg'; + export type { BaseProps as Props }; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'Props', + }, + }, + ], + }, + // Prefer: export type { BaseProps as Props } from 'pkg'; + { + code: ` + import type { BaseProps as LocalProps, OtherProps } from 'pkg'; + export type { LocalProps as Props }; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'Props', + }, + }, + ], + }, + // Prefer: export type { Keep, BaseProps as Props } from 'pkg'; + { + code: ` + import type { BaseProps } from 'pkg'; + export { keep } from 'pkg'; + export type { Keep } from 'pkg'; + export type { BaseProps as Props }; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'Props', + }, + }, + ], + }, + // Prefer: export type { Keep, BaseProps as Props } from 'pkg'; + { + code: ` + import type { BaseProps } from 'pkg'; + export type { Keep } from 'pkg'; + export type { BaseProps as Props }; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'Props', + }, + }, + ], + }, + // Prefer: export type { BaseProps as Props } from 'pkg'; + { + code: ` + import type { BaseProps as LocalProps, OtherProps } from 'pkg'; + export { type LocalProps as Props }; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'Props', + }, + }, + ], + }, + // Prefer: export type { BaseProps as Props } from 'pkg'; + { + code: ` + import type { BaseProps } from 'pkg'; + export { BaseProps as Props }; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'Props', + }, + }, + ], + }, + // Prefer: export { upstream as publicRender } from 'pkg'; + { + code: ` + import { keep, upstream as local } from 'pkg'; + export const publicRender = local; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'upstream', + exportedName: 'publicRender', + }, + }, + ], + }, + // Prefer: export { upstream as publicRender } from 'pkg'; + { + code: ` + import { keep, upstream as local } from 'pkg'; + export { local as publicRender }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'upstream', + exportedName: 'publicRender', + }, + }, + ], + }, + // Prefer: export { "render-base" as "render" } from 'pkg'; + { + code: ` + import { "render-base" as local } from 'pkg'; + export { local as "render" }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'render-base', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { default } from 'pkg' assert { type: 'json' }; + { + code: ` + import data, { keep } from 'pkg' assert { type: 'json' }; + export { data as default }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'default', + exportedName: 'default', + }, + }, + ], + }, + // Prefer: export { value as named } from 'pkg' with { type: 'json' }; + { + code: ` + import { keep, value } from 'pkg' with { type: 'json' }; + export { value as named }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'value', + exportedName: 'named', + }, + }, + ], + }, + // Prefer: export { other, value as named } from 'pkg' with { type: 'json' }; + { + code: ` + import { value } from 'pkg' with { type: 'json' }; + export { keep } from 'pkg'; + export { other } from 'pkg' with { type: 'json' }; + export { value as named }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'value', + exportedName: 'named', + }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + export const render = props => renderBase(props); + `, + errors: [ + { + messageId: 'preferFunctionReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { renderBase as publicRender } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + const render = props => renderBase(props); + export { render as publicRender }; + `, + errors: [ + { + messageId: 'preferFunctionReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'publicRender', + }, + }, + ], + }, + // Prefer: export type { BaseProps as PublicProps } from 'pkg'; + { + code: ` + import type { BaseProps } from 'pkg'; + type Props = BaseProps; + export type { Props as PublicProps }; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'PublicProps', + }, + }, + ], + }, + // Prefer: export type { BaseProps as PublicProps } from 'pkg'; + { + code: ` + import type { BaseProps } from 'pkg'; + type Props = BaseProps; + export { Props as PublicProps }; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'PublicProps', + }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + export function render(props, options) { + return renderBase(props, options); + } + `, + errors: [ + { + messageId: 'preferFunctionReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { other, "pkg" as "pkg" } from 'pkg' with { type: 'json' }; + { + code: ` + import { "pkg" as local } from 'pkg' with { type: 'json' }; + export { keep } from 'pkg'; + export { other } from 'pkg' with { type: 'json' }; + export { local as "pkg" }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'pkg', + exportedName: 'pkg', + }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + /** public render */ + export const render = renderBase; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { renderBase as publicRender } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + const render = renderBase; + render.displayName = 'render'; + export { render as publicRender }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'publicRender', + }, + }, + ], + }, + // Prefer: export { renderBase as publicRender } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + function render(props) { + return renderBase(props); + } + void render; + export { render as publicRender }; + `, + errors: [ + { + messageId: 'preferFunctionReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'publicRender', + }, + }, + ], + }, + // Prefer: export { renderBase as publicRender } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + const sharedRender = renderBase; + const render = sharedRender; + const alsoRender = sharedRender; + export { render as publicRender }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'publicRender', + }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + const keep = 1; + export const render = renderBase, other = keep; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // Prefer: export { firstBase as first } from 'pkg'; + { + code: ` + import { firstBase, secondBase } from 'pkg'; + export const first = firstBase, second = secondBase; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'firstBase', + exportedName: 'first', + }, + }, + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'secondBase', + exportedName: 'second', + }, + }, + ], + }, + // Prefer: export type { BaseProps as PublicProps } from 'pkg'; + { + code: ` + import { BaseProps } from 'pkg'; + export type PublicProps = BaseProps; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'PublicProps', + }, + }, + ], + }, + // Prefer: export { renderBase as publicRender } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + const localRender = renderBase; + export const publicRender = renderBase; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'publicRender', + }, + }, + ], + }, + // Prefer: export { renderBase as publicRender } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + const stable = 1; + export { + renderBase as publicRender, + // keep this export comment + stable, + }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'publicRender', + }, + }, + ], + }, + // Prefer: export { stable }; export { renderBase as publicRender } from 'pkg'; + { + code: ` + import { + keep, // keep this import comment + renderBase, + } from 'pkg'; + const stable = 1; + export { + stable, // keep this export comment + }; + export { renderBase as publicRender }; + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'publicRender', + }, + }, + ], + }, + ] as never, +}); + +describe(RULE_NAME, () => { + it('stays report-only', () => { + // Rewriting an import + local declaration into an `export…from` is not safely automatable + // (JSDoc, extra references, identity wrappers), so the rule intentionally ships no fixer. + expect(rule.meta.fixable).toBeUndefined(); + expect(rule.meta.hasSuggestions).toBeFalsy(); + }); +}); diff --git a/tools/eslint-rules/rules/prefer-direct-reexport.ts b/tools/eslint-rules/rules/prefer-direct-reexport.ts new file mode 100644 index 00000000000000..35979e385f6d9e --- /dev/null +++ b/tools/eslint-rules/rules/prefer-direct-reexport.ts @@ -0,0 +1,433 @@ +import { ESLintUtils, AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; + +interface TrackedImport { + importedName: string; + isTypeOnly: boolean; + source: string; +} + +/** Placeholder name used for `import * as ns from 'pkg'`, which maps to `export * as ns from 'pkg'`. */ +const NAMESPACE_IMPORT_NAME = '*'; + +type MessageIds = 'preferFunctionReexport' | 'preferTypeReexport' | 'preferValueReexport'; +type Options = []; + +export const RULE_NAME = 'prefer-direct-reexport'; + +function isTypeOnly(declarationKind: 'type' | 'value', specifierKind: 'type' | 'value') { + return declarationKind === 'type' || specifierKind === 'type'; +} + +function getNodeName(node: TSESTree.Identifier | TSESTree.StringLiteral): string | null { + if (node.type === AST_NODE_TYPES.Identifier) { + return node.name; + } + + return typeof node.value === 'string' ? node.value : null; +} + +function getImportedName(specifier: TSESTree.ImportClause): string | null { + switch (specifier.type) { + case AST_NODE_TYPES.ImportDefaultSpecifier: + return 'default'; + case AST_NODE_TYPES.ImportNamespaceSpecifier: + return NAMESPACE_IMPORT_NAME; + case AST_NODE_TYPES.ImportSpecifier: + return getNodeName(specifier.imported); + default: + return null; + } +} + +function hasWriteReferences(variable: TSESLint.Scope.Variable) { + return variable.references.some(reference => reference.isWrite()); +} + +function isConstVariableDeclarator(variableDeclarator: TSESTree.VariableDeclarator) { + return ( + variableDeclarator.parent?.type === AST_NODE_TYPES.VariableDeclaration && variableDeclarator.parent.kind === 'const' + ); +} + +function getParameterIdentifiers(parameters: TSESTree.Parameter[]): TSESTree.Identifier[] | null { + const identifiers: TSESTree.Identifier[] = []; + + for (const parameter of parameters) { + if (parameter.type !== AST_NODE_TYPES.Identifier || parameter.typeAnnotation) { + return null; + } + + identifiers.push(parameter); + } + + return identifiers; +} + +function getWrappedCallExpression( + body: TSESTree.ArrowFunctionExpression['body'] | TSESTree.BlockStatement, +): TSESTree.CallExpression | null { + if (body.type === AST_NODE_TYPES.CallExpression) { + return body; + } + + if (body.type !== AST_NODE_TYPES.BlockStatement || body.body.length !== 1) { + return null; + } + + const [statement] = body.body; + + if ( + statement.type !== AST_NODE_TYPES.ReturnStatement || + !statement.argument || + statement.argument.type !== AST_NODE_TYPES.CallExpression + ) { + return null; + } + + return statement.argument; +} + +function hasExactArguments(params: TSESTree.CallExpressionArgument[], parameterIdentifiers: TSESTree.Identifier[]) { + if (params.length !== parameterIdentifiers.length) { + return false; + } + + return params.every( + (param, index) => param.type === AST_NODE_TYPES.Identifier && param.name === parameterIdentifiers[index].name, + ); +} + +export const rule = ESLintUtils.RuleCreator(() => __filename)({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: 'Prefer direct re-exports over local aliases and identity wrappers', + }, + messages: { + preferFunctionReexport: + 'Function "{{exportedName}}" is an identity wrapper around "{{importedName}}" from "{{source}}". Prefer `export … from "{{source}}"`.', + preferTypeReexport: + 'Type "{{exportedName}}" is an alias of "{{importedName}}" from "{{source}}". Prefer `export type … from "{{source}}"`.', + preferValueReexport: + 'Value "{{exportedName}}" is an alias of "{{importedName}}" from "{{source}}". Prefer `export … from "{{source}}"`.', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + const trackedImports = new Map(); + + function trackImportDeclaration(importDeclaration: TSESTree.ImportDeclaration) { + if (typeof importDeclaration.source.value !== 'string') { + return; + } + + const source = importDeclaration.source.value; + + importDeclaration.specifiers.forEach(specifier => { + const importedName = getImportedName(specifier); + + if (!importedName) { + return; + } + + const typeOnly = isTypeOnly( + importDeclaration.importKind, + specifier.type === AST_NODE_TYPES.ImportSpecifier ? specifier.importKind : 'value', + ); + + sourceCode.getDeclaredVariables(specifier).forEach(variable => { + trackedImports.set(variable, { + importedName, + isTypeOnly: typeOnly, + source, + }); + }); + }); + } + + function checkTypeAliasDeclaration( + typeAliasDeclaration: TSESTree.TSTypeAliasDeclaration, + exportedName = typeAliasDeclaration.id.name, + reportNode: TSESTree.Identifier | TSESTree.StringLiteral = typeAliasDeclaration.id, + ) { + const typeAnnotation = typeAliasDeclaration.typeAnnotation; + + if ( + typeAliasDeclaration.typeParameters || + typeAnnotation.type !== AST_NODE_TYPES.TSTypeReference || + typeAnnotation.typeName.type !== AST_NODE_TYPES.Identifier || + typeAnnotation.typeArguments + ) { + return; + } + + const trackedImport = getTrackedImport(typeAnnotation.typeName); + if (!trackedImport) { + return; + } + + reportIssue('preferTypeReexport', reportNode, exportedName, trackedImport); + } + + function checkVariableDeclarator( + variableDeclarator: TSESTree.VariableDeclarator, + exportedName?: string, + reportNode?: TSESTree.Identifier | TSESTree.StringLiteral, + ) { + if ( + variableDeclarator.id.type !== AST_NODE_TYPES.Identifier || + !variableDeclarator.init || + !isConstVariableDeclarator(variableDeclarator) + ) { + return; + } + + const variableId = variableDeclarator.id; + const resolvedExportedName = exportedName ?? variableId.name; + const resolvedReportNode = reportNode ?? variableId; + + if (variableId.typeAnnotation) { + return; + } + + if (variableDeclarator.init.type === AST_NODE_TYPES.Identifier) { + const trackedImport = getTrackedImport(variableDeclarator.init); + if (!trackedImport) { + return; + } + + reportIssue('preferValueReexport', resolvedReportNode, resolvedExportedName, trackedImport); + return; + } + + if ( + variableDeclarator.init.type === AST_NODE_TYPES.ArrowFunctionExpression || + variableDeclarator.init.type === AST_NODE_TYPES.FunctionExpression + ) { + checkFunctionLike(variableDeclarator.init, resolvedReportNode, resolvedExportedName); + } + } + + function checkFunctionLike( + functionNode: TSESTree.ArrowFunctionExpression | TSESTree.FunctionDeclaration | TSESTree.FunctionExpression, + reportNode: TSESTree.Identifier | TSESTree.StringLiteral, + exportedName: string, + ) { + if (functionNode.async || functionNode.generator || functionNode.typeParameters || functionNode.returnType) { + return; + } + + if (functionNode.type === AST_NODE_TYPES.FunctionDeclaration && functionNode.id) { + const [declaredVariable] = sourceCode.getDeclaredVariables(functionNode); + if (declaredVariable && hasWriteReferences(declaredVariable)) { + return; + } + } + + const parameterIdentifiers = getParameterIdentifiers(functionNode.params); + + if (!parameterIdentifiers) { + return; + } + + const callExpression = getWrappedCallExpression(functionNode.body); + if ( + !callExpression || + callExpression.callee.type !== AST_NODE_TYPES.Identifier || + callExpression.optional || + callExpression.typeArguments || + !hasExactArguments(callExpression.arguments, parameterIdentifiers) + ) { + return; + } + + const trackedImport = getTrackedImport(callExpression.callee); + + if (!trackedImport) { + return; + } + + reportIssue('preferFunctionReexport', reportNode, exportedName, trackedImport); + } + + function processExportNamedDeclaration(exportNamedDeclaration: TSESTree.ExportNamedDeclaration) { + const declaration = exportNamedDeclaration.declaration; + if (declaration) { + if (declaration.type === AST_NODE_TYPES.TSTypeAliasDeclaration) { + checkTypeAliasDeclaration(declaration); + return; + } + + if (declaration.type === AST_NODE_TYPES.VariableDeclaration) { + if (declaration.kind !== 'const') { + return; + } + + declaration.declarations.forEach(variableDeclarator => { + checkVariableDeclarator(variableDeclarator); + }); + return; + } + + if (declaration.type === AST_NODE_TYPES.FunctionDeclaration && declaration.id) { + checkFunctionLike(declaration, declaration.id, declaration.id.name); + return; + } + } + + if (exportNamedDeclaration.source) { + return; + } + + exportNamedDeclaration.specifiers.forEach(specifier => { + if (specifier.type === AST_NODE_TYPES.ExportSpecifier) { + processExportSpecifier(specifier, exportNamedDeclaration.exportKind); + } + }); + } + + function processExportSpecifier(exportSpecifier: TSESTree.ExportSpecifier, exportKind: 'type' | 'value') { + if (exportSpecifier.local.type !== AST_NODE_TYPES.Identifier) { + return; + } + + const exportedName = getNodeName(exportSpecifier.exported); + + if (!exportedName) { + return; + } + + const isTypeExport = isTypeOnly(exportKind, exportSpecifier.exportKind); + const trackedImport = getTrackedImport(exportSpecifier.local); + + if (trackedImport) { + if (trackedImport.importedName === NAMESPACE_IMPORT_NAME && exportedName === 'default') { + return; + } + + reportIssue( + isTypeExport || trackedImport.isTypeOnly ? 'preferTypeReexport' : 'preferValueReexport', + exportSpecifier.exported, + exportedName, + trackedImport, + ); + return; + } + + const resolvedVariable = getResolvedVariable(exportSpecifier.local); + const definitionNode = resolvedVariable?.defs[0]?.node; + + if (!definitionNode) { + return; + } + + if (definitionNode.type === AST_NODE_TYPES.TSTypeAliasDeclaration) { + checkTypeAliasDeclaration(definitionNode, exportedName, exportSpecifier.exported); + return; + } + + if (definitionNode.type === AST_NODE_TYPES.VariableDeclarator) { + checkVariableDeclarator(definitionNode, exportedName, exportSpecifier.exported); + return; + } + + if (definitionNode.type === AST_NODE_TYPES.FunctionDeclaration && definitionNode.id) { + checkFunctionLike(definitionNode, exportSpecifier.exported, exportedName); + } + } + + function processExportDefaultDeclaration(exportDefaultDeclaration: TSESTree.ExportDefaultDeclaration) { + const declaration = exportDefaultDeclaration.declaration; + + if (declaration.type !== AST_NODE_TYPES.Identifier) { + return; + } + + const trackedImport = getTrackedImport(declaration); + + if (!trackedImport || trackedImport.importedName === NAMESPACE_IMPORT_NAME) { + return; + } + + reportIssue( + trackedImport.isTypeOnly ? 'preferTypeReexport' : 'preferValueReexport', + declaration, + 'default', + trackedImport, + ); + } + + function getTrackedImport( + identifier: TSESTree.Identifier, + visitedVariables = new Set(), + ): TrackedImport | undefined { + const resolvedVariable = getResolvedVariable(identifier); + + if (!resolvedVariable || visitedVariables.has(resolvedVariable)) { + return; + } + + const trackedImport = trackedImports.get(resolvedVariable); + if (trackedImport) { + return trackedImport; + } + + visitedVariables.add(resolvedVariable); + + const definitionNode = resolvedVariable.defs[0]?.node; + + if ( + definitionNode?.type !== AST_NODE_TYPES.VariableDeclarator || + definitionNode.id.type !== AST_NODE_TYPES.Identifier || + definitionNode.id.typeAnnotation || + definitionNode.init?.type !== AST_NODE_TYPES.Identifier || + !isConstVariableDeclarator(definitionNode) + ) { + return; + } + + return getTrackedImport(definitionNode.init, visitedVariables); + } + + function getResolvedVariable(identifier: TSESTree.Identifier): TSESLint.Scope.Variable | null { + let scope: TSESLint.Scope.Scope | null = sourceCode.getScope(identifier); + + while (scope) { + const reference = scope.references.find(currentReference => currentReference.identifier === identifier); + if (reference) { + return reference.resolved ?? null; + } + + scope = scope.upper; + } + + return null; + } + + function reportIssue( + messageId: MessageIds, + node: TSESTree.Identifier | TSESTree.StringLiteral, + exportedName: string, + trackedImport: TrackedImport, + ) { + context.report({ + node, + messageId, + data: { + exportedName, + importedName: trackedImport.importedName, + source: trackedImport.source, + }, + }); + } + + return { + ImportDeclaration: trackImportDeclaration, + ExportNamedDeclaration: processExportNamedDeclaration, + ExportDefaultDeclaration: processExportDefaultDeclaration, + }; + }, +});