diff --git a/packages/eslint-plugin/src/internal.js b/packages/eslint-plugin/src/internal.js index 02c31f5aa97d30..323c79840c04d8 100644 --- a/packages/eslint-plugin/src/internal.js +++ b/packages/eslint-plugin/src/internal.js @@ -35,6 +35,8 @@ const __internal = { /** @type {import('eslint').Linter.RulesRecord} */ rules: { '@nx/workspace-consistent-callback-type': 'error', + '@nx/workspace-base-hook-signature': 'error', + '@nx/workspace-base-hook-no-forbidden-runtime': 'error', '@nx/workspace-no-restricted-globals': restrictedGlobals.react, '@nx/workspace-no-missing-jsx-pragma': ['error', { runtime: 'automatic' }], }, diff --git a/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.ts b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.ts index e6b0b19aca97f1..95d485df9910e2 100644 --- a/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.ts +++ b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.ts @@ -27,6 +27,7 @@ type UseTagGroupBaseOptions = { * @param props - props from this instance of TagGroup (without appearance, size) * @param ref - reference to root HTMLDivElement of TagGroup */ +// eslint-disable-next-line @nx/workspace-base-hook-signature -- accepts an extra `options` arg used internally by `useTagGroup_unstable` to coordinate focus after a tag is dismissed export const useTagGroupBase_unstable = ( props: TagGroupBaseProps, ref: React.Ref, diff --git a/tools/eslint-rules/index.ts b/tools/eslint-rules/index.ts index 6aeaae6c06da8e..b0ed1faac661ed 100644 --- a/tools/eslint-rules/index.ts +++ b/tools/eslint-rules/index.ts @@ -4,6 +4,11 @@ import { RULE_NAME as consistentCallbackTypeName, rule as consistentCallbackType, } from './rules/consistent-callback-type'; +import { RULE_NAME as baseHookSignatureName, rule as baseHookSignature } from './rules/base-hook-signature'; +import { + RULE_NAME as baseHookNoForbiddenRuntimeName, + rule as baseHookNoForbiddenRuntime, +} from './rules/base-hook-no-forbidden-runtime'; /** * Import your custom workspace rules at the top of this file. @@ -32,6 +37,8 @@ module.exports = { */ rules: { [consistentCallbackTypeName]: consistentCallbackType, + [baseHookSignatureName]: baseHookSignature, + [baseHookNoForbiddenRuntimeName]: baseHookNoForbiddenRuntime, [noRestrictedGlobalsName]: noRestrictedGlobals, [noMissingJsxPragmaName]: noMissingJsxPragma, }, diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/dummy.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/dummy.ts new file mode 100644 index 00000000000000..73f11d7b5c58a4 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/dummy.ts @@ -0,0 +1,4 @@ +// Placeholder so the tsconfig has an actual file to anchor the project. +// RuleTester test cases reference filenames inside this directory but their +// content comes from the inline `code` field. +export const dummy = 1; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/test.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/test.ts new file mode 100644 index 00000000000000..1e866fcbc83226 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/test.ts @@ -0,0 +1,4 @@ +// Placeholder anchor for typed RuleTester cases. The actual code being linted +// comes from each test's inline `code` field; this file just needs to exist so +// that the fixture tsconfig's Program contains the filename used by tests. +export {}; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/a.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/a.ts new file mode 100644 index 00000000000000..12e940cc7f1fde --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/a.ts @@ -0,0 +1,5 @@ +import { useB } from './b'; + +export function useA(): number { + return useB() + 1; +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/b.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/b.ts new file mode 100644 index 00000000000000..777b389e45df54 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/b.ts @@ -0,0 +1,6 @@ +import { useA } from './a'; + +export function useB(): number { + // Pretend lazy ref to break true cycle at runtime; the static graph is cyclic. + return (useA as unknown as () => number).length; +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/index.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/index.ts new file mode 100644 index 00000000000000..81c4ddc44291b1 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/index.ts @@ -0,0 +1,3 @@ +// Cyclic re-export — exercises the cycle-safety of the transitive walk. +export { useA } from './a'; +export { useB } from './b'; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/heavy-runtime/index.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/heavy-runtime/index.ts new file mode 100644 index 00000000000000..8f5f7d2b4e91fb --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/heavy-runtime/index.ts @@ -0,0 +1,5 @@ +export function runHeavy(): { tag: 'heavy' } { + return { tag: 'heavy' }; +} + +export type HeavyOptions = { kind: 'heavy' }; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/light-helper/index.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/light-helper/index.ts new file mode 100644 index 00000000000000..9ab573b97e25e7 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/light-helper/index.ts @@ -0,0 +1,3 @@ +export function runLight(_opts?: { mode: 'light' }): void { + /* noop */ +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts new file mode 100644 index 00000000000000..9798a4b8d6b35e --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts @@ -0,0 +1,7 @@ +import { runHeavy } from 'heavy-runtime'; + +export type HeavyType = { tag: 'heavy' }; + +export function useHeavy(): HeavyType { + return runHeavy(); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/index.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/index.ts new file mode 100644 index 00000000000000..ecc54ec64c43da --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/index.ts @@ -0,0 +1,9 @@ +import type { HeavyType } from './heavy'; + +export { useHeavy } from './heavy'; +export { useLight } from './light'; +export type { LightOptions } from './light'; +// Re-export of a type-only thing from the heavy module — must not count as a runtime reach. +export type { HeavyType } from './heavy'; + +export type HeavyWrapper = { tag: 'heavy-wrapper'; inner: HeavyType }; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/light.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/light.ts new file mode 100644 index 00000000000000..970deab9edbbd4 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/light.ts @@ -0,0 +1,7 @@ +import { runLight } from 'light-helper'; + +export type LightOptions = { mode: 'light' }; + +export function useLight(opts?: LightOptions): void { + runLight(opts); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/tsconfig.json b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/tsconfig.json new file mode 100644 index 00000000000000..432b9967e725a7 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "watched-pkg": ["./stubs/watched-pkg/index.ts"], + "watched-pkg/*": ["./stubs/watched-pkg/*"], + "heavy-runtime": ["./stubs/heavy-runtime/index.ts"], + "light-helper": ["./stubs/light-helper/index.ts"], + "cyclic-pkg": ["./stubs/cyclic-pkg/index.ts"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "stubs/**/*.ts"] +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Sibling/useSiblingBase.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Sibling/useSiblingBase.ts new file mode 100644 index 00000000000000..4133e7b12d5f5b --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Sibling/useSiblingBase.ts @@ -0,0 +1,3 @@ +export const useSiblingBase_unstable = (props: { a: number }, ref: React.Ref) => { + return { props, ref }; +}; diff --git a/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.spec.ts b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.spec.ts new file mode 100644 index 00000000000000..40b5b7217c80d5 --- /dev/null +++ b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.spec.ts @@ -0,0 +1,388 @@ +import * as path from 'node:path'; +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { rule, RULE_NAME } from './base-hook-no-forbidden-runtime'; + +const FIXTURE_ROOT = path.join(__dirname, '__fixtures__/base-hook-no-forbidden-runtime'); +const TYPED_FILENAME = 'src/test.ts'; + +const typedLanguageOptions = { + parserOptions: { + project: path.join(FIXTURE_ROOT, 'tsconfig.json'), + tsconfigRootDir: FIXTURE_ROOT, + }, +}; + +// --------------------------------------------------------------------------- +// Untyped checks: direct forbidden imports, scope shadowing, default allow-list, +// and the `typedServicesUnavailable` one-shot warning. +// --------------------------------------------------------------------------- +const ruleTester = new RuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + // Identifier with the same local name as a forbidden import alias does not collide via scope analysis. + { + code: ` + import { useArrowNavigationGroup } from '@fluentui/react-tabster'; + export const useThing_unstable = (props, ref) => { + return useArrowNavigationGroup({}); + }; + export const useThingBase_unstable = (props, ref) => { + const useArrowNavigationGroup = () => 1; + return { value: useArrowNavigationGroup() }; + }; + `, + }, + // \`keyborg\` is not in the default forbidden runtime list — bindings imported from it are allowed inside base hooks. + { + code: ` + import { createKeyborg, KEYBORG_FOCUSIN } from 'keyborg'; + export const useThingBase_unstable = (props, ref) => { + return { kb: createKeyborg(window), evt: KEYBORG_FOCUSIN }; + }; + `, + }, + // No watched/forbidden imports — base hook body is not inspected at all. + { + code: ` + export const useThingBase_unstable = (props, ref) => { + return { props, ref }; + }; + `, + }, + ], + invalid: [ + // Referencing a watched-package binding inside a base hook without typed services available + // surfaces a one-shot `typedServicesUnavailable` diagnostic so the misconfiguration is visible. + { + code: ` + import { useArrowNavigationGroup } from '@fluentui/react-tabster'; + export const useThingBase_unstable = (props, ref) => { + return useArrowNavigationGroup({}); + }; + `, + errors: [ + { + messageId: 'typedServicesUnavailable', + data: { + watchedPackages: '@fluentui/react-tabster', + forbiddenRuntimes: 'tabster', + }, + }, + ], + }, + ], +}); + +// --------------------------------------------------------------------------- +// Forbidden-runtime + transitive-reach checks — require typed linting. +// --------------------------------------------------------------------------- +const typedRuleTester = new RuleTester(); + +const transitiveOptions: readonly [{ watchedPackages: string[]; forbiddenRuntimes: string[] }] = [ + { + watchedPackages: ['watched-pkg'], + forbiddenRuntimes: ['heavy-runtime'], + }, +]; + +const transitiveOptionsAllowTypeImports: readonly [ + { watchedPackages: string[]; forbiddenRuntimes: string[]; allowTypeImports: boolean }, +] = [ + { + watchedPackages: ['watched-pkg'], + forbiddenRuntimes: ['heavy-runtime'], + allowTypeImports: true, + }, +]; + +typedRuleTester.run(`${RULE_NAME} (typed)`, rule, { + valid: [ + // The defining file of \`useLight\` only reaches \`light-helper\`, not \`heavy-runtime\`. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useLight } from 'watched-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + useLight(); + return { props, ref }; + }; + `, + }, + // Type-only import of a watched-package symbol whose defining file does NOT reach the + // forbidden runtime is allowed (`LightOptions` is defined in `light.ts` which only pulls + // `light-helper`). + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import type { LightOptions } from 'watched-pkg'; + export const useThingBase_unstable = (props: LightOptions, ref) => { + return { props, ref }; + }; + `, + }, + // Watched-package import exists but only used by a non-base hook in the same file. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useHeavy } from 'watched-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + return { props, ref }; + }; + export const useThing_unstable = (props, ref) => { + return useHeavy(); + }; + `, + }, + // Cyclic re-export graph must not infinite-loop; \`useA\` does not reach heavy-runtime. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: [ + { + watchedPackages: ['cyclic-pkg'], + forbiddenRuntimes: ['heavy-runtime'], + }, + ], + code: ` + import { useA } from 'cyclic-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + return { props, ref, value: useA() }; + }; + `, + }, // With `allowTypeImports: true`, type-only imports from a forbidden runtime are permitted. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptionsAllowTypeImports, + code: ` + import type { HeavyOptions } from 'heavy-runtime'; + export const useThingBase_unstable = (props: HeavyOptions, ref) => { + return { props, ref }; + }; + `, + }, + // With `allowTypeImports: true`, per-specifier type-only import from a forbidden runtime is permitted. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptionsAllowTypeImports, + code: ` + import { type HeavyOptions } from 'heavy-runtime'; + export const useThingBase_unstable = (props: HeavyOptions, ref) => { + return { props, ref }; + }; + `, + }, + // Symmetric `allowTypeImports`: also exempts watched-package type-only imports whose defining + // module reaches a forbidden runtime (no runtime coupling is possible from a type). + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptionsAllowTypeImports, + code: ` + import type { HeavyType } from 'watched-pkg'; + export const useThingBase_unstable = (props: HeavyType, ref) => { + return { props, ref }; + }; + `, + }, + ], + invalid: [ + // Direct import from a forbidden-runtime package. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { runHeavy } from 'heavy-runtime'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + return { props, ref, x: runHeavy() }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeDirect', + data: { + hookName: 'useThingBase_unstable', + importedName: 'runHeavy', + package: 'heavy-runtime', + }, + }, + ], + }, + // Symbol from watched package whose defining file transitively imports the forbidden runtime. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useHeavy } from 'watched-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + return { props, ref, x: useHeavy() }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeReach', + data: { + hookName: 'useThingBase_unstable', + importedName: 'useHeavy', + package: 'watched-pkg', + runtime: 'heavy-runtime', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts', + }, + }, + ], + }, + // Aliased import from a forbidden-runtime package is still flagged on the alias use site. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { runHeavy as go } from 'heavy-runtime'; + export function useThingBase_unstable(props: { a: number }, ref) { + return go(); + } + `, + errors: [ + { + messageId: 'forbiddenRuntimeDirect', + data: { + hookName: 'useThingBase_unstable', + importedName: 'runHeavy', + package: 'heavy-runtime', + }, + }, + ], + }, + // By default, a top-level type-only import from a forbidden runtime is disallowed. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import type { HeavyOptions } from 'heavy-runtime'; + export const useThingBase_unstable = (props: HeavyOptions, ref) => { + return { props, ref }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeDirect', + data: { + hookName: 'useThingBase_unstable', + importedName: 'HeavyOptions', + package: 'heavy-runtime', + }, + }, + ], + }, + // By default, a per-specifier type-only import from a forbidden runtime is also disallowed. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { type HeavyOptions } from 'heavy-runtime'; + export const useThingBase_unstable = (props: HeavyOptions, ref) => { + return { props, ref }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeDirect', + data: { + hookName: 'useThingBase_unstable', + importedName: 'HeavyOptions', + package: 'heavy-runtime', + }, + }, + ], + }, + // Type-leakage through a watched package: a top-level `import type` of a watched-package + // symbol whose defining module transitively reaches the forbidden runtime is disallowed + // because the type still ties the base hook's public API to the forbidden runtime. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import type { HeavyType } from 'watched-pkg'; + export const useThingBase_unstable = (props: HeavyType, ref) => { + return { props, ref }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeReach', + data: { + hookName: 'useThingBase_unstable', + importedName: 'HeavyType', + package: 'watched-pkg', + runtime: 'heavy-runtime', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts', + }, + }, + ], + }, + // Per-specifier `type` modifier variant of the same scenario. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { type HeavyType, useLight } from 'watched-pkg'; + export const useThingBase_unstable = (props: HeavyType, ref) => { + useLight(); + return { props, ref }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeReach', + data: { + hookName: 'useThingBase_unstable', + importedName: 'HeavyType', + package: 'watched-pkg', + runtime: 'heavy-runtime', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts', + }, + }, + ], + }, + // Indirect type leakage: `HeavyWrapper` is declared in `watched-pkg/index.ts` (not in `heavy.ts`), + // but its defining file value-re-exports `./heavy`, so the type-graph reach from `index.ts` still + // includes `heavy-runtime`. The base hook surface is therefore tied to the forbidden runtime. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import type { HeavyWrapper } from 'watched-pkg'; + export const useThingBase_unstable = (props: HeavyWrapper, ref) => { + return { props, ref }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeReach', + data: { + hookName: 'useThingBase_unstable', + importedName: 'HeavyWrapper', + package: 'watched-pkg', + runtime: 'heavy-runtime', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/index.ts', + }, + }, + ], + }, + ], +}); diff --git a/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts new file mode 100644 index 00000000000000..75284f067f667c --- /dev/null +++ b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts @@ -0,0 +1,707 @@ +import type { TSESTree, TSESLint, ParserServicesWithTypeInformation } from '@typescript-eslint/utils'; +import { ESLintUtils, AST_NODE_TYPES } from '@typescript-eslint/utils'; +import * as ts from 'typescript'; + +// NOTE: The rule will be available in ESLint configs as "@nx/workspace-base-hook-no-forbidden-runtime" +export const RULE_NAME = 'base-hook-no-forbidden-runtime'; + +/** + * Names of v9 "base hooks": the implementation-only half of a `useFoo` / `useFooBase_unstable` + * pair, kept free of focus/keyboard runtime so it can be composed by callers that may opt out + * of those concerns. + */ +const BASE_HOOK_NAME_PATTERN = /^use[A-Z]\w*Base_unstable$/; + +/** + * Any function-literal form a base hook can take: top-level function declaration, inline arrow + * function, or function expression bound to a variable / export. + */ +type BaseHookFunction = + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression; + +const DEFAULT_WATCHED_PACKAGES: ReadonlyArray = ['@fluentui/react-tabster']; +const DEFAULT_FORBIDDEN_RUNTIMES: ReadonlyArray = ['tabster']; + +type Options = [ + { + /** + * Packages whose imported symbols must be analyzed transitively. + * A symbol imported from one of these packages is allowed inside a base + * hook only if its defining source file does not reach any + * `forbiddenRuntimes` package via value imports. + */ + watchedPackages?: string[]; + /** + * Runtime packages whose presence in the transitive value-import graph of + * a referenced symbol is forbidden inside base hooks. Direct imports from + * these packages are also forbidden. + */ + forbiddenRuntimes?: string[]; + /** + * When `true`, type-only imports (both from `forbiddenRuntimes` packages directly and + * from `watchedPackages` whose defining module reaches a forbidden runtime) are permitted + * inside base hooks. Type-only imports emit no runtime code, so this option trades API + * decoupling for ergonomics. + * + * Defaults to `false` — type-only imports are checked the same way as value imports, to + * keep the base hook's public API fully decoupled from forbidden runtimes. + */ + allowTypeImports?: boolean; + }?, +]; + +type MessageIds = 'forbiddenRuntimeDirect' | 'forbiddenRuntimeReach' | 'typedServicesUnavailable'; + +/** + * The original (imported) name of an import specifier, used for diagnostics and for matching + * against a forbidden/watched package's exports. + * + * - named import (`import { Foo }`) → `'Foo'` + * - aliased named import (`import { Foo as Bar }`) → `'Foo'` (the original, not the alias) + * - default import (`import X from 'pkg'`) → `'default'` + * - namespace import (`import * as X`) → `'*'` + */ +type ImportSpecifierNode = + | TSESTree.ImportSpecifier + | TSESTree.ImportDefaultSpecifier + | TSESTree.ImportNamespaceSpecifier; + +/** + * A locally-declared binding originating from a tracked import (a watched or forbidden-runtime + * package). Built when walking `ImportDeclaration` nodes so body references can be matched in + * O(1) via a `Map`. + */ +interface TrackedImport { + /** The package the binding came from (a watched OR forbidden-runtime package). */ + package: string; + /** Original imported name (not the local alias). `default` or `*` for default / namespace. */ + importedName: string; + /** Kind of package — controls how the reference is checked. */ + kind: 'watched' | 'forbidden'; + /** + * `true` when the binding is type-only (either the declaration is `import type ...` + * or the specifier is `import { type Foo }`). Used to gate whether direct usage in a + * value position is even possible (type-only bindings only surface in type positions). + */ + isTypeOnly: boolean; + /** The specifier node (used for symbol lookup via ParserServices). */ + specifier: ImportSpecifierNode; +} + +/** + * Result of a single transitive-reach DFS over a source file's import graph. + * - `value` — packages reachable via value (non type-only) imports only. Used to decide + * whether a runtime reference can pull a forbidden runtime at execution time. + * - `all` — packages reachable via value OR type imports. Used to decide whether a + * type reference can leak a forbidden runtime through the public API surface. + * `value` is always a subset of `all`. + */ +interface Reach { + value: ReadonlySet; + all: ReadonlySet; +} + +const EMPTY_REACH: Reach = { value: new Set(), all: new Set() }; + +/** + * Per-Program cache: source file path → reach sets transitively computed from that file. + * Both `value` and `all` sets are filled in a single DFS pass to share resolution work. + * + * Keyed by `ts.Program` identity so the cache is invalidated whenever + * typescript-eslint rebuilds the Program. + */ +const programCache = new WeakMap>(); + +export const rule = ESLintUtils.RuleCreator(() => __filename)({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: + 'Disallow inside v9 base hooks (`useBase_unstable`) any binding whose defining module transitively pulls a forbidden runtime package (default `tabster`) — both at value positions (runtime coupling) and at type positions (API surface coupling).', + }, + schema: [ + { + type: 'object', + properties: { + watchedPackages: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + }, + forbiddenRuntimes: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + }, + allowTypeImports: { + type: 'boolean', + }, + }, + additionalProperties: false, + }, + ], + messages: { + forbiddenRuntimeDirect: + 'Base hook `{{hookName}}` cannot reference `{{importedName}}` from forbidden runtime package `{{package}}`. Move logic that depends on `{{package}}` to the wrapping `*_unstable` hook instead.', + forbiddenRuntimeReach: + 'Base hook `{{hookName}}` cannot reference `{{importedName}}` from `{{package}}` because its defining module transitively imports forbidden runtime `{{runtime}}` (via `{{viaFile}}`). Move logic that depends on `{{runtime}}` to the wrapping `*_unstable` hook instead.', + typedServicesUnavailable: + 'base-hook-no-forbidden-runtime: transitive runtime analysis was skipped because TypeScript type information is unavailable. Enable typescript-eslint type-aware linting (set `parserOptions.projectService: true` or `parserOptions.project`) so references through watched packages (e.g. `{{watchedPackages}}`) can be verified against forbidden runtimes (e.g. `{{forbiddenRuntimes}}`).', + }, + }, + defaultOptions: [{}], + create(context) { + const sourceCode = context.sourceCode; + const options = context.options[0] ?? {}; + const watchedPackages = new Set(options.watchedPackages ?? DEFAULT_WATCHED_PACKAGES); + const forbiddenRuntimes = new Set(options.forbiddenRuntimes ?? DEFAULT_FORBIDDEN_RUNTIMES); + const allowTypeImports = options.allowTypeImports ?? false; + // `forbidden` takes precedence: if the same name appears in both lists, treat the binding as forbidden. + const trackedPackages = new Set([...watchedPackages, ...forbiddenRuntimes]); + + // Map of locally-declared variable identity → original import origin metadata. Keyed by Variable + // identity (not name) so re-declarations / shadowing inside the base hook resolve correctly. + const trackedImports = new Map(); + + // Tracks whether `computeSymbolReach` was invoked while typed services were unavailable. When set, + // we emit a single diagnostic on `Program:exit` so the user knows transitive analysis was skipped. + let typedServicesNeededButMissing = false; + + // Lazily-acquired typed services. Resolved once per file, cached in `typedServices` (undefined = + // not yet attempted, null = attempted and unavailable, value = available). + let typedServices: ParserServicesWithTypeInformation | null | undefined; + + /** + * Returns typed services (TS Program + checker) for the current file, or `null` if untyped + * lint is in effect. Result is memoized for the lifetime of the per-file rule instance. + */ + function getTypedServices(): ParserServicesWithTypeInformation | null { + if (typedServices !== undefined) { + return typedServices; + } + try { + typedServices = ESLintUtils.getParserServices(context); + } catch { + typedServices = null; + } + return typedServices; + } + + /** + * Walks the base hook's scope graph looking for references to any tracked import. Bails out + * early if nothing is tracked, so the typical case (no watched/forbidden imports in the file) + * stays free. + */ + function checkBodyReferences(hookName: string, hookFn: BaseHookFunction): void { + if (trackedImports.size === 0) { + return; + } + const hookScope = sourceCode.getScope(hookFn); + visitScope(hookScope, hookFn, hookName); + } + + /** + * Recursively visits `scope` and all its descendants that are still inside the base hook body. + * For every resolved reference whose declaration is a tracked import, either flag the direct + * usage or delegate to `computeSymbolReach` for the transitive check. + * + * The chosen reach set depends on the reference position: + * - value reference → `value` reach (runtime coupling) + * - type reference → `all` reach (API coupling — a type alias can still tie the public + * API to a forbidden runtime via its defining module) + */ + function visitScope(scope: TSESLint.Scope.Scope, hookFn: BaseHookFunction, hookName: string): void { + if (!isScopeWithinFunction(scope, hookFn)) { + return; + } + + scope.references.forEach(reference => { + const resolved = reference.resolved; + if (!resolved) { + return; + } + const origin = trackedImports.get(resolved); + if (!origin) { + return; + } + + const isTypeRef = reference.isTypeReference === true; + // A type-only binding can only legally appear in type positions; ignore the (invalid) + // value reference — TS will flag it independently. + if (origin.isTypeOnly && !isTypeRef) { + return; + } + + if (origin.kind === 'forbidden') { + context.report({ + node: reference.identifier, + messageId: 'forbiddenRuntimeDirect', + data: { + hookName, + importedName: origin.importedName, + package: origin.package, + }, + }); + return; + } + + // Watched package: only flag if the defining module transitively reaches a forbidden runtime. + const reach = computeSymbolReach(origin); + if (!reach) { + return; // untyped lint or unresolvable — silently skip + } + const reached = isTypeRef ? reach.all : reach.value; + + for (const runtime of forbiddenRuntimes) { + if (reached.has(runtime)) { + context.report({ + node: reference.identifier, + messageId: 'forbiddenRuntimeReach', + data: { + hookName, + importedName: origin.importedName, + package: origin.package, + runtime, + viaFile: reach.viaFile, + }, + }); + return; + } + } + }); + + scope.childScopes.forEach(child => visitScope(child, hookFn, hookName)); + } + + /** + * Resolves the watched-package import to its defining module via TS `Program`, then queries the + * transitive import graph for forbidden runtimes (both value-only and value+type sets). + * Returns `null` (and flips the `typedServicesNeededButMissing` flag) when typed services + * aren't available, so the caller can silently skip and we can warn once on `Program:exit`. + */ + function computeSymbolReach( + origin: TrackedImport, + ): { value: ReadonlySet; all: ReadonlySet; viaFile: string } | null { + const services = getTypedServices(); + if (!services) { + typedServicesNeededButMissing = true; + return null; + } + const checker = services.program.getTypeChecker(); + const tsNode = services.esTreeNodeToTSNodeMap.get(origin.specifier); + if (!tsNode) { + return null; + } + + // For an ImportSpecifier we want the imported (right-hand) identifier so the symbol resolves to + // the exported name on the source module, not the local alias. + let nameNode: ts.Node | undefined; + if (ts.isImportSpecifier(tsNode)) { + nameNode = tsNode.propertyName ?? tsNode.name; + } else if (ts.isImportClause(tsNode) || ts.isNamespaceImport(tsNode)) { + nameNode = tsNode.name; + } else { + nameNode = tsNode; + } + if (!nameNode) { + return null; + } + + let symbol = checker.getSymbolAtLocation(nameNode); + if (!symbol) { + return null; + } + // eslint-disable-next-line no-bitwise -- ts.SymbolFlags is a bitfield enum + if ((symbol.flags & ts.SymbolFlags.Alias) !== 0) { + try { + symbol = checker.getAliasedSymbol(symbol); + } catch { + return null; + } + } + const declaration = symbol.declarations?.[0]; + const definingFile = declaration?.getSourceFile(); + if (!definingFile) { + return null; + } + + const reach = transitiveReach(services.program, definingFile); + return { value: reach.value, all: reach.all, viaFile: shortenPath(definingFile.fileName) }; + } + + /** + * `ImportDeclaration` visitor: records every named/default/namespace specifier coming from a + * watched or forbidden-runtime package so body references can later be resolved via + * `sourceCode.getDeclaredVariables`. Tracks both value AND type-only specifiers — type refs + * are still inspected at the hook signature because a type from a watched package can + * transitively expose a forbidden runtime through its defining module. + */ + function trackImportDeclaration(node: TSESTree.ImportDeclaration): void { + const source = node.source.value; + if (typeof source !== 'string' || !trackedPackages.has(source)) { + return; + } + const isForbiddenPkg = forbiddenRuntimes.has(source); + const stmtTypeOnly = node.importKind === 'type'; + // Symmetric semantics: when `allowTypeImports` is true, type-only imports are exempt from + // both direct forbidden-runtime checks AND transitive watched-package reach checks (a type + // can never pull runtime code at execution time). + if (stmtTypeOnly && allowTypeImports) { + return; + } + const kind: TrackedImport['kind'] = isForbiddenPkg ? 'forbidden' : 'watched'; + + node.specifiers.forEach(specifier => { + const specTypeOnly = + stmtTypeOnly || (specifier.type === AST_NODE_TYPES.ImportSpecifier && specifier.importKind === 'type'); + if (specTypeOnly && allowTypeImports) { + return; + } + const importedName = getImportedName(specifier as ImportSpecifierNode); + if (importedName === undefined) { + return; + } + for (const variable of sourceCode.getDeclaredVariables(specifier)) { + trackedImports.set(variable, { + package: source, + importedName, + kind, + isTypeOnly: specTypeOnly, + specifier: specifier as ImportSpecifierNode, + }); + } + }); + } + + return { + ImportDeclaration: trackImportDeclaration, + + // Match only base hooks — wrapping state hook signature is enforced by the sibling + // `base-hook-signature` rule (which also handles pair detection). + [`FunctionDeclaration[id.name=/${BASE_HOOK_NAME_PATTERN.source}/]`]: (node: TSESTree.FunctionDeclaration) => { + if (!node.id) { + return; + } + checkBodyReferences(node.id.name, node); + }, + + [`VariableDeclarator[id.name=/${BASE_HOOK_NAME_PATTERN.source}/]`]: (node: TSESTree.VariableDeclarator) => { + const init = getFunctionInit(node); + if (!init || node.id.type !== AST_NODE_TYPES.Identifier) { + return; + } + checkBodyReferences(node.id.name, init); + }, + + /** + * One-shot diagnostic so the user is informed (rather than silently degraded) when the + * transitive runtime check was needed but skipped due to missing typed services. + */ + 'Program:exit'(programNode) { + if (!typedServicesNeededButMissing) { + return; + } + context.report({ + node: programNode, + messageId: 'typedServicesUnavailable', + data: { + watchedPackages: [...watchedPackages].join(', '), + forbiddenRuntimes: [...forbiddenRuntimes].join(', '), + }, + }); + }, + }; + }, +}); + +// --------------------------------------------------------------------------- +// Import-specifier helpers +// --------------------------------------------------------------------------- + +function getImportedName(specifier: ImportSpecifierNode): string | undefined { + switch (specifier.type) { + case AST_NODE_TYPES.ImportSpecifier: + return specifier.imported.type === AST_NODE_TYPES.Identifier + ? specifier.imported.name + : String(specifier.imported.value); + case AST_NODE_TYPES.ImportDefaultSpecifier: + return 'default'; + case AST_NODE_TYPES.ImportNamespaceSpecifier: + return '*'; + default: + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Scope helpers +// --------------------------------------------------------------------------- + +/** + * Returns the function literal initializer of a `VariableDeclarator` when the declarator is a + * plain Identifier bound to an inline arrow/function expression; otherwise `undefined`. + */ +function getFunctionInit(node: TSESTree.VariableDeclarator): BaseHookFunction | undefined { + if (node.id.type !== AST_NODE_TYPES.Identifier) { + return undefined; + } + const init = node.init; + if ( + !init || + (init.type !== AST_NODE_TYPES.ArrowFunctionExpression && init.type !== AST_NODE_TYPES.FunctionExpression) + ) { + return undefined; + } + return init; +} + +/** + * `true` when `scope` (or any of its ancestor scopes) is the function scope of `hookFn`. Used to + * confine the body-reference walk to the base hook itself — references in sibling functions are + * out of scope for this rule. + */ +function isScopeWithinFunction(scope: TSESLint.Scope.Scope, hookFn: BaseHookFunction): boolean { + let current: TSESLint.Scope.Scope | null = scope; + while (current) { + if (current.block === hookFn) { + return true; + } + current = current.upper; + } + return false; +} + +// --------------------------------------------------------------------------- +// Transitive value/type reach analysis +// --------------------------------------------------------------------------- + +/** + * Returns the bare package specifiers transitively reachable from `sourceFile`, computed in two + * granularities in a single DFS pass: + * - `value` — only value (non type-only) imports are followed. Used to decide whether a runtime + * reference can pull a forbidden runtime at execution time. + * - `all` — both value and type imports are followed. Used to decide whether a type reference + * can leak a forbidden runtime through the public API surface of a base hook. + * + * Memoized per Program × file. Cycle-safe. + */ +function transitiveReach(program: ts.Program, sourceFile: ts.SourceFile): Reach { + let cache = programCache.get(program); + if (!cache) { + cache = new Map(); + programCache.set(program, cache); + } + return computeReach(program, sourceFile, cache, new Set()); +} + +/** + * Recursive worker for `transitiveReach`. Walks the import graph DFS, recording every bare + * specifier encountered (separately for value-only vs value+type follow modes) and recursing into + * each resolved source file. Uses `inProgress` to break cycles (cycle hits return empty sets + * without caching, so the originating call still commits the complete result). + */ +function computeReach( + program: ts.Program, + sourceFile: ts.SourceFile, + cache: Map, + inProgress: Set, +): Reach { + const cached = cache.get(sourceFile.fileName); + if (cached) { + return cached; + } + if (inProgress.has(sourceFile.fileName)) { + // Cycle: return empty sets without caching so the eventual full result is committed by the originator. + return EMPTY_REACH; + } + inProgress.add(sourceFile.fileName); + + const value = new Set(); + const all = new Set(); + for (const imp of collectImports(sourceFile)) { + if (isBareSpecifier(imp.specifier)) { + const pkg = packageNameOf(imp.specifier); + all.add(pkg); + if (!imp.typeOnly) { + value.add(pkg); + } + } + const resolved = resolveModule(program, sourceFile, imp.specifier, imp.literal); + if (!resolved) { + continue; + } + const childSourceFile = program.getSourceFile(resolved); + if (!childSourceFile) { + continue; + } + const childReach = computeReach(program, childSourceFile, cache, inProgress); + for (const pkg of childReach.all) { + all.add(pkg); + } + if (!imp.typeOnly) { + // A type-only edge does not propagate runtime reach: it can only widen the `all` set. + for (const pkg of childReach.value) { + value.add(pkg); + } + } + } + + inProgress.delete(sourceFile.fileName); + const result: Reach = { value, all }; + cache.set(sourceFile.fileName, result); + return result; +} + +interface ImportEdge { + specifier: string; + literal: ts.StringLiteralLike; + /** `true` when this edge only carries type information (no runtime side-effect). */ + typeOnly: boolean; +} + +/** + * Enumerates every module specifier in `sourceFile`, tagging each edge as value (`typeOnly: false`) + * or type-only (`typeOnly: true`). `import type` / `export type`, fully type-only named import or + * export clauses, and `import type =` are emitted with `typeOnly: true`. Side-effect imports + * (no clause) are emitted as value edges. + */ +function collectImports(sourceFile: ts.SourceFile): ImportEdge[] { + const result: ImportEdge[] = []; + for (const stmt of sourceFile.statements) { + if (ts.isImportDeclaration(stmt)) { + let typeOnly = false; + if (stmt.importClause?.isTypeOnly) { + typeOnly = true; + } else if ( + stmt.importClause && + stmt.importClause.namedBindings && + ts.isNamedImports(stmt.importClause.namedBindings) + ) { + const named = stmt.importClause.namedBindings; + const hasValue = !!stmt.importClause.name || named.elements.some(element => !element.isTypeOnly); + typeOnly = !hasValue; + } + if (ts.isStringLiteralLike(stmt.moduleSpecifier)) { + result.push({ specifier: stmt.moduleSpecifier.text, literal: stmt.moduleSpecifier, typeOnly }); + } + continue; + } + if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier && ts.isStringLiteralLike(stmt.moduleSpecifier)) { + let typeOnly = stmt.isTypeOnly; + if (!typeOnly && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) { + typeOnly = stmt.exportClause.elements.every(element => element.isTypeOnly); + } + result.push({ specifier: stmt.moduleSpecifier.text, literal: stmt.moduleSpecifier, typeOnly }); + continue; + } + if ( + ts.isImportEqualsDeclaration(stmt) && + ts.isExternalModuleReference(stmt.moduleReference) && + ts.isStringLiteralLike(stmt.moduleReference.expression) + ) { + result.push({ + specifier: stmt.moduleReference.expression.text, + literal: stmt.moduleReference.expression, + typeOnly: stmt.isTypeOnly, + }); + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Module resolution helpers +// --------------------------------------------------------------------------- + +/** + * Resolves `specifier` (as used in `sourceFile`) to an absolute file path using the same algorithm + * the host TS Program uses. Prefers the (faster) `program.getResolvedModule` API exposed in TS ≥ 5.3 + * and falls back to `ts.resolveModuleName` for older toolchains. Returns `undefined` if the module + * cannot be resolved (e.g. ambient declarations, broken paths). + */ +function resolveModule( + program: ts.Program, + sourceFile: ts.SourceFile, + specifier: string, + literal: ts.StringLiteralLike, +): string | undefined { + // TS ≥ 5.3 exposes program.getResolvedModule + const getResolvedModule = ( + program as unknown as { + getResolvedModule?: ( + file: ts.SourceFile, + moduleName: string, + mode?: ts.ResolutionMode, + ) => { resolvedModule?: ts.ResolvedModuleFull } | undefined; + } + ).getResolvedModule; + const mode = ( + ts as unknown as { + getModeForUsageLocation?: (file: ts.SourceFile, usage: ts.StringLiteralLike) => ts.ResolutionMode; + } + ).getModeForUsageLocation?.(sourceFile, literal); + + if (typeof getResolvedModule === 'function') { + const resolutionResult = getResolvedModule.call(program, sourceFile, specifier, mode); + if (resolutionResult?.resolvedModule) { + return resolutionResult.resolvedModule.resolvedFileName; + } + } + + // Fallback for older TS: use ts.resolveModuleName against the compiler host. + const compilerOptions = program.getCompilerOptions(); + const host = + (program as unknown as { getCompilerHost?: () => ts.ModuleResolutionHost }).getCompilerHost?.() ?? ts.sys; + const result = ts.resolveModuleName( + specifier, + sourceFile.fileName, + compilerOptions, + host as ts.ModuleResolutionHost, + undefined, + undefined, + mode, + ); + return result.resolvedModule?.resolvedFileName; +} + +/** + * `true` when the import specifier refers to a package (e.g. `react`, `@scope/pkg`, `pkg/sub`) + * rather than a relative or absolute path. + */ +function isBareSpecifier(specifier: string): boolean { + return !specifier.startsWith('.') && !specifier.startsWith('/'); +} + +/** + * Extracts the npm package name from a bare specifier. Handles both unscoped (`pkg/sub` → `pkg`) + * and scoped (`@scope/pkg/sub` → `@scope/pkg`) forms. + */ +function packageNameOf(specifier: string): string { + if (specifier.startsWith('@')) { + const [scope, name] = specifier.split('/', 2); + return name ? `${scope}/${name}` : scope; + } + const slash = specifier.indexOf('/'); + return slash === -1 ? specifier : specifier.slice(0, slash); +} + +/** + * Shortens an absolute file path for display in diagnostics: returns the part after the last + * `node_modules/` segment when present (so users see e.g. `tabster/dist/index.js`), or makes the + * path workspace-relative when inside the current working directory. + */ +function shortenPath(absolute: string): string { + const marker = '/node_modules/'; + const idx = absolute.lastIndexOf(marker); + if (idx !== -1) { + return absolute.slice(idx + marker.length); + } + const cwd = process.cwd(); + if (absolute.startsWith(cwd)) { + return absolute.slice(cwd.length + 1); + } + return absolute; +} diff --git a/tools/eslint-rules/rules/base-hook-signature.spec.ts b/tools/eslint-rules/rules/base-hook-signature.spec.ts new file mode 100644 index 00000000000000..8dd4ed49e25a5a --- /dev/null +++ b/tools/eslint-rules/rules/base-hook-signature.spec.ts @@ -0,0 +1,241 @@ +import * as path from 'node:path'; +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { rule, RULE_NAME } from './base-hook-signature'; + +const FIXTURE_ROOT = path.join(__dirname, '__fixtures__/base-hook-signature'); +const SIBLING_FILENAME = path.join(FIXTURE_ROOT, 'src/components/Sibling/useSibling.ts'); +const ORPHAN_FILENAME = path.join(FIXTURE_ROOT, 'src/components/Orphan/useOrphan.ts'); + +const ruleTester = new RuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + // Valid base hook: namespace import — `import * as React from 'react'` + `React.Ref<...>`. + { + code: ` + import * as React from 'react'; + export const useThingBase_unstable = (props, ref: React.Ref) => { + return { props, ref }; + }; + `, + }, + // Valid base hook: named import — `import { Ref } from 'react'` + `Ref<...>` (FunctionDeclaration form). + { + code: ` + import { Ref } from 'react'; + export function useThingBase_unstable(props, ref: Ref) { + return { props, ref }; + } + `, + }, + // Valid base hook: default import — `import React from 'react'` + `React.Ref<...>`. + { + code: ` + import React from 'react'; + export const useThingBase_unstable = (props, ref: React.Ref) => { + return { props, ref }; + }; + `, + }, + // Valid base hook with only \`props\` (ref is optional). + { + code: ` + export const useThingBase_unstable = (props) => { + return { props }; + }; + `, + }, + // Non-base hook without a paired base hook is not subject to the contract. + { + code: ` + export const useThing_unstable = (props, ref, extra) => { + return { props, ref, extra }; + }; + `, + }, + // Pair detection (same file): a state hook `useThing_unstable` next to its base hook + // `useThingBase_unstable` IS subject to the contract. Correct signature passes. + { + code: ` + import * as React from 'react'; + export const useThing_unstable = (props, ref: React.Ref) => { + return { props, ref }; + }; + export const useThingBase_unstable = (props, ref: React.Ref) => { + return { props, ref }; + }; + `, + }, + // Pair detection (no sibling base hook on disk): `useOrphanContextValues_unstable(state)` + // is NOT a paired wrapping hook and must NOT be flagged for its non-(props, ref) signature. + // The Orphan folder has no `useOrphanContextValuesBase.ts(x)` next to it. + { + filename: ORPHAN_FILENAME, + code: ` + export function useOrphanContextValues_unstable(state) { + return { state }; + } + `, + }, + // Pair detection (sibling file): wrapping state hook lives in `useSibling.ts`, paired with + // `useSiblingBase.ts` in the same folder. Correct (props, ref) signature passes. + { + filename: SIBLING_FILENAME, + code: ` + import * as React from 'react'; + export const useSibling_unstable = (props, ref: React.Ref) => { + return { props, ref }; + }; + `, + }, + ], + invalid: [ + // Too few params (0). + { + code: ` + export const useThingBase_unstable = () => ({}); + `, + errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThingBase_unstable', actual: 0 } }], + }, + // Too many params. + { + code: ` + export const useThingBase_unstable = (props, ref, extra) => ({ props, ref, extra }); + `, + errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThingBase_unstable', actual: 3 } }], + }, + // Wrong param names. + { + code: ` + export const useThingBase_unstable = (p, r) => ({ p, r }); + `, + errors: [ + { + messageId: 'invalidParamName', + data: { hookName: 'useThingBase_unstable', index: 1, expected: 'props', actual: 'p' }, + }, + { + messageId: 'invalidParamName', + data: { hookName: 'useThingBase_unstable', index: 2, expected: 'ref', actual: 'r' }, + }, + ], + }, + // ObjectPattern for \`props\` is not allowed. + { + code: ` + import * as React from 'react'; + export const useThingBase_unstable = ({ a }, ref: React.Ref) => ({ a, ref }); + `, + errors: [ + { + messageId: 'invalidParamName', + data: { hookName: 'useThingBase_unstable', index: 1, expected: 'props', actual: '{ ... }' }, + }, + ], + }, + // \`ref\` parameter without a type annotation. + { + code: ` + export const useThingBase_unstable = (props, ref) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: '' }, + }, + ], + }, + // \`ref\` parameter typed as something other than React.Ref. + { + code: ` + export const useThingBase_unstable = (props, ref: HTMLElement) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: 'HTMLElement' }, + }, + ], + }, + // \`ref\` parameter typed as React.ForwardedRef (must be React.Ref). + { + code: ` + export const useThingBase_unstable = (props, ref: React.ForwardedRef) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: 'React.ForwardedRef' }, + }, + ], + }, + // \`Ref\` is a locally declared type alias, not imported from react. + { + code: ` + type Ref = { current: T | null }; + export const useThingBase_unstable = (props, ref: Ref) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: 'Ref' }, + }, + ], + }, + // \`Ref\` imported from a non-react package is not accepted. + { + code: ` + import { Ref } from 'not-react'; + export const useThingBase_unstable = (props, ref: Ref) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: 'Ref' }, + }, + ], + }, + // \`React\` is a locally declared identifier, not the react module namespace. + { + code: ` + const React = { Ref: null }; + export const useThingBase_unstable = (props, ref: React.Ref) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: 'React.Ref' }, + }, + ], + }, + // Pair detection (same file): wrapping state hook with too many params is flagged because + // its sibling base hook in the same file marks it as a paired wrapper. + { + code: ` + import * as React from 'react'; + export const useThing_unstable = (props, ref, extra) => ({ props, ref, extra }); + export const useThingBase_unstable = (props, ref: React.Ref) => ({ props, ref }); + `, + errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThing_unstable', actual: 3 } }], + }, + // Pair detection (sibling file): wrapping state hook in `useSibling.ts` is paired with + // `useSiblingBase.ts` in the same folder. Wrong param names are flagged. + { + filename: SIBLING_FILENAME, + code: ` + import * as React from 'react'; + export const useSibling_unstable = (p, r: React.Ref) => ({ p, r }); + `, + errors: [ + { + messageId: 'invalidParamName', + data: { hookName: 'useSibling_unstable', index: 1, expected: 'props', actual: 'p' }, + }, + { + messageId: 'invalidParamName', + data: { hookName: 'useSibling_unstable', index: 2, expected: 'ref', actual: 'r' }, + }, + ], + }, + ], +}); diff --git a/tools/eslint-rules/rules/base-hook-signature.ts b/tools/eslint-rules/rules/base-hook-signature.ts new file mode 100644 index 00000000000000..4b178e4fc380de --- /dev/null +++ b/tools/eslint-rules/rules/base-hook-signature.ts @@ -0,0 +1,390 @@ +import type { TSESTree, TSESLint } from '@typescript-eslint/utils'; +import { ESLintUtils, AST_NODE_TYPES } from '@typescript-eslint/utils'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// NOTE: The rule will be available in ESLint configs as "@nx/workspace-base-hook-signature" +export const RULE_NAME = 'base-hook-signature'; + +/** + * Names of v9 "base hooks": the implementation-only half of a `useFoo` / `useFooBase_unstable` + * pair, kept free of focus/keyboard runtime so it can be composed by callers that may opt out + * of those concerns. + */ +const BASE_HOOK_NAME_PATTERN = /^use[A-Z]\w*Base_unstable$/; + +/** + * Names of any `_unstable` hook, including base hooks themselves. Used in the rule's selector + * which then dispatches by whether the name matches `BASE_HOOK_NAME_PATTERN` (always checked) + * or is a wrapping state hook paired with a base hook (only checked when a pair exists). + */ +const STATE_HOOK_NAME_PATTERN = /^use[A-Z]\w*_unstable$/; + +const BASE_SUFFIX = 'Base_unstable'; +const UNSTABLE_SUFFIX = '_unstable'; +const SIBLING_EXTENSIONS: ReadonlyArray = ['.ts', '.tsx']; + +const EXPECTED_PARAM_NAMES = ['props', 'ref'] as const; +const MIN_PARAM_COUNT = 1; +const MAX_PARAM_COUNT = 2; + +/** + * Any function-literal form a base or paired state hook can take: top-level function declaration, + * inline arrow function, or function expression bound to a variable / export. The signature rule + * runs the same parameter validation against all three. + */ +type BaseHookFunction = + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression; + +type Options = []; + +type MessageIds = 'invalidParamCount' | 'invalidParamName' | 'invalidRefType'; + +export const rule = ESLintUtils.RuleCreator(() => __filename)({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: + 'Enforce the API contract for v9 "base" hooks (`useBase_unstable`) and their paired wrapping state hooks (`use_unstable` declared in the same file or a sibling component-folder file): a required `props` parameter and an optional `ref` parameter typed as `React.Ref<...>`.', + }, + schema: [], + messages: { + invalidParamCount: + 'Hook `{{hookName}}` must take 1 or 2 positional parameters (`props`, optional `ref`), got {{actual}}.', + invalidParamName: + 'Hook `{{hookName}}` parameter #{{index}} must be named `{{expected}}` (Identifier), got `{{actual}}`.', + invalidRefType: 'Hook `{{hookName}}` parameter `ref` must be typed as `React.Ref<...>`, got `{{actual}}`.', + }, + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + const pairDetector = createPairDetector(context.filename); + + /** + * Validates the hook signature: 1 or 2 positional params, first must be Identifier `props`, + * optional second must be Identifier `ref` typed as `React.Ref<...>` (verified to originate + * from the `react` package so collisions with same-named locals don't pass). + */ + function checkParameters(hookName: string, hookFn: BaseHookFunction, reportNode: TSESTree.Node): void { + if (hookFn.params.length < MIN_PARAM_COUNT || hookFn.params.length > MAX_PARAM_COUNT) { + context.report({ + node: reportNode, + messageId: 'invalidParamCount', + data: { hookName, actual: hookFn.params.length }, + }); + return; + } + + hookFn.params.forEach((param, index) => { + const expected = EXPECTED_PARAM_NAMES[index]; + if (param.type !== AST_NODE_TYPES.Identifier || param.name !== expected) { + context.report({ + node: reportNode, + messageId: 'invalidParamName', + data: { hookName, index: index + 1, expected, actual: describeParam(param) }, + }); + return; + } + if (index === 1 && !isReactRefTypeAnnotation(param.typeAnnotation, sourceCode.getScope(param))) { + context.report({ + node: reportNode, + messageId: 'invalidRefType', + data: { hookName, actual: describeRefType(param.typeAnnotation) }, + }); + } + }); + } + + return { + // Populate the pair detector's same-file index from top-level declarations so the state-hook + // visitor can synchronously decide pairing for the same-file case (82 / 85 occurrences across + // react-components). + Program(node: TSESTree.Program): void { + for (const stmt of node.body) { + collectBaseHookNames(stmt, pairDetector.baseHooksInCurrentFile); + } + }, + + // Broader selector matches both base hooks and state-hook candidates; the handler dispatches + // by name. State hooks only get the signature check when paired with a sibling base hook. + [`FunctionDeclaration[id.name=/${STATE_HOOK_NAME_PATTERN.source}/]`]: (node: TSESTree.FunctionDeclaration) => { + // `export default function () {}` produces an anonymous FunctionDeclaration (id === null). + // The esquery selector above requires `id.name`, so this branch should be unreachable in + // practice — kept as a type-narrowing guard so TS treats `node.id` as non-null below. + if (!node.id) { + return; + } + const name = node.id.name; + if (BASE_HOOK_NAME_PATTERN.test(name) || pairDetector.hasPairedBaseHook(name)) { + checkParameters(name, node, node.id); + } + }, + + [`VariableDeclarator[id.name=/${STATE_HOOK_NAME_PATTERN.source}/]`]: (node: TSESTree.VariableDeclarator) => { + const init = getFunctionInit(node); + if (!init || node.id.type !== AST_NODE_TYPES.Identifier) { + return; + } + const name = node.id.name; + if (BASE_HOOK_NAME_PATTERN.test(name) || pairDetector.hasPairedBaseHook(name)) { + checkParameters(name, init, node.id); + } + }, + }; + }, +}); + +// --------------------------------------------------------------------------- +// AST helpers +// --------------------------------------------------------------------------- + +/** + * Returns a human-readable label for a function parameter, used in `invalidParamName` diagnostics + * so the user sees what they actually wrote (destructuring, rest, default value, …) instead of + * just `Identifier`. + */ +function describeParam(param: TSESTree.Parameter): string { + switch (param.type) { + case AST_NODE_TYPES.Identifier: + return param.name; + case AST_NODE_TYPES.ObjectPattern: + return '{ ... }'; + case AST_NODE_TYPES.ArrayPattern: + return '[ ... ]'; + case AST_NODE_TYPES.RestElement: + return '...rest'; + case AST_NODE_TYPES.AssignmentPattern: + return param.left.type === AST_NODE_TYPES.Identifier ? `${param.left.name} = …` : '… = …'; + default: + return param.type; + } +} + +/** + * Collects names of top-level declarations matching `BASE_HOOK_NAME_PATTERN` into `out`. + * Handles both `export const useFooBase_unstable = ...` (incl. `export const` chains) and + * `export function useFooBase_unstable() {}`, plus the unexported / `export { ... }` forms. + */ +function collectBaseHookNames(stmt: TSESTree.Node, out: Set): void { + // `export const useFooBase_unstable = ...` / `export function useFooBase_unstable() {}` + if (stmt.type === AST_NODE_TYPES.ExportNamedDeclaration && stmt.declaration) { + collectBaseHookNames(stmt.declaration, out); + return; + } + // `function useFooBase_unstable() {}` + if (stmt.type === AST_NODE_TYPES.FunctionDeclaration) { + if (stmt.id && BASE_HOOK_NAME_PATTERN.test(stmt.id.name)) { + out.add(stmt.id.name); + } + return; + } + // `const useFooBase_unstable = ...` (incl. multi-declarator forms) + if (stmt.type === AST_NODE_TYPES.VariableDeclaration) { + for (const decl of stmt.declarations) { + if (decl.id.type === AST_NODE_TYPES.Identifier && BASE_HOOK_NAME_PATTERN.test(decl.id.name)) { + out.add(decl.id.name); + } + } + } +} + +/** + * Returns the function literal initializer of a `VariableDeclarator` when the declarator is a + * plain Identifier bound to an inline arrow/function expression; otherwise `undefined`. Skips + * destructuring patterns (no inspectable function literal) and non-function initializers. + */ +function getFunctionInit(node: TSESTree.VariableDeclarator): BaseHookFunction | undefined { + if (node.id.type !== AST_NODE_TYPES.Identifier) { + return undefined; + } + const init = node.init; + if ( + !init || + (init.type !== AST_NODE_TYPES.ArrowFunctionExpression && init.type !== AST_NODE_TYPES.FunctionExpression) + ) { + return undefined; + } + return init; +} + +/** + * Stateful helper that decides whether a wrapping state hook (`useFoo_unstable`) is paired with + * a sibling base hook (`useFooBase_unstable`) — either declared in the same file or as a sibling + * `.ts` / `.tsx` file in the same directory. The base hook is the structural marker; when found, + * the wrapping hook is required to honor the `(props, ref)` signature contract. + * + * Per-instance state: + * - `baseHooksInCurrentFile` is populated by the rule's `Program` visitor. + * - `siblingFileExistsCache` memoizes `fs.statSync` results so each component directory pays at + * most one syscall per linted run. + * + * Anchoring detection on the base hook eliminates false positives on other `_unstable` hooks + * (e.g. `useFooContextValues_unstable`, `useFooStyles_unstable`) that intentionally take + * different signatures. + */ +function createPairDetector(filename: string | undefined) { + const baseHooksInCurrentFile = new Set(); + const siblingFileExistsCache = new Map(); + + function hasPairedBaseHook(stateHookName: string): boolean { + const baseHookName = stateHookName.slice(0, -UNSTABLE_SUFFIX.length) + BASE_SUFFIX; + if (baseHooksInCurrentFile.has(baseHookName)) { + return true; + } + // ESLint passes synthetic filenames like `` for inline code; nothing to check. + if (!filename || !path.isAbsolute(filename)) { + return false; + } + const dir = path.dirname(filename); + // Sibling base-hook file (the wrapping hook lives in `useFoo.tsx`, the base in `useFooBase.tsx`). + const siblingBasename = baseHookName.slice(0, -UNSTABLE_SUFFIX.length); // e.g. `useFooBase` + for (const ext of SIBLING_EXTENSIONS) { + const candidate = path.join(dir, siblingBasename + ext); + if (candidate === filename) { + continue; + } + let exists = siblingFileExistsCache.get(candidate); + if (exists === undefined) { + try { + exists = fs.statSync(candidate).isFile(); + } catch { + exists = false; + } + siblingFileExistsCache.set(candidate, exists); + } + if (exists) { + return true; + } + } + return false; + } + + return { + baseHooksInCurrentFile, + hasPairedBaseHook, + }; +} + +/** + * Returns `true` when `annotation` is `React.Ref<...>` (qualified) or `Ref<...>` (named) AND the + * referenced identifier was imported from the `react` package in the surrounding scope. The scope + * check guards against false positives when a local `Ref` shadows the React import. + */ +function isReactRefTypeAnnotation( + annotation: TSESTree.TSTypeAnnotation | undefined, + scope: TSESLint.Scope.Scope, +): boolean { + if (!annotation) { + return false; + } + const type = annotation.typeAnnotation; + if (type.type !== AST_NODE_TYPES.TSTypeReference) { + return false; + } + const { typeName } = type; + if (typeName.type === AST_NODE_TYPES.Identifier) { + return typeName.name === 'Ref' && isReactImportedIdentifier(typeName, scope, 'Ref'); + } + if (typeName.type === AST_NODE_TYPES.TSQualifiedName) { + return ( + typeName.left.type === AST_NODE_TYPES.Identifier && + typeName.left.name === 'React' && + typeName.right.name === 'Ref' && + isReactImportedIdentifier(typeName.left, scope, '*') + ); + } + return false; +} + +/** + * Resolves the given identifier in `scope` and verifies it was imported from the `react` + * package. `expectedImportedName` is matched against the original import name: + * - a named-import specifier (e.g. `import { Ref } from 'react'`) must match the name, + * - a namespace/default import (e.g. `import * as React from 'react'`) matches `'*'`/`'default'`. + * + * Scope-based (no ParserServices required), so the rule still works without TypeScript type + * information. + */ +function isReactImportedIdentifier( + identifier: TSESTree.Identifier, + scope: TSESLint.Scope.Scope, + expectedImportedName: string, +): boolean { + const variable = findVariableInScope(scope, identifier.name); + if (!variable) { + return false; + } + return variable.defs.some(def => { + if (def.type !== 'ImportBinding') { + return false; + } + const importDecl = def.parent; + if (!importDecl || importDecl.type !== AST_NODE_TYPES.ImportDeclaration) { + return false; + } + if (importDecl.source.value !== 'react') { + return false; + } + const specifier = def.node; + switch (specifier.type) { + case AST_NODE_TYPES.ImportSpecifier: { + const importedName = + specifier.imported.type === AST_NODE_TYPES.Identifier + ? specifier.imported.name + : String(specifier.imported.value); + return importedName === expectedImportedName; + } + case AST_NODE_TYPES.ImportNamespaceSpecifier: + return expectedImportedName === '*'; + case AST_NODE_TYPES.ImportDefaultSpecifier: + // `import React from 'react'` is also a valid way to access `React.Ref`. + return expectedImportedName === '*' || expectedImportedName === 'default'; + default: + return false; + } + }); +} + +/** + * Walks the scope chain looking for a variable with the given name. Plain `scope.set.get` only + * inspects the local scope, so this helper enables identifier resolution that matches JavaScript's + * lookup semantics. + */ +function findVariableInScope(scope: TSESLint.Scope.Scope, name: string): TSESLint.Scope.Variable | undefined { + let current: TSESLint.Scope.Scope | null = scope; + while (current) { + const variable = current.set.get(name); + if (variable) { + return variable; + } + current = current.upper; + } + return undefined; +} + +/** + * Renders the actual ref type annotation as a string for `invalidRefType` diagnostics, so users + * see what they wrote (`HTMLAttributes`, `Ref`, `MyType`…) instead of bare AST node types. + */ +function describeRefType(annotation: TSESTree.TSTypeAnnotation | undefined): string { + if (!annotation) { + return ''; + } + const type = annotation.typeAnnotation; + if (type.type !== AST_NODE_TYPES.TSTypeReference) { + return type.type; + } + const { typeName } = type; + if (typeName.type === AST_NODE_TYPES.Identifier) { + return typeName.name; + } + if (typeName.type === AST_NODE_TYPES.TSQualifiedName) { + const left = typeName.left.type === AST_NODE_TYPES.Identifier ? typeName.left.name : '…'; + return `${left}.${typeName.right.name}`; + } + return type.type; +} diff --git a/tools/eslint-rules/tsconfig.lint.json b/tools/eslint-rules/tsconfig.lint.json index bb717c5e289e34..5215cb85923e4e 100644 --- a/tools/eslint-rules/tsconfig.lint.json +++ b/tools/eslint-rules/tsconfig.lint.json @@ -4,6 +4,6 @@ "outDir": "../../dist/out-tsc", "types": ["node"] }, - "exclude": ["**/*.spec.ts"], + "exclude": ["**/*.spec.ts", "**/__fixtures__/**"], "include": ["**/*.ts"] }