On Dify web/ (5860 .ts / .tsx files), running one type-aware rule:
| Wall (min of 3) | Peak RSS | |
|---|---|---|
| ESLint Linter | 25 s | 7.0 GB |
| TSSLint 3.1 | 1.5 s | 3.75 GB |
Native linters (Rust's Oxlint and the like) hitting 10× is normal — that's the cross-runtime gap. JS-vs-JS usually lands at 1.5-3×. This 16× is the same V8, the same ESLint rule source, the same ESTree shape — the gap is all architecture. 3.1 is a full rewrite of @tsslint/compat-eslint (the compatibility layer that lets stock ESLint rules run inside TSSLint). Bench: tsslint-dify-bench.
Scope: this package only serves the "use stock ESLint rules" path. TSSLint native rules go straight to
ts.Node+ts.TypeCheckerand bypass compat-eslint's selector dispatch / lazy ESTree / scope-manager — that's a separate path.
For "lint a lot of TS files + want type-aware" workloads, ESLint burns time on two things: selectors are registered once per session and never change, but they get re-evaluated dynamically against every node — pure waste. typescript-estree also eagerly walks the entire ESTree before lint even starts. The goal: rules untouched, API compatible, dispatch path rebuilt as a hot path.
The two pipelines from source to diagnostic:
ESLint + typescript-eslint TSSLint 3.1
───────────────────────── ───────────
source file source file
│ │ (already in
▼ │ host's ts.Program —
@typescript-eslint/parser │ no rebuild)
│ builds own ts.Program / │
│ ProjectService (2 copies vs host) │
▼ ▼
typescript-estree tsScanTraverse
│ EAGER full walk → entire ESTree │ predicate hit?
│ (28k nodes built) │ miss → recurse
▼ │ hit → materialize
ESLint Linter │ LazyNode
│ build SourceCode (indexMap / │ (children lazy)
│ line splits / ▼
│ scopeManager) dispatchTarget
▼ │ Map lookup by .type
SourceCodeTraverser ▼
│ walk visitor-keys all nodes listener (inline)
▼ │
SourceCodeVisitor ▼
│ EventEmitter bucket lookup diagnostic
▼
esquery
│ per-node × per-selector
│ dynamic graph match
│ (parent.type? :has? attr?)
▼
listener (try/catch wrap per call)
│
▼
diagnostic
Every ▼ on the left is a per-node cost. The right has only two: tsScanTraverse's predicate, and dispatchTarget's Map lookup. The three lines below crack the right pipeline open: how selectors get pre-decomposed, how the TS AST is walked, how dispatch goes inline.
ESLint rule listeners register esquery-string selectors:
'Identifier'
'Identifier:exit'
'Identifier[name="foo"]'
'CallExpression > MemberExpression'
'FunctionDeclaration BlockStatement'
':matches(A, B, C)'
'CallExpression:has(Literal)'
':function'
'Parent > Child.field'ESLint internally buckets listeners by node type and caches parsed selector ASTs. Even so, every visited node still pays the esquery evaluator — recursive checks asking "current node? parent.type? subtree has X?". Across 28k nodes × dozens of selectors, the total match count runs into the millions per file.
But selectors register once and don't change within a lint session — they can be flattened at registration into a type set + predicate function, removing all runtime graph matching.
decomposeSimple flattens each selector into a FastDispatchInfo struct:
interface FastDispatchInfo {
types: Set<string> | 'all'; // node.types this fires on
isExit: boolean;
fieldFire?: string; // single-level: trigger on parent, listener gets parent[fieldFire]
fieldChain?: string[]; // multi-level: A > B.f1 > C.f2 walks [f1, f2]
fieldChainTypes?: (string | undefined)[]; // paired with fieldChain, intermediate type checks
typeFilter?: string; // extracted node must match this .type
filter?: (target: any) => boolean; // generic per-target predicate
}The whole esquery expressivity collapses into these fields — 5-7 selector forms map cleanly:
| Selector | types | fieldFire / fieldChain | filter |
|---|---|---|---|
'Identifier' |
{Identifier} |
— | — |
'Identifier[name="foo"]' |
{Identifier} |
— | n => n.name === 'foo' |
'CallExpression > MemberExpression' |
{MemberExpression} |
— | n => n.parent?.type === 'CallExpression' |
'FunctionDeclaration BlockStatement' |
{BlockStatement} |
— | n => walk ancestors for FunctionDeclaration |
'CallExpression:has(Literal)' |
{CallExpression} |
— | n => walk subtree for Literal |
':function' |
{Func/FuncExpr/ArrowFunc} |
— | — |
'Parent > *.body' |
{Parent} |
fieldFire 'body' |
— |
'A > B.f1 > C.f2' |
{A} |
fieldChain [f1, f2] |
— |
Covers every selector form rules actually use (ESLint + typescript-eslint catalogue 100%) — not the full esquery grammar. Uncovered shapes throw UnsupportedSelectorError, never falling back to NodeEventGenerator — gaps are loud and force themselves to be filled.
buildFastDispatch buckets a pile of FastDispatchInfo by types into a type-keyed Map<string, DispatchEntry[]>, plus two wildcard arrays (enterAll / exitAll) and a codePath event Map. Hot path: Map lookup + a few short-circuit ifs (fieldFire / fieldChain → typeFilter → filter; all undefined skips straight to listener call). Simple selectors are 80%+ of the catalogue; for those every if short-circuits.
The fast tables from Line 1 are keyed on ESTree types — Identifier / MemberExpression / ImportDeclaration. But tsserver hands us a ts.SourceFile made of ts.SyntaxKind enums and TS-shaped child layouts, structurally similar to ESTree but with 170+ divergences:
ts.SourceFile ↔ Program
ts.VariableStatement ↔ VariableDeclaration (names swapped)
ts.PropertyAccess ↔ MemberExpression
ts.BinaryExpression ↔ BinaryExpression / LogicalExpression / AssignmentExpression
(one-to-three, depending on operator)
typescript-estree's approach is to convert eagerly — every ts.Node gets a matching ESTree node (54k-line checker.ts produces ~28k nodes). We don't. Materialization timing, lazy children downward, lazy .parent upward, structural wrappers handled in both directions — four things, in that order. Shape first:
ts.SourceFile (top-down via tsForEachChild)
│
visit(node) {
predicate hit?
├── NO ──── tsForEachChild → recurse children (no ESTree object cost)
│
└── YES ──── materialize(node)
│
▼
LazyNode { type, _ts, parent } ← children not built yet
│
├ enterCb(LazyNode) ← fires :enter listener
├ tsForEachChild → recurse
└ leaveCb(LazyNode) ← fires :exit listener
}
bench: checker.ts × 43-rule, ~10-20% hit rate
28k ts.Node walked → ~5-8k LazyNode materialized
Once materialized, a LazyNode's children are still lazy:
IfStatementNode {
type: 'IfStatement', _ts, parent
get test ←──── built only when a rule reads it
get consequent ←──── never built if no rule reads
get alternate ←──── same
}
rule reads node.test.left.name:
.test → BinaryExpressionNode (new LazyNode)
.left → IdentifierNode (new LazyNode)
.name → straight from _ts.left.text (string, not LazyNode)
Deciding "fire a listener?" only needs ts.Node.kind — not the ESTree object itself:
const visit = (node: ts.Node, parentTarget): void => {
const hit = predicate(node);
if (hit) {
const target = materialize(node, ctx); // build LazyNode here
enterCb(target);
}
tsForEachChild(node, child => visit(child, target ?? parentTarget));
if (hit) leaveCb(target);
};predicate(node) decides via ESTree-type → ts.kind mapping — most are pure kind matches (IfStatement / BinaryExpression); a few need modifier / operator checks (LogicalExpression looks at the operator token; TSAbstractMethodDefinition looks at the abstract modifier).
Each LazyNode child field is a getter — node.test builds a BinaryExpressionNode the first time it's read and caches it; never read, never built. Bench: checker.ts × 43-rule averages ~5000-8000 LazyNodes built (vs eager ~28000+).
ESLint rules occasionally call astMaps.esTreeNodeToTSNodeMap.get(estreeNode) to look up the corresponding ts.Node. typescript-estree keeps two WeakMaps for forward and reverse mapping — every ESTree node built does two set() calls.
But every LazyNode already stores its ts.Node in the _ts field — the forward set is fully redundant. esTreeNodeToTSNodeMap becomes a facade object: get(estreeNode) returns estreeNode._ts, set is a no-op. ~9k fewer WeakMap.set calls per file.
LazyNode children can be lazy, but parent can't — the constructor needs the parent reference for super, and rules read .parent constantly.
The naive approach is recursing materialize(tsNode.parent): each call is a fresh frame. We replace that with one walk up + one build down, three phases:
materialize(tsNode):
(a) if tsNode is wrapper-routed — parent connects through a synthetic ESTree wrapper
(TSTypeAnnotation / ChainExpression / ..., see §Structural wrappers below):
materialize(owner) → trigger owner's slot getter → done
(b) otherwise walk up the parent chain, skip structural wrappers, find nearest cached ancestor:
while walker: skip SyntaxList/CaseBlock/...; cache hit → break;
else toBuild.push, walker = walker.parent
(c) build downward: from the cached anchor, new LazyNode all the way down,
constructor auto-registers in cache.
SourceFile is pre-registered in the cache — the walker never loops; every LazyNode built is auto-cached. Naive recursion against an ESTree depth of 30+ pays 30 stack frames per .parent access; one walk + reverse build pays 30 once, then ancestor cache hits in 1 step. Plenty of rules read .parent.
The ESTree / TS AST mismatch has two directions.
ESTree adds a synthetic wrapper (no TS kind):
ClassDeclaration → ClassBody → members— TS just hasClassDeclaration { members }directly, no intermediate ClassBodyTSInterfaceDeclaration → TSInterfaceBody → members— sameExportNamedDeclaration { declaration: VariableDeclaration }— TS is aVariableStatementwith an export modifierChainExpression { expression: MemberExpression }— TS isPropertyAccess?.fooat the outermost levelTSParameterProperty { parameter }— TS is a Parameter with a public/readonly modifierTSTypeQuery { exprName: TSImportType }— TS is an ImportType with isTypeOf=true
These ESTree types live in WRAPPER_HEAD_TYPES. On hit, unwrapChain pulls out the chain, enters them in order / leaves in reverse, with child visits in the middle. For inner types like ClassBody (no own ts.kind), SIMPLE_KINDS reverse-maps them to the head's kind (ClassBody → ClassDeclaration / ClassExpression, TSInterfaceBody → InterfaceDeclaration, etc.) — registering a 'ClassBody' listener marks ClassDeclaration's kind in the predicate; on hit, chain expansion enters both layers, and dispatch fires from each type's bucket.
TS AST adds a structural wrapper (no ESTree counterpart):
SyntaxList— TS parser internal markerCaseBlock—SwitchStatement.casesis a flat array in ESTree; TS wraps with CaseBlockVariableDeclarationListinside aVariableStatement— folded into VariableStatement → ESTree VariableDeclarationNamedImports—ImportDeclaration.specifiers[]is direct ImportSpecifiers in ESTreeImportClausewithout.name— no default specifier, no ESTree counterpart at all- Other position-dependent cases:
BlockinsideClassStaticBlockDeclaration(StaticBlockis the block),VariableDeclarationinsideCatchClause(catch param is the Identifier directly),HeritageClause(extends), left-side commaBinaryExpressionflattened into outerSequenceExpression
If the walker materialized all of these, the same source range would fire enter multiple times. Two skips: the downward walker (tsScanTraverse) doesn't enter, just recurses; the upward walker (materialize parent chain) skips and continues toward an ancestor.
The two skip sets are structurally symmetric but not identical: ImportClause in the downward walker has to inspect .name (a default name's .name is itself an ImportDefaultSpecifier to dispatch on), while the upward walker doesn't care and skips unconditionally. Missing a skip surfaces as enter firing twice or two independent LazyNode trees being built.
Dispatch is one inline path. The visitor decides whether to wrap CPA based on whether any rule registered an onCodePath* listener:
- Non-CPA mode (default) — visitor calls
dispatchTarget(target, isEnter, ...)directly on enter / leave. - CPA mode — visitor forwards enter / leave to
CodePathAnalyzer; CPA emits when it should emit, calls enter / leave when it should, and every hook dispatches inline todispatchTargetor its codePath listener array. CPA's call order is the dispatch order — no step queue mediates.
Both modes share dispatchTarget + runEntries. The only difference is whether the visitor wraps a CPA layer.
dispatchTarget:
function dispatchTarget(target, isEnter, fast, errors, onTarget) {
onTarget(target); // both phases update currentNode
const arr = (isEnter ? fast.enter : fast.exit).get(target.type);
if (arr) runEntries(arr, target, errors);
const allArr = isEnter ? fast.enterAll : fast.exitAll;
if (allArr.length) runEntries(allArr, target, errors);
}The action is a Map lookup + entry walk. runEntries short-circuits per entry: rule already errored → skip (a listener throw writes to the errors map; subsequent calls for the same rule all skip — rules don't run half-baked); fieldFire extracts a child (null / array → skip), fieldChain walks multiple fields with intermediate type checks (any null / array / type mismatch → skip); typeFilter matches; filter matches; only then the listener call. Simple selectors (80%+) skip all the ifs.
Compare ESLint: every visited node goes through one layer of listener-registry type-bucket dispatch, then every selector in the bucket runs through esquery's evaluator for complex conditions, then individual listener calls (each with its own try/catch). We inline the call, the listener leaves the array directly. Across 5860 files × 28k visits × N selectors, that framework layer is what's in the way.
typescript-estree eagerly builds sourceCode.tokens / sourceCode.comments (checker.ts: 348k tokens, 6.7k comments — cold ~50ms + ~115ms / file). Most rules never read them; we made them lazy — never read, never paid.
The control flow analyzer (CodePathAnalyzer) provides CFG segment objects for onCodePathStart / onCodePathEnd / onCodePathSegmentStart and the like. Rules like no-unreachable / no-fallthrough / require-atomic-updates use it.
CPA's emit and enter / leave order are tightly coupled (startCodePath → emit → forwardCurrentToHead → emit → original.enterNode); internal state must update during the walk — has to be inline, but doesn't need a buffer-then-replay step, hooks dispatch directly. In ESLint, CPA wraps the event generator — CPA's enterNode handles CFG state, then forwards to the original generator. We stuff dispatchTarget straight into the wrapper, then feed the wrapper to CPA:
const wrapped = {
emit: (name, args) => dispatchEvent(name, args), // codePath listener arrays
emitter: { emit: (name, ...args) => dispatchEvent(name, args) },
enterNode: t => dispatchTarget(t, true, fast, errors, onTarget),
leaveNode: t => dispatchTarget(t, false, fast, errors, onTarget),
};
new CodePathAnalyzer(wrapped);dispatchEvent uses the same errors.has short-circuit + try/catch wrapping a single listener call as runEntries — same shape. The CPA wrapper is under 30 lines and shares one dispatchTarget with non-CPA mode.
CPA mode uses predicateAllKinds (CPA's preprocess(node) reads node.parent.type — the walker can't narrow).
After three lines, runtime dependencies go from eslint + @typescript-eslint/{visitor-keys,typescript-estree,scope-manager} down to just @tsslint/types + esquery. What can be inlined as a static table is inlined; what can be redone via ts.checker is redone; what can be vendored is vendored. esquery's 1500-line grammar parser stays untouched — the principle is "don't rewrite what doesn't need rewriting".
This part isn't a speedup (the speed wins are already in the three lines above); it's about decoupling. When ESLint changes its internal emitter / SourceCode shape, this layer doesn't have to follow. The compatibility layer's responsibility ends at the public rule API.
With 3.0.4 in the table to see the delta:
| Wall (min of 3) | Peak RSS | |
|---|---|---|
| TSSLint 3.0.4 | 23 s | 7.0 GB |
| TSSLint 3.1 | 1.5 s | 3.75 GB |
| ESLint Linter | 25 s | 7.0 GB |
3.1 vs 3.0.4 is ~15× — the cumulative result of replacing typescript-estree and ESLint Linter machinery with our own lazy implementation.
3.1 vs ESLint Linter is ~16× — both sides are fed the same ts.Program (via programs: [program] to typescript-eslint parser, controlling for variables), so this 16× is the dispatch hot path's three things: lazy ESTree, selector decomposition into fast tables, inline dispatch (no emitter / step queue).
But this 16× is correlated with rule count. The bench runs a single rule, narrow trigger, ~10-20% hit rate — lazy ESTree saves the most when most nodes never materialize. Wider production rule sets push hit rates up and compress lazy's win; the speedup shrinks as you stack rules.
profile (node --prof) own-code time:
TS internals (binder / checker / scanner): 50-60%
ESLint rule code + plugin internals: 20-25%
ESLint SourceCode / TokenStore + CPA: 10-15%
compat-eslint own code (what we can move): 7.3%
7.3% is the floor. Even cleaning up all our own code only leaves ~7% to gain — realistically ~3% extractable; below that we're reaching for V8 internals, unrelated to "how lint runs". The remaining 92% of CPU is in TS internals, ESLint rule code, plugin internals — not on our side. The 16× bench number is for "narrow rule" workloads (wider rules compress the gap, see §Numbers); but "the part we can move has hit its ceiling" doesn't shift with workload.
Looking back, the three lines say one thing — "no hit, no read, no ask, no pay". Trigger doesn't fire → no materialize, child not read → no build, parent not walked → no construction; tokens and selectors are extensions of the same principle. What's left to save isn't on our side anymore.
ESLint picking ESTree as the base wasn't a constraint of the era — TSLint, contemporaneous, walked TS AST directly. ESLint chose ESTree because it was the established AST standard in the JS ecosystem; the cost is TS support stuck in the "TS AST → ESTree" conversion gap — I think the trade-off no longer holds after TS went mainstream. TSSLint doesn't break it either, just shrinks the gap to its minimum: lazy, materialize on hit.
TSSLint is a successor to TSLint's spirit — after TSLint deprecated in 2019, "go straight to TS AST" had no active carrier; TSSLint picks it up via the tsserver plugin form. Which is also why I don't see this as ESLint ecosystem's long-term answer. It only holds when three premises align — ESLint is still the community default, you need custom type-aware rules, the IDE wants sub-100ms response. The day base AST aligns with TS, or a native linter integrates into tsserver, TSSLint should retire.
This rewrite was almost entirely written, debugged, and profiled by an LLM; I just guided. Replacing the entire typescript-estree + ESLint Linter machinery and standing up a byte-identical parity bench across packages — the workload didn't pencil out for a single maintainer pre-AI. 3.1 is the version AI made possible.
Performance series, prior chapter: alien-signals deep dive
@tsslint/compat-eslint source: https://github.com/johnsoncodehk/tsslint/tree/master/packages/compat-eslint