Skip to content

fix(linter/plugins): sort visitors in order of specificity#16834

Merged
graphite-app[bot] merged 1 commit intomainfrom
12-14-fix_linter_plugins_sort_visitors_in_order_of_specificity
Dec 14, 2025
Merged

fix(linter/plugins): sort visitors in order of specificity#16834
graphite-app[bot] merged 1 commit intomainfrom
12-14-fix_linter_plugins_sort_visitors_in_order_of_specificity

Conversation

@overlookmotel
Copy link
Copy Markdown
Member

@overlookmotel overlookmotel commented Dec 14, 2025

ESLint calls visitor functions in order of selector specificity, with the least specific selectors first.

Attribute count takes priority in calculation of specificity. A selector with 1 attribute is considered more specific than a selector with any number of identifiers, but no attributes.

e.g. these visit methods are called in the order they're defined:

const rule = {
  create(context) {
    return {
      // 0 identifiers, 0 attributes (least specific)
      "*"(node) {},
      // 1 identifier, 0 attributes
      "Identifier"(node) {},
      // 2 identifiers, 0 attributes
      ":matches(Identifier, Program)"(node) {},
      // 1 identifier, 1 attribute (most specific)
      "Identifier[name=foo]"(node) {},

      // 0 identifiers, 0 attributes (least specific)
      "*:exit"(node) {},
      // 1 identifier, 0 attributes
      "Identifier:exit"(node) {},
      // 2 identifiers, 0 attributes (most specific)
      ":matches(Identifier, Program):exit"(node) {},
    };
  }
};

This PR implement ordering by specificity, to match ESLint.

Fixes 900 conformance tests for indent rule.

@github-actions github-actions bot added A-linter Area - Linter A-cli Area - CLI A-linter-plugins Area - Linter JS plugins C-bug Category - Bug labels Dec 14, 2025
Copy link
Copy Markdown
Member Author

overlookmotel commented Dec 14, 2025


How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • 0-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent hot fixes, skip the queue and merge this PR next

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements ESLint-compatible visitor ordering by specificity for plugin selectors. The change ensures that visitor functions are called in the same order as ESLint: least specific selectors first, ordered by number of attributes (ascending), then number of identifiers (ascending), with selector string as an alphabetical tie-breaker. This fix resolves 900 conformance test failures for the indent rule.

Key Changes

  • Introduced VisitProp interface to track visitor functions along with their specificity metrics (attributeCount, identifierCount, selectorStr)
  • Implemented sorting logic in mergeVisitFns that orders visitors by specificity before merging
  • Refactored visitor compilation to collect all visitors first, then sort and merge them, instead of merging as they're added

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 1 comment.

File Description
apps/oxlint/test/fixtures/selector/plugin.ts Moved *:exit visitor to track all visits, reporting on Program exit instead of only Program exit
apps/oxlint/test/fixtures/selector/output.snap.md Updated expected visitor call order to reflect correct specificity-based sorting
apps/oxlint/src-js/plugins/visitor.ts Core implementation of specificity-based visitor sorting with EXIT_FLAG to separate enter/exit visitors

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specificity ordering is implemented, but the EXIT_FLAG bit-packing into attributeCount creates a fragile hidden invariant and is easy to misuse later. mergeVisitFns() relies on engine sort behavior for deterministic ordering when selectors compare equal, which may not be stable across runtimes. There’s also a lifecycle robustness concern: if compilation throws mid-way, pooled state (visitPropsCacheNextIndex / active buckets) may remain inconsistent for the next run. Performance-wise, per-type sort() may become a hot cost if many handlers exist per node type, so insertion-order maintenance is worth considering if profiling indicates regressions.

Additional notes (3)
  • Performance | apps/oxlint/src-js/plugins/visitor.ts:467-527
    mergeVisitFns() sorts visitProps in-place. These arrays come from compilingLeafVisitor[typeId] / compilingNonLeafVisitor[...] and are then cleared (visitProps.length = 0). That’s OK, but it means every finalize pays an O(n log n) sort cost per active node type.

Given this is a hot path for linter execution, it’s worth sanity-checking whether sorting is required for all multi-visitor cases. Many node types likely only have one handler (common case) and you already fast-path numVisitFns === 1.

For the multi-handler case, you may be able to avoid full sorts by inserting VisitProps in sorted order at add-time (stable insertion) or by bucketing by (attributeCount, identifierCount) when selector parsing already computes counts.

  • Readability | apps/oxlint/src-js/plugins/visitor.ts:214-214
    VisitProp pooling resets only fn and selectorStr to null, but leaves attributeCount / identifierCount intact. That’s fine because you overwrite them when reusing from cache.

The issue is the rare path called out in the comment: selectors with typeIds empty cause visitProp to be allocated/claimed from the pool and then never stored into any per-type list. In that case, the object remains “in use” until finalizeCompiledVisitor() when you clear visitPropsCacheNextIndex back to 0—but its fn/selectorStr will still be nulled because your reset loop runs for (i = visitPropsCacheNextIndex - 1; i >= 0; i--).

That’s correct, but it relies on always calling finalizeCompiledVisitor() even if compilation throws later in the same visitor. If an exception is thrown after some visitProps are checked out, the cache index won’t reset and subsequent compilations may reuse partially-initialized objects or leak references longer than intended.

  • Maintainability | apps/oxlint/src-js/plugins/visitor.ts:270-270
    VisitProp instances are shared across all typeIds for selectors like :matches(A, B) (same object pushed into multiple per-type arrays). mergeVisitFns() sorts the per-type VisitProp[] in place, which is fine, but it also mutates visitProps.length = 0 at the end. Because those arrays are per-type, that part is fine too.

The risky part is the shared VisitProp object itself: it’s mutated later for complex selectors (visitProp.fn = wrapVisitFnWithSelectorMatch(...)) and for specificity (attributeCount |= ..., identifierCount = ...). Right now that mutation occurs before the object is pushed to per-type arrays, so it’s consistent.

However, this design becomes fragile if any future change mutates a VisitProp after it’s been added to one type’s array (e.g. a later pass sets a tie-breaker, a late wrap, etc.). Given how subtle this is, it deserves a hard guard or an explicit comment stating that VisitProp is immutable after insertion.

Summary of changes

What changed

apps/oxlint/src-js/plugins/visitor.ts

  • Expanded visitor-key support to include selectors (not just node type names) and updated documentation/examples accordingly.
  • Reworked visitor compilation to collect VisitProp records (fn, attributeCount, identifierCount, selectorStr) per node type, and to sort visitor invocations by selector specificity to match ESLint semantics.
  • Introduced:
    • VisitProp object pooling (visitPropsCache) and persistent per-type buckets (compilingLeafVisitor, compilingNonLeafVisitor) to reduce allocations.
    • EXIT_FLAG embedded into attributeCount to ensure exit callbacks sort after enter callbacks for leaf nodes.
    • Active type tracking via activeLeafVisitorTypeIds and activeNonLeafVisitorTypeIds.
  • Updated finalizeCompiledVisitor() to merge sorted visitor arrays into compiledVisitor, reusing cached {enter, exit} objects.

Test fixtures

  • Updated selector fixture plugin to use "*:exit" instead of "Program:exit" for reporting.
  • Updated selector snapshot ordering to reflect the new specificity-based visitor execution order.

@charliecreates charliecreates bot removed the request for review from CharlieHelps December 14, 2025 15:17
@overlookmotel overlookmotel force-pushed the 12-14-fix_linter_plugins_sort_visitors_in_order_of_specificity branch from 0300b0e to 3764771 Compare December 14, 2025 15:28
@overlookmotel overlookmotel self-assigned this Dec 14, 2025
@overlookmotel overlookmotel force-pushed the 12-14-fix_linter_plugins_sort_visitors_in_order_of_specificity branch from 3764771 to ddd1b6b Compare December 14, 2025 15:45
@graphite-app graphite-app bot added the 0-merge Merge with Graphite Merge Queue label Dec 14, 2025
@graphite-app
Copy link
Copy Markdown
Contributor

graphite-app bot commented Dec 14, 2025

Merge activity

ESLint calls visitor functions in order of selector specificity, with the least specific selectors first.

Attribute count takes priority in calculation of specificity. A selector with 1 attribute is considered more specific than a selector with any number of identifiers, but no attributes.

e.g. these visit methods are called in the order they're defined:

```js
const rule = {
  create(context) {
    return {
      // 0 identifiers, 0 attributes (least specific)
      "*"(node) {},
      // 1 identifier, 0 attributes
      "Identifier"(node) {},
      // 2 identifiers, 0 attributes
      ":matches(Identifier, Program)"(node) {},
      // 1 identifier, 1 attribute (most specific)
      "Identifier[name=foo]"(node) {},

      // 0 identifiers, 0 attributes (least specific)
      "*:exit"(node) {},
      // 1 identifier, 0 attributes
      "Identifier:exit"(node) {},
      // 2 identifiers, 0 attributes (most specific)
      ":matches(Identifier, Program):exit"(node) {},
    };
  }
};
```

This PR implement ordering by specificity, to match ESLint.

Fixes 900 conformance tests for `indent` rule.
@graphite-app graphite-app bot force-pushed the 12-14-refactor_linter_plugins_move_type_defs_into_visitor.ts_ branch from 8b347c9 to 274594e Compare December 14, 2025 15:58
@graphite-app graphite-app bot force-pushed the 12-14-fix_linter_plugins_sort_visitors_in_order_of_specificity branch from ddd1b6b to 3302fcb Compare December 14, 2025 15:59
graphite-app bot pushed a commit that referenced this pull request Dec 14, 2025
#16834 implemented sorting visit methods by specificity. Speed up the sorting by storing `specificity` as a single integer, rather than 2 separate properties `identifierCount` and `attributeCount`.

See comment in `selector.ts` for further explanation.

Note: I've checked that minifier const-folds and inlines all the consts in release build.
Base automatically changed from 12-14-refactor_linter_plugins_move_type_defs_into_visitor.ts_ to main December 14, 2025 16:04
@graphite-app graphite-app bot removed the 0-merge Merge with Graphite Merge Queue label Dec 14, 2025
@graphite-app graphite-app bot merged commit 3302fcb into main Dec 14, 2025
19 checks passed
@graphite-app graphite-app bot deleted the 12-14-fix_linter_plugins_sort_visitors_in_order_of_specificity branch December 14, 2025 16:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-cli Area - CLI A-linter Area - Linter A-linter-plugins Area - Linter JS plugins C-bug Category - Bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants