fix(eslint-rules): analyze base hook dependencies per symbol, not per file - #36498
Draft
Hotell wants to merge 1 commit into
Draft
fix(eslint-rules): analyze base hook dependencies per symbol, not per file#36498Hotell wants to merge 1 commit into
Hotell wants to merge 1 commit into
Conversation
… file `base-hook-no-forbidden-runtime` had two defects that made it miss the leaks it exists to catch while reporting ones that were not real. 1. Imports were only tracked when the module specifier exactly matched a fixed `watchedPackages` allowlist. Relative imports and unlisted packages were never analyzed, so `useDropdownBase_unstable` -> `./useButtonTriggerSlot` -> `../../utils/useTriggerSlot` -> `@fluentui/react-tabster` went undetected. 2. Reach was computed from the *file* declaring the imported symbol. A binding therefore inherited every dependency of its defining module and of every barrel it was re-exported through, e.g. importing `useARIAButtonProps` from `@fluentui/react-aria` inherited `useActiveDescendant` -> tabster. Reach is now computed from the symbol's own declaration, following only the identifiers that declaration references. Alias hops resolve through barrels to the leaf declaration, so a clean export no longer inherits its siblings' dependencies. Forbidden runtimes are detected at module specifier boundaries and via the leaf declaration's owning package. `watchedPackages` is removed; every import is analyzed. The walk deliberately stops at namespace bindings (`import * as ns`) and whole-module symbols, which bind an entire module rather than a symbol. Declaration files are traversed, so type coupling is still detected when a package resolves to built output. Also fixes subpath specifiers (`tabster/sub`) not being normalized to the package name, and memoizes results per binding and per Program. Repo-wide this moves the rule from 0 reported violations to 6, all with verified chains into `tabster`. Measured cost is ~90ms per package (3.3% of rule time).
📊 Bundle size report✅ No changes found |
|
Pull request demo site: URL |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Previous Behavior
@nx/workspace-base-hook-no-forbidden-runtimereported 0 violations across the whole repo, including the one it was written to catch:Two defects caused this.
1. Imports were only tracked when the specifier exactly matched a
watchedPackagesallowlist.trackedPackageswas{'@fluentui/react-tabster', 'tabster'}, so relative imports and every other package were never analyzed. The transitive machinery could only fire when the directly imported package was already@fluentui/react-tabster— i.e. only for the case a plainno-restricted-importsalready covers. Subpath specifiers (tabster/sub) also slipped through, since the check compared the raw specifier rather than the package name.2. Reach was computed from the file declaring the imported symbol, not the symbol.
A binding inherited every dependency of its defining module and of every barrel it was re-exported through.
useButton.tsimportsuseARIAButtonPropsfrom@fluentui/react-aria; because the react-aria barrel also exportsuseActiveDescendant,useButtonBase_unstablewas reported as tabster-dependent. Same for a symbol referencing an unrelated sibling export of its own file.New Behavior
Reach is computed per symbol. The walk starts at the symbol's own declaration and follows only the identifiers that declaration references. Alias hops resolve through barrels to the leaf declaration, so a clean export never inherits its siblings' dependencies. Forbidden runtimes are detected at module-specifier boundaries and via the leaf declaration's owning package (catching
export { x } from 'tabster'chains thatgetAliasedSymbolcollapses).watchedPackagesis removed — every import is analyzed, relative ones included.Deliberate bounds: namespace bindings (
import * as ns) and whole-module symbols stop at the specifier check, since they bind a module rather than a symbol. Without this,React.useRefwalks into React's type graph and hits tabster's declaration-merged augmentations, producingcannot reference `*` from `react` because `*` depends on forbidden runtime `tabster`. Declaration files are traversed, so type coupling is still caught when a package resolves to built output.forbiddenRuntimesstays['tabster']. Wrapper packages are intentionally not banned: at symbol granularity,useOnKeyboardNavigationChange->useKeyborgRef->keyborgis legitimately clean, whileuseTabsterAttributes->useTabster->tabsteris not. Banning@fluentui/react-tabsterwholesale would reintroduce exactly the conflation this PR removes.Findings
6 violations across 3 packages, each with a verified chain:
react-comboboxuseDropdown.tsx:75useButtonTriggerSlotreact-tabster/src/hooks/useTabster.tsreact-comboboxuseCombobox.tsx:76useInputTriggerSlotreact-tabster/src/hooks/useTabster.tsreact-tag-pickeruseTagPickerButton.tsx:37useButtonTriggerSlotreact-tabster/src/hooks/useTabster.tsreact-tag-pickeruseTagPickerInput.tsx:79useInputTriggerSlotreact-tabster/src/hooks/useTabster.tsreact-avataruseAvatarGroupPopover.tsx:74AvatarGroupPopoverBaseProps/BaseStatereact-tabster/src/hooks/useModalAttributes.tsVerified by reading react-tabster sources that everything keyborg-only is correctly silent —
useActiveDescendant,useListboxSlot,Listbox,useFocusWithin,useIsNavigatingWithKeyboard,createFocusOutlineStyle.These are reported, not fixed, in this PR. Follow-up needed; four of the six share one root cause (
useTriggerSlotcallinguseTabsterAttributes).Performance
The symbol walk is memoized per binding and per
ts.Program. Measured onreact-combobox(66 files) withTIMING=15 npx eslint src:@typescript-eslint/no-deprecatedreact-hooks/static-componentscompat/compat@nx/workspace-base-hook-no-forbidden-runtime@typescript-eslint/naming-conventionimport/no-extraneous-dependenciesreact-hooks/rules-of-hooksEnd-to-end A/B on the same package:
npx eslint srcwith rule offnpx eslint srcwith rule on~190ms, ~2% of lint time. For context, ~4.7s of that 9s is fixed ESLint startup + TS Program construction (measured by linting a single file), and two unrelated rules account for 76% of all rule execution time.
Tests
98 passing. New coverage for the cases that previously had none:
./local-trigger->./local-heavy-> runtime) — theuseDropdownshapeuseBenign->useBenignRef-> benign dep) vs a deep one (useDeep->useDeepInner-> runtime) — the keyborg/tabster splituseActiveDescendantshape.d.tsExisting type-leak fixtures were reshaped:
HeavyTypenow genuinely embeds a forbidden-runtime type instead of merely living in a file that imports one.Notes
tools/eslint-rulesisprivate: trueandpackages/eslint-pluginis untouched.npx beachball check --branch masterconfirms.packages/eslint-plugin/src/internal.jsis unchanged ('error', no options). Merging this makes lint fail in 3 packages until the 6 findings are addressed.Known limitations (follow-ups)
import()is not followedimport './x') have no identifier to resolve, so they need a separate per-file passns.foo) is not resolved; trivial to add, but needs areact/@typesguard first or the React type-graph false positives returnRelated Issue(s)