diff --git a/change/@fluentui-react-headless-components-preview-562759ff-fce6-436c-8255-503e22d8ba20.json b/change/@fluentui-react-headless-components-preview-562759ff-fce6-436c-8255-503e22d8ba20.json new file mode 100644 index 00000000000000..0b2201bb187005 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-562759ff-fce6-436c-8255-503e22d8ba20.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "chore: adjust re-exports after direct re-exports workspace rule", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "none" +} diff --git a/packages/eslint-plugin/src/internal.js b/packages/eslint-plugin/src/internal.js index 323c79840c04d8..046555b1e262a8 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': 'off', '@nx/workspace-no-restricted-globals': restrictedGlobals.react, '@nx/workspace-no-missing-jsx-pragma': ['error', { runtime: 'automatic' }], }, diff --git a/packages/react-components/react-headless-components-preview/library/eslint.config.cjs b/packages/react-components/react-headless-components-preview/library/eslint.config.cjs index 6e76685858d5fd..ab52399d551824 100644 --- a/packages/react-components/react-headless-components-preview/library/eslint.config.cjs +++ b/packages/react-components/react-headless-components-preview/library/eslint.config.cjs @@ -12,4 +12,11 @@ module.exports = [ 'import/no-extraneous-dependencies': 'off', }, }, + { + files: ['**/src/**/*.{ts,tsx}'], + ignores: ['**/*.{test,cy,stories}.{ts,tsx}'], + rules: { + '@nx/workspace-prefer-direct-reexport': 'error', + }, + }, ]; diff --git a/packages/react-components/react-headless-components-preview/library/etc/accordion.api.md b/packages/react-components/react-headless-components-preview/library/etc/accordion.api.md index 07e90ed3df5379..0d082989e0f55f 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/accordion.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/accordion.api.md @@ -4,7 +4,6 @@ ```ts -import type { AccordionBaseProps } from '@fluentui/react-accordion'; import type { AccordionBaseState } from '@fluentui/react-accordion'; import { AccordionContextValues } from '@fluentui/react-accordion'; import type { AccordionHeaderBaseState } from '@fluentui/react-accordion'; @@ -17,6 +16,7 @@ import type { AccordionItemState as AccordionItemState_2 } from '@fluentui/react import type { AccordionPanelBaseState } from '@fluentui/react-accordion'; import { AccordionPanelBaseProps as AccordionPanelProps } from '@fluentui/react-accordion'; import { AccordionPanelSlots } from '@fluentui/react-accordion'; +import { AccordionBaseProps as AccordionProps } from '@fluentui/react-accordion'; import { AccordionSlots } from '@fluentui/react-accordion'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; @@ -77,8 +77,7 @@ export type AccordionPanelState = AccordionPanelBaseState & { }; }; -// @public (undocumented) -export type AccordionProps = AccordionBaseProps; +export { AccordionProps } export { AccordionSlots } diff --git a/packages/react-components/react-headless-components-preview/library/etc/menu.api.md b/packages/react-components/react-headless-components-preview/library/etc/menu.api.md index 983de2115fce74..29c80e887958dd 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/menu.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/menu.api.md @@ -34,7 +34,7 @@ import { MenuItemSwitchSlots } from '@fluentui/react-menu'; import { MenuItemSwitchState } from '@fluentui/react-menu'; import type { MenuListContextValues } from '@fluentui/react-menu'; import { MenuListProps } from '@fluentui/react-menu'; -import type { MenuListSlots } from '@fluentui/react-menu'; +import { MenuListSlots } from '@fluentui/react-menu'; import type { MenuListState as MenuListState_2 } from '@fluentui/react-menu'; import { MenuOpenChangeData } from '@fluentui/react-menu'; import { MenuOpenEvent } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/message-bar.api.md b/packages/react-components/react-headless-components-preview/library/etc/message-bar.api.md index e8bc533ff84c11..9c7235cd09f3d1 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/message-bar.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/message-bar.api.md @@ -15,7 +15,7 @@ import { MessageBarBodyProps } from '@fluentui/react-message-bar'; import { MessageBarBodySlots } from '@fluentui/react-message-bar'; import { MessageBarBodyState } from '@fluentui/react-message-bar'; import { MessageBarContextValues } from '@fluentui/react-message-bar'; -import type { MessageBarIntent } from '@fluentui/react-message-bar'; +import { MessageBarIntent } from '@fluentui/react-message-bar'; import { MessageBarBaseProps as MessageBarProps } from '@fluentui/react-message-bar'; import { MessageBarSlots } from '@fluentui/react-message-bar'; import { MessageBarTitleProps } from '@fluentui/react-message-bar'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/nav.api.md b/packages/react-components/react-headless-components-preview/library/etc/nav.api.md index db74bb5314a96b..2a5c70344d672a 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/nav.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/nav.api.md @@ -125,8 +125,12 @@ export type NavDividerProps = Omit; export { NavDividerSlots } -// @public -export type NavDividerState = DividerState; +// @public (undocumented) +export type NavDividerState = DividerBaseState & { + root: { + 'data-orientation'?: 'vertical' | 'horizontal'; + }; +}; // @public export const NavDrawer: ForwardRefComponent; diff --git a/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md b/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md index 8a5a34e52a4355..489101c9a40c88 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md @@ -34,7 +34,7 @@ import { TagPickerControlInternalSlots } from '@fluentui/react-tag-picker'; import { TagPickerControlProps } from '@fluentui/react-tag-picker'; import { TagPickerControlSlots } from '@fluentui/react-tag-picker'; import type { TagPickerGroupBaseState } from '@fluentui/react-tag-picker'; -import type { TagPickerGroupSlots as TagPickerGroupSlots_2 } from '@fluentui/react-tag-picker'; +import { TagPickerGroupSlots } from '@fluentui/react-tag-picker'; import type { TagPickerInputBaseState } from '@fluentui/react-tag-picker'; import { TagPickerInputBaseProps as TagPickerInputProps } from '@fluentui/react-tag-picker'; import { TagPickerInputSlots } from '@fluentui/react-tag-picker'; @@ -113,8 +113,7 @@ export const TagPickerGroup: ForwardRefComponent; // @public export type TagPickerGroupProps = ComponentProps & Pick; -// @public (undocumented) -export type TagPickerGroupSlots = TagPickerGroupSlots_2; +export { TagPickerGroupSlots } // @public export type TagPickerGroupState = TagPickerGroupBaseState & { diff --git a/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md index 6ee09f1f455a86..271f83c73c2fb7 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md @@ -224,10 +224,40 @@ export { TeachingPopoverHeaderSlots } export { TeachingPopoverHeaderState } // @public -export type TeachingPopoverProps = PopoverProps; +export type TeachingPopoverProps = { + children: [JSXElement, JSXElement] | JSXElement; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: EventHandler; + openOnHover?: boolean; + openOnContext?: boolean; + mouseLeaveDelay?: number; + positioning?: PositioningShorthand; + withArrow?: boolean; + id?: string; + trapFocus?: boolean; +}; // @public -export type TeachingPopoverState = PopoverState; +export type TeachingPopoverState = Required> & Pick & { + setOpen: (e: OpenPopoverEvents, open: boolean) => void; + toggleOpen: (e: OpenPopoverEvents) => void; + triggerRef: React_2.RefObject; + contentRef: React_2.RefObject; + arrowRef: React_2.RefObject; + popoverTrigger: React_2.ReactElement | undefined; + popoverSurface: React_2.ReactElement | undefined; + contextTarget: { + x: number; + y: number; + } | undefined; + setContextTarget: (target: { + x: number; + y: number; + } | undefined) => void; + positioning: PositioningReturn; + surfaceId: string; +}; // @public export const TeachingPopoverSurface: ForwardRefComponent; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Accordion/Accordion.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Accordion/Accordion.types.ts index d2ec75c3fb670f..cda19f4d4d55bf 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Accordion/Accordion.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Accordion/Accordion.types.ts @@ -1,8 +1,10 @@ -import type { AccordionBaseProps, AccordionBaseState } from '@fluentui/react-accordion'; +import type { AccordionBaseState } from '@fluentui/react-accordion'; -export type { AccordionSlots, AccordionContextValues } from '@fluentui/react-accordion'; - -export type AccordionProps = AccordionBaseProps; +export type { + AccordionSlots, + AccordionContextValues, + AccordionBaseProps as AccordionProps, +} from '@fluentui/react-accordion'; export type AccordionState = AccordionBaseState & { root: { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/MenuList.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/MenuList.types.ts index 4dab1d1507b184..52088893be8602 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/MenuList.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/MenuList.types.ts @@ -1,16 +1,14 @@ -import type { +import type { MenuListState as MenuListBaseState } from '@fluentui/react-menu'; + +export type { + MenuListProps, MenuListSlots, - MenuListState as MenuListBaseState, MenuCheckedValueChangeData, MenuCheckedValueChangeEvent, } from '@fluentui/react-menu'; -export type { MenuListProps } from '@fluentui/react-menu'; - export type MenuListState = MenuListBaseState & { root: { focusgroup?: string; }; }; - -export type { MenuListSlots, MenuCheckedValueChangeData, MenuCheckedValueChangeEvent }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/MessageBar/MessageBar.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/MessageBar/MessageBar.types.ts index 77a90840166ada..ce95b1639739ed 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/MessageBar/MessageBar.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/MessageBar/MessageBar.types.ts @@ -1,9 +1,10 @@ -import type { MessageBarBaseState, MessageBarIntent } from '@fluentui/react-message-bar'; +import type { MessageBarBaseState } from '@fluentui/react-message-bar'; export type { MessageBarSlots, MessageBarBaseProps as MessageBarProps, MessageBarContextValues, + MessageBarIntent, } from '@fluentui/react-message-bar'; export type MessageBarState = MessageBarBaseState & { @@ -19,5 +20,3 @@ export type MessageBarState = MessageBarBaseState & { 'data-intent'?: string; }; }; - -export type { MessageBarIntent }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Nav/NavDivider/NavDivider.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Nav/NavDivider/NavDivider.types.ts index 06b55140a6db61..2d39b68a6ac73f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Nav/NavDivider/NavDivider.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Nav/NavDivider/NavDivider.types.ts @@ -1,13 +1,8 @@ -import type { DividerProps, DividerState } from '../../Divider'; +import type { DividerProps } from '../../Divider'; -export type { DividerSlots as NavDividerSlots } from '../../Divider'; +export type { DividerSlots as NavDividerSlots, DividerState as NavDividerState } from '../../Divider'; /** * NavDivider Props */ export type NavDividerProps = Omit; - -/** - * State used in rendering NavDivider - */ -export type NavDividerState = DividerState; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts index 17f3ffdfa6018a..b96a0c3f22b219 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts @@ -5,12 +5,12 @@ import type { TagPickerGroupSlots as TagPickerGroupBaseSlots, } from '@fluentui/react-tag-picker'; -export type TagPickerGroupSlots = TagPickerGroupBaseSlots; +export type { TagPickerGroupSlots } from '@fluentui/react-tag-picker'; /** * TagPickerGroup Props */ -export type TagPickerGroupProps = ComponentProps & +export type TagPickerGroupProps = ComponentProps & Pick; /** diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.types.ts index 52ddc2f03464d5..84e1fda91f41df 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.types.ts @@ -1,17 +1,13 @@ import type { PopoverContextValue as BasePopoverContextValue } from '@fluentui/react-popover'; -import type { PopoverProps, PopoverState, PopoverContextValue } from '../Popover/Popover.types'; +import type { PopoverContextValue } from '../Popover/Popover.types'; /** * TeachingPopover Props */ -export type TeachingPopoverProps = PopoverProps; - -/** - * TeachingPopover State — identical to the headless Popover state. Styling - * concerns from `@fluentui/react-teaching-popover` (`appearance`, `trapFocus`, - * `inline`) are intentionally omitted; consumers control presentation. - */ -export type TeachingPopoverState = PopoverState; +export type { + PopoverProps as TeachingPopoverProps, + PopoverState as TeachingPopoverState, +} from '../Popover/Popover.types'; /** * Subset of the `@fluentui/react-popover` `PopoverContextValue` that the 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..277a18821046a6 --- /dev/null +++ b/tools/eslint-rules/rules/prefer-direct-reexport.spec.ts @@ -0,0 +1,1294 @@ +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: [ + // --- Already a direct re-export --- + // valid: Already a direct type re-export. + { + code: ` + export type { BaseProps as Props } from 'pkg'; + `, + }, + // valid: Already a direct value re-export. + { + code: ` + export { renderBase as render } from 'pkg'; + `, + }, + // --- Aliases that declare a new type --- + // valid: `extends` declares a new interface instead of renaming the imported one. + { + code: ` + import type { BaseProps } from 'pkg'; + export interface Props extends BaseProps {} + `, + }, + // valid: An intersection declares a new type. + { + code: ` + import type { BaseProps } from 'pkg'; + type OtherProps = { other: boolean }; + export type Props = BaseProps & OtherProps; + `, + }, + // valid: Instantiating a generic declares a new type. + { + code: ` + import type { BaseProps } from 'pkg'; + export type Props = BaseProps; + `, + }, + { + code: ` + import { renderBase } from 'pkg'; + const localRender = renderBase; + export const render = (props: any) => localRender(props); + `, + }, + // valid: A generic alias declares a new type constructor. + { + code: ` + import type { BaseProps } from 'pkg'; + export type Props = BaseProps; + `, + }, + // --- Namespace imports --- + // valid: `export … from` cannot address a single member of a namespace binding. + { + code: ` + import * as upstream from 'pkg'; + export const render = upstream.renderBase; + `, + }, + // valid: `export default ns` re-exports the namespace object, which `export … from` cannot name. + { + code: ` + import * as local from 'pkg'; + export default local; + `, + }, + // valid: Same for the specifier form of the default export. + { + code: ` + import * as local from 'pkg'; + export { local as default }; + `, + }, + // --- Value bindings that are not a plain alias --- + // valid: Destructuring is not an alias of the import. + { + code: ` + import { renderBase } from 'pkg'; + export const [render] = [renderBase]; + `, + }, + // valid: The annotation on a plain alias narrows the public signature, so the export is not the import. + { + code: ` + import { renderBase } from 'pkg'; + type PublicSignature = typeof renderBase; + export const render: PublicSignature = renderBase; + `, + }, + // valid: Same for an annotated identity wrapper. + { + code: ` + import { renderBase } from 'pkg'; + type PublicType = typeof renderBase; + export const render: PublicType = props => renderBase(props); + `, + }, + // valid: A reassigned alias is a live mutable export, which `export … from` cannot express. + { + code: ` + import { renderBase } from 'pkg'; + export let render = renderBase; + render = renderOther; + `, + }, + // valid: Same for a reassigned identity wrapper. + { + code: ` + import { renderBase } from 'pkg'; + export let render = props => renderBase(props); + render = renderOther; + `, + }, + // valid: A reassigned function declaration is a live binding, here exported as a declaration. + { + code: ` + import { renderBase } from 'pkg'; + const otherRender = props => props; + export function render(props) { + return renderBase(props); + } + render = otherRender; + `, + }, + // valid: Same when the reassigned declaration is exported through a specifier instead. + { + code: ` + import { renderBase } from 'pkg'; + const otherRender = props => props; + function render(props) { + return renderBase(props); + } + render = otherRender; + export { render }; + `, + }, + // --- Wrapper signature --- + // valid: An annotated parameter narrows the public signature. + { + code: ` + import { renderBase } from 'pkg'; + export const render = (value: unknown) => renderBase(value); + `, + }, + // valid: `async` wraps the result in a promise. + { + code: ` + import { renderBase } from 'pkg'; + export const render = async props => renderBase(props); + `, + }, + // valid: A generator returns an iterator instead of the call result. + { + code: ` + import { renderBase } from 'pkg'; + export function* render(props) { + return renderBase(props); + } + `, + }, + // valid: The return type annotation narrows the public signature. + { + code: ` + import { renderBase } from 'pkg'; + export function render(value): unknown { + return renderBase(value); + } + `, + }, + // valid: Type parameters and annotations narrow the public signature. + { + code: ` + import { renderBase } from 'pkg'; + export const render = function (value: T): T { + return renderBase(value); + }; + `, + }, + // --- Wrapper body and arguments --- + // valid: A default parameter value changes what reaches the call. + { + code: ` + import { renderBase } from 'pkg'; + export const render = (props = defaultProps) => renderBase(props); + `, + }, + // valid: A rest parameter does not forward a fixed signature. + { + code: ` + import { renderBase } from 'pkg'; + export const render = (...args) => renderBase(...args); + `, + }, + // valid: A destructured parameter changes what reaches the call. + { + code: ` + import { renderBase } from 'pkg'; + export const render = ({ props }) => renderBase(props); + `, + }, + // valid: The arguments are reordered. + { + code: ` + import { renderBase } from 'pkg'; + export const render = (first, second) => renderBase(second, first); + `, + }, + // valid: An extra argument is added. + { + code: ` + import { renderBase } from 'pkg'; + export const render = first => renderBase(first, 1); + `, + }, + // valid: An argument is dropped. + { + code: ` + import { renderBase } from 'pkg'; + export const render = (first, second) => renderBase(first); + `, + }, + // valid: `.call` is not a direct invocation. + { + code: ` + import { renderBase } from 'pkg'; + export const render = props => renderBase.call(undefined, props); + `, + }, + // valid: The wrapper runs an extra statement. + { + code: ` + import { renderBase } from 'pkg'; + const before = () => {}; + export const render = props => { + before(); + return renderBase(props); + }; + `, + }, + // valid: An `as` assertion changes the argument before it is forwarded. + { + code: ` + import type { BaseProps } from 'pkg'; + import { renderBase } from 'pkg'; + export const render = props => renderBase(props as BaseProps); + `, + }, + // valid: Same for a non-null assertion. + { + code: ` + import { renderBase } from 'pkg'; + export const render = props => renderBase(props!); + `, + }, + // valid: Explicit type arguments narrow the call. + { + code: ` + import { renderBase } from 'pkg'; + export const render = props => renderBase(props); + `, + }, + // valid: The parameter shadows the import, so the call never reaches it. + { + code: ` + import { renderBase } from 'pkg'; + export const render = renderBase => renderBase(renderBase); + `, + }, + // --- Locals that are never exported --- + // valid: The local alias is never exported; only a property is assigned to it. + { + code: ` + import { renderBase } from 'pkg'; + const render = renderBase; + render.displayName = 'render'; + export { renderBase as publicRender } from 'pkg'; + `, + }, + // valid: The local wrapper is never exported; only a property is assigned to it. + { + code: ` + import { renderBase } from 'pkg'; + function render(props) { + return renderBase(props); + } + render.displayName = 'render'; + export { renderBase as publicRender } from 'pkg'; + `, + }, + // valid: The alias chain is never exported. + { + code: ` + import { renderBase } from 'pkg'; + const sharedRender = renderBase; + const render = sharedRender; + const alsoRender = sharedRender; + export { renderBase as publicRender } from 'pkg'; + `, + }, + // valid: The alias is never exported. + { + code: ` + import { renderBase } from 'pkg'; + const localRender = renderBase; + export { renderBase as publicRender } from 'pkg'; + `, + }, + // --- Imports still used locally --- + // valid: `Foo` is needed by `Wrapper`, so the import stays and `export … from` could only be + // added alongside it rather than replace it. + { + code: ` + import type { Foo } from 'pkg'; + export type { Foo }; + export type Wrapper = { inner: Foo }; + `, + }, + // valid: The imported value is called locally, so the import cannot be dropped. + { + code: ` + import { helper } from 'pkg'; + export { helper }; + export const wrapped = props => helper(props, true); + `, + }, + // valid: Same when the import is renamed locally. + { + code: ` + import { helper as localHelper } from 'pkg'; + export { localHelper as helper }; + export const wrapped = props => localHelper(props, true); + `, + }, + ], + invalid: [ + // The guard is per binding, not per import declaration: `Bar` is used locally but `Foo` is + // imported only to be re-exported, so `Foo` is still reported. + // Prefer: export type { Foo } from 'pkg'; + { + code: ` + import type { Foo, Bar } from 'pkg'; + export type { Foo }; + export type State = Bar & { extra: string }; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { source: 'pkg', importedName: 'Foo', exportedName: 'Foo' }, + }, + ], + }, + // A locally used alias is still a violation: the fix drops the alias and switches its use sites + // to the imported name, so `Props` becomes `BaseProps` inside `Wrapper`. + // Prefer: export type { BaseProps as Props } from 'pkg'; + { + code: ` + import type { BaseProps } from 'pkg'; + export type Props = BaseProps; + export type Wrapper = { inner: Props }; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { source: 'pkg', importedName: 'BaseProps', exportedName: 'Props' }, + }, + ], + }, + // Reported the same way when the export is a specifier rather than part of the declaration. + // Prefer: export type { BaseProps as Props } from 'pkg'; + { + code: ` + import type { BaseProps } from 'pkg'; + type Props = BaseProps; + export type Wrapper = { inner: Props }; + export type { Props }; + `, + 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; + export const renderTwice = props => render(render(props)); + `, + errors: [ + { + messageId: 'preferValueReexport', + data: { source: 'pkg', importedName: 'renderBase', exportedName: 'render' }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + { + code: ` + import { renderBase } from 'pkg'; + export function render(props) { + return renderBase(props); + } + export const renderTwice = props => render(render(props)); + `, + errors: [ + { + messageId: 'preferFunctionReexport', + data: { source: 'pkg', importedName: 'renderBase', exportedName: 'render' }, + }, + ], + }, + // Prefer: export type { BaseProps as Props } from 'pkg'; + // The local alias is transparent, so the chain still resolves to the imported type. + { + code: ` + import type { BaseProps } from 'pkg'; + type LocalBaseProps = BaseProps; + export type Props = LocalBaseProps; + `, + errors: [ + { + messageId: 'preferTypeReexport', + data: { + source: 'pkg', + importedName: 'BaseProps', + exportedName: 'Props', + }, + }, + ], + }, + // Prefer: export { renderBase as render } from 'pkg'; + // A `let` that is never reassigned is not a live binding, so it is a plain re-export. + { + code: ` + import { renderBase } from 'pkg'; + export let 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 let render = props => renderBase(props); + `, + errors: [ + { + messageId: 'preferFunctionReexport', + data: { + source: 'pkg', + importedName: 'renderBase', + exportedName: 'render', + }, + }, + ], + }, + // 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', + }, + }, + ], + }, + // --- Identity wrappers --- + // 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', + }, + }, + ], + }, + ], +}); 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..afb0eeb8254217 --- /dev/null +++ b/tools/eslint-rules/rules/prefer-direct-reexport.ts @@ -0,0 +1,500 @@ +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; + } +} + +/** + * A binding that is reassigned is a live mutable export, which `export … from` cannot express. + * The initializer write is skipped so that a never-reassigned `let` still counts as a re-export. + */ +function isReassigned(variable: TSESLint.Scope.Variable) { + return variable.references.some(reference => reference.isWrite() && !reference.init); +} + +/** + * Whether the binding is referenced somewhere other than the export that re-exports it. Such a + * binding has to stay, so `export … from` can only be added alongside it rather than replace it. + * The initializer write and the `export { … }` specifier itself are not local uses. + */ +function hasLocalUses(variable: TSESLint.Scope.Variable | undefined) { + return Boolean( + variable?.references.some( + reference => !reference.init && reference.identifier.parent?.type !== AST_NODE_TYPES.ExportSpecifier, + ), + ); +} + +/** + * Identifier of a bare `type Local = Imported` alias, or `null` when the alias declares type + * parameters, instantiates its target, or composes it with anything else — in all of those cases + * the alias is a new type rather than another name for the imported one. + */ +function getAliasedTypeReference(typeAliasDeclaration: TSESTree.TSTypeAliasDeclaration): TSESTree.Identifier | null { + const { typeAnnotation } = typeAliasDeclaration; + + if ( + typeAliasDeclaration.typeParameters || + typeAnnotation.type !== AST_NODE_TYPES.TSTypeReference || + typeAnnotation.typeName.type !== AST_NODE_TYPES.Identifier || + typeAnnotation.typeArguments + ) { + return null; + } + + return typeAnnotation.typeName; +} + +/** Identifier that a local `const local = imported` / `type Local = Imported` declaration aliases. */ +function getAliasTarget(definitionNode: TSESTree.Node): TSESTree.Identifier | null { + if (definitionNode.type === AST_NODE_TYPES.TSTypeAliasDeclaration) { + return getAliasedTypeReference(definitionNode); + } + + if ( + definitionNode.type !== AST_NODE_TYPES.VariableDeclarator || + definitionNode.id.type !== AST_NODE_TYPES.Identifier || + definitionNode.id.typeAnnotation || + definitionNode.init?.type !== AST_NODE_TYPES.Identifier + ) { + return null; + } + + return definitionNode.init; +} + +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 aliasedTypeReference = getAliasedTypeReference(typeAliasDeclaration); + + if (!aliasedTypeReference) { + return; + } + + const trackedImport = getTrackedImport(aliasedTypeReference); + + if (!trackedImport) { + return; + } + + reportIssue('preferTypeReexport', reportNode, exportedName, trackedImport); + } + + function isReassignedDeclarator(variableDeclarator: TSESTree.VariableDeclarator) { + const [declaredVariable] = sourceCode.getDeclaredVariables(variableDeclarator); + + return !declaredVariable || isReassigned(declaredVariable); + } + + function checkVariableDeclarator( + variableDeclarator: TSESTree.VariableDeclarator, + exportedName?: string, + reportNode?: TSESTree.Identifier | TSESTree.StringLiteral, + ) { + if ( + variableDeclarator.id.type !== AST_NODE_TYPES.Identifier || + !variableDeclarator.init || + isReassignedDeclarator(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); + } + } + + /** + * Matches an identity wrapper — a function whose body forwards its own parameters, unchanged + * and in order, to an imported callee: `export const render = props => renderBase(props)`. + * + * The match is purely syntactic and never needs to know what the parameters are typed as; any + * annotation in the signature narrows the public contract, so the rule bails. + * + * A wrapper is not byte-for-byte equivalent — it drops `this`, `fn.length`, `fn.name`, extra + * arguments and `new`-ability — but none of that is observable for these re-export patterns. + */ + 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 && isReassigned(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) { + 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 resolvedVariable = getResolvedVariable(exportSpecifier.local); + + if ( + resolvedVariable?.defs[0]?.type === TSESLint.Scope.DefinitionType.ImportBinding && + hasLocalUses(resolvedVariable) + ) { + return; + } + + 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 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 || isReassigned(resolvedVariable)) { + return; + } + + const aliasTarget = getAliasTarget(definitionNode); + + if (!aliasTarget) { + return; + } + + return getTrackedImport(aliasTarget, 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, + }; + }, +});