From d8d42b4f633e04eebce8b41deb287448895dc4b6 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 26 May 2026 15:56:52 +0200 Subject: [PATCH 1/2] feat(eslint-rules): add base-hook-no-forbidden-runtime rule Registers @nx/workspace-base-hook-no-forbidden-runtime. Rule is not yet enabled in any project config and is fully inert until wired up in a follow-up PR. --- tools/eslint-rules/index.ts | 5 + .../src/dummy.ts | 4 + .../src/test.ts | 4 + .../stubs/cyclic-pkg/a.ts | 5 + .../stubs/cyclic-pkg/b.ts | 6 + .../stubs/cyclic-pkg/index.ts | 3 + .../stubs/heavy-runtime/index.ts | 5 + .../stubs/light-helper/index.ts | 3 + .../stubs/watched-pkg/heavy.ts | 7 + .../stubs/watched-pkg/index.ts | 9 + .../stubs/watched-pkg/light.ts | 7 + .../tsconfig.json | 21 + .../base-hook-no-forbidden-runtime.spec.ts | 388 ++++++++++ .../rules/base-hook-no-forbidden-runtime.ts | 704 ++++++++++++++++++ 14 files changed, 1171 insertions(+) create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/dummy.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/test.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/a.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/b.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-pkg/index.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/heavy-runtime/index.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/light-helper/index.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/index.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/light.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/tsconfig.json create mode 100644 tools/eslint-rules/rules/base-hook-no-forbidden-runtime.spec.ts create mode 100644 tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts diff --git a/tools/eslint-rules/index.ts b/tools/eslint-rules/index.ts index 6411bbfbf1dde..b0ed1faac661e 100644 --- a/tools/eslint-rules/index.ts +++ b/tools/eslint-rules/index.ts @@ -5,6 +5,10 @@ import { 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. @@ -34,6 +38,7 @@ 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 0000000000000..73f11d7b5c58a --- /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 0000000000000..1e866fcbc8322 --- /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 0000000000000..12e940cc7f1fd --- /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 0000000000000..777b389e45df5 --- /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 0000000000000..81c4ddc44291b --- /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 0000000000000..8f5f7d2b4e91f --- /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 0000000000000..9ab573b97e25e --- /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 0000000000000..9798a4b8d6b35 --- /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 0000000000000..ecc54ec64c43d --- /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 0000000000000..970deab9edbbd --- /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 0000000000000..432b9967e725a --- /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/base-hook-no-forbidden-runtime.spec.ts b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.spec.ts new file mode 100644 index 0000000000000..40b5b7217c80d --- /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 0000000000000..6cb4dcf59d32d --- /dev/null +++ b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts @@ -0,0 +1,704 @@ +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; +} From 18d8549712d332f4cea3df2de9b1e9d4321ad5aa Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 26 May 2026 19:25:06 +0200 Subject: [PATCH 2/2] fix(eslint-rules): make base-hook runtime reach cycle-safe Cache in-progress reach results to preserve transitive closure across import cycles, normalize diagnostic paths cross-platform, and add cyclic regression fixtures/tests. --- .../stubs/cyclic-heavy-pkg/a.ts | 7 ++ .../stubs/cyclic-heavy-pkg/b.ts | 6 ++ .../stubs/cyclic-heavy-pkg/index.ts | 3 + .../tsconfig.json | 3 +- .../base-hook-no-forbidden-runtime.spec.ts | 30 ++++++ .../rules/base-hook-no-forbidden-runtime.ts | 101 ++++++++++-------- 6 files changed, 103 insertions(+), 47 deletions(-) create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/a.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/b.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/index.ts diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/a.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/a.ts new file mode 100644 index 0000000000000..3d3ae4a79ed60 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/a.ts @@ -0,0 +1,7 @@ +import { useB } from './b'; +import { runHeavy } from 'heavy-runtime'; + +export function useA(): number { + runHeavy(); + return useB() + 1; +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/b.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/b.ts new file mode 100644 index 0000000000000..b017e3745e90c --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/b.ts @@ -0,0 +1,6 @@ +import { useA } from './a'; + +export function useB(): number { + // Keep the static import cycle while avoiding direct execution recursion. + return (useA as unknown as () => number).length; +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/index.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/index.ts new file mode 100644 index 0000000000000..96c10cc196b1e --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/index.ts @@ -0,0 +1,3 @@ +// Cyclic graph where `a` imports a forbidden runtime and `b` reaches it only transitively. +export { useA } from './a'; +export { useB } from './b'; 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 index 432b9967e725a..a01e6c8cb8594 100644 --- 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 @@ -14,7 +14,8 @@ "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"] + "cyclic-pkg": ["./stubs/cyclic-pkg/index.ts"], + "cyclic-heavy-pkg": ["./stubs/cyclic-heavy-pkg/index.ts"] } }, "include": ["src/**/*.ts", "src/**/*.tsx", "stubs/**/*.ts"] 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 index 40b5b7217c80d..9421f2e1785da 100644 --- a/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.spec.ts +++ b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.spec.ts @@ -241,6 +241,36 @@ typedRuleTester.run(`${RULE_NAME} (typed)`, rule, { }, ], }, + // Regression: when the defining symbol lives on one node of a cycle, forbidden runtime + // imported by another node in that cycle must still appear in transitive reach. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: [ + { + watchedPackages: ['cyclic-heavy-pkg'], + forbiddenRuntimes: ['heavy-runtime'], + }, + ], + code: ` + import { useB } from 'cyclic-heavy-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + return { props, ref, value: useB() }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeReach', + data: { + hookName: 'useThingBase_unstable', + importedName: 'useB', + package: 'cyclic-heavy-pkg', + runtime: 'heavy-runtime', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/b.ts', + }, + }, + ], + }, // Aliased import from a forbidden-runtime package is still flagged on the alias use site. { languageOptions: typedLanguageOptions, diff --git a/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts index 6cb4dcf59d32d..24d2a9b995618 100644 --- a/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts +++ b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts @@ -1,5 +1,6 @@ import type { TSESTree, TSESLint, ParserServicesWithTypeInformation } from '@typescript-eslint/utils'; import { ESLintUtils, AST_NODE_TYPES } from '@typescript-eslint/utils'; +import * as path from 'node:path'; import * as ts from 'typescript'; // NOTE: The rule will be available in ESLint configs as "@nx/workspace-base-hook-no-forbidden-runtime" @@ -100,8 +101,6 @@ interface Reach { 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. @@ -497,8 +496,8 @@ function transitiveReach(program: ts.Program, sourceFile: ts.SourceFile): Reach /** * 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). + * each resolved source file. Uses `inProgress` to break cycles by returning the already-cached, + * in-progress reach object for the cycle participant. */ function computeReach( program: ts.Program, @@ -506,49 +505,51 @@ function computeReach( cache: Map, inProgress: Set, ): Reach { - const cached = cache.get(sourceFile.fileName); + const fileName = sourceFile.fileName; + const cached = cache.get(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 result: Reach = { value, all }; + cache.set(fileName, result); + if (inProgress.has(fileName)) { + return result; + } + inProgress.add(fileName); + + try { + 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); + 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); + } } } + } finally { + inProgress.delete(fileName); } - - inProgress.delete(sourceFile.fileName); - const result: Reach = { value, all }; - cache.set(sourceFile.fileName, result); return result; } @@ -691,14 +692,22 @@ function packageNameOf(specifier: string): string { * 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 resolvedAbsolute = path.resolve(absolute); + const normalizedAbsolute = toPosixPath(resolvedAbsolute); + const segments = normalizedAbsolute.split('/'); + const nodeModulesIdx = segments.lastIndexOf('node_modules'); + if (nodeModulesIdx !== -1 && nodeModulesIdx + 1 < segments.length) { + return segments.slice(nodeModulesIdx + 1).join('/'); } - const cwd = process.cwd(); - if (absolute.startsWith(cwd)) { - return absolute.slice(cwd.length + 1); + + const relative = path.relative(path.resolve(process.cwd()), resolvedAbsolute); + if (relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return toPosixPath(relative); } - return absolute; + + return normalizedAbsolute; +} + +function toPosixPath(value: string): string { + return value.replace(/\\/g, '/'); }