fix(linter/plugins): sort visitors in order of specificity#16834
Conversation
How to use the Graphite Merge QueueAdd either label to this PR to merge it via the merge queue:
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. |
There was a problem hiding this comment.
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
VisitPropinterface to track visitor functions along with their specificity metrics (attributeCount, identifierCount, selectorStr) - Implemented sorting logic in
mergeVisitFnsthat 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.
There was a problem hiding this comment.
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()sortsvisitPropsin-place. These arrays come fromcompilingLeafVisitor[typeId]/compilingNonLeafVisitor[...]and are then cleared (visitProps.length = 0). That’s OK, but it means every finalize pays anO(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
VisitProppooling resets onlyfnandselectorStrtonull, but leavesattributeCount/identifierCountintact. 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
VisitPropinstances are shared across alltypeIdsfor selectors like:matches(A, B)(same object pushed into multiple per-type arrays).mergeVisitFns()sorts the per-typeVisitProp[]in place, which is fine, but it also mutatesvisitProps.length = 0at 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
VisitProprecords (fn,attributeCount,identifierCount,selectorStr) per node type, and to sort visitor invocations by selector specificity to match ESLint semantics. - Introduced:
VisitPropobject pooling (visitPropsCache) and persistent per-type buckets (compilingLeafVisitor,compilingNonLeafVisitor) to reduce allocations.EXIT_FLAGembedded intoattributeCountto ensure exit callbacks sort after enter callbacks for leaf nodes.- Active type tracking via
activeLeafVisitorTypeIdsandactiveNonLeafVisitorTypeIds.
- Updated
finalizeCompiledVisitor()to merge sorted visitor arrays intocompiledVisitor, 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.
0300b0e to
3764771
Compare
3764771 to
ddd1b6b
Compare
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.
8b347c9 to
274594e
Compare
ddd1b6b to
3302fcb
Compare
#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.

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:
This PR implement ordering by specificity, to match ESLint.
Fixes 900 conformance tests for
indentrule.