Skip to content

feat(linter): per-rule timing support#19689

Closed
wagenet wants to merge 2 commits into
oxc-project:mainfrom
wagenet:feat/per-rule-timing
Closed

feat(linter): per-rule timing support#19689
wagenet wants to merge 2 commits into
oxc-project:mainfrom
wagenet:feat/per-rule-timing

Conversation

@wagenet

@wagenet wagenet commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds --timing flag (and TIMING=1 env var) that prints a per-rule execution time table at the end of a lint run, covering all three rule types:

  • Built-in Rust rules — accumulated via a timed_call! macro using a per-file local map to avoid per-call lock contention
  • Type-aware rules (tsgolint) — consumed from a new binary-framed timing message sent by the subprocess (tsgolint PR #740); wall-clock minus rule-sum gives TypeScript program-load overhead
  • JS plugin rules — the JS runtime times visitor callbacks and returns them as timings: number[]; NAPI bridge time minus JS rule time gives bridge overhead

Output

Rule                            | Time (ms) | Relative
--------------------------------|----------:|---------:
basic-custom-plugin/no-debugger |      0.13 |     0.2%
no-debugger                     |      0.09 |     0.1%
...

Overhead         | Time (ms)
-----------------|----------:
JS plugin bridge |      4.18

Relative is percentage of total wall-clock run time.

Protocol change

LintFileReturnValue::Success changes from a tuple to a struct variant ({ diagnostics, timings }) so the JS runtime can return optional per-rule timing data. This is an internal NAPI protocol change.

Test plan

  • TIMING=1 oxlint --timing <files> shows rule timing table for built-in rules
  • TIMING=1 oxlint --timing --type-aware <files> shows type-aware rules + "Type-aware linting setup" overhead (requires tsgolint #740)
  • JS plugin rules appear in table with "JS plugin bridge" overhead entry
  • Without TIMING / --timing, no overhead and no table
  • All existing tests pass

🤖 Generated with Claude Code

Adds `--timing` flag (and `TIMING` env var) to oxlint that shows a
per-rule breakdown of execution time at the end of a lint run.

## What's measured

**Built-in Rust rules** — each rule call is wrapped with `Instant::now()`
via a `timed_call!` macro; durations are accumulated in a per-file
`FxHashMap` and merged into a shared `TimingStore` once per file to
avoid per-call lock contention.

**Type-aware rules (tsgolint)** — when `TIMING` is set the tsgolint
subprocess wraps each listener with a nanosecond timer and sends a
binary-framed timing message after all diagnostics. The Rust side
merges these into `TimingStore`.  The wall-clock time minus the sum
of per-rule times is recorded as "Type-aware linting setup" overhead
(captures TypeScript program-loading cost).

**JS plugin rules** — the JS runtime (`src-js/plugins/lint.ts`) times
`create`, `before`/`after` hooks, and visitor callbacks when `TIMING`
is set, returning them as `timings: number[]` alongside diagnostics.
The Rust NAPI bridge measures the full `lint_file` call; subtracting
JS rule times gives "JS plugin bridge" overhead.

## Output

```
Rule                            | Time (ms) | Relative
--------------------------------|----------:|---------:
no-floating-promises            |     58.20 |    73.7%
no-misused-promises             |      3.10 |     3.9%
...

Overhead                        | Time (ms)
--------------------------------|----------:
Type-aware linting setup        |    120.40
JS plugin bridge                |      3.67
```

`Relative` is expressed as a percentage of wall-clock run time.

## Protocol change

`LintFileReturnValue::Success` is changed from a tuple variant to a
struct variant (`{ diagnostics, timings }`) so the JS side can return
optional per-rule timing data without a separate call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@wagenet wagenet requested a review from camc314 as a code owner February 24, 2026 17:22
Copilot AI review requested due to automatic review settings February 24, 2026 17:22
@github-actions github-actions Bot added A-linter Area - Linter A-cli Area - CLI A-linter-plugins Area - Linter JS plugins C-enhancement Category - New feature or request labels Feb 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 adds comprehensive per-rule timing support to oxlint, allowing developers to profile rule performance across all three rule types: built-in Rust rules, type-aware rules (tsgolint), and JS plugin rules. The implementation uses a --timing flag (or TIMING=1 environment variable) to enable instrumentation.

Changes:

  • Introduces a TimingStore for accumulating per-rule timing data with minimal lock contention
  • Instruments built-in Rust rules using a timed_call! macro that conditionally times rule execution with zero overhead when disabled
  • Integrates timing data from tsgolint subprocess via a new binary-framed timing message protocol
  • Captures JS plugin timing by wrapping visitor callbacks and tracking execution time
  • Outputs timing data as formatted tables showing top 10 rules and overhead breakdown

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
crates/oxc_linter/src/timing.rs New module defining TimingStore for thread-safe accumulation of per-rule timing data and overhead tracking
crates/oxc_linter/src/tsgolint.rs Adds timing protocol for type-aware rules: consumes timing messages from tsgolint subprocess and calculates program-load overhead
crates/oxc_linter/src/lib.rs Instruments built-in Rust rules with timed_call! macro and captures JS plugin timing via NAPI bridge
crates/oxc_linter/src/lint_runner.rs Threads timing store through LintRunnerBuilder to enable timing for type-aware linting
crates/oxc_linter/src/external_linter.rs Updates NAPI protocol to return timing data alongside diagnostics
apps/oxlint/src/output_formatter/mod.rs Adds rule_timings and overhead_timings fields to LintCommandInfo
apps/oxlint/src/output_formatter/json.rs Updates test fixtures to include new timing fields (set to None)
apps/oxlint/src/output_formatter/default.rs Implements timing table formatting: top 10 rules with relative percentages and overhead section
apps/oxlint/src/lint.rs Wires up timing store initialization and collection based on --timing flag or TIMING env var
apps/oxlint/src/js_plugins/external_linter.rs Changes LintFileReturnValue::Success from tuple to struct variant to include optional timings
apps/oxlint/src/command/lint.rs Adds --timing flag to CLI options
apps/oxlint/src-js/plugins/lint.ts Implements timing instrumentation in JS runtime: wraps visitor callbacks and tracks execution time per rule

Comment on lines +268 to +272
.map(|(name, ms)| (name, (Duration::from_secs_f64(ms / 1000.0), 1)))
.collect();
store.merge(local);
}

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

The tsgolint timing data always uses a count of 1 for each rule, regardless of how many files were linted. This is incorrect for multi-file linting scenarios where tsgolint processes multiple files in a single subprocess invocation. The timings from tsgolint represent accumulated execution time across all files, but the count should reflect the actual number of times each rule was executed.

Consider either:

  1. Having tsgolint report per-rule execution counts in addition to timing data, or
  2. Not reporting counts for type-aware rules if accurate counts are unavailable

Copilot uses AI. Check for mistakes.
self.config.resolve_plugin_rule_names(rule_id);
let key = format!("{plugin_name}/{rule_name}");
let entry = lt.entry(key).or_default();
entry.0 += Duration::from_secs_f64(time_ms / 1000.0);

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

The JS plugin timing count is always set to 1, even though multiple visitor callbacks for the same rule can be invoked during AST traversal. This means the count represents "1 file" rather than the actual number of callback invocations. While this may be intentional for consistency with how timing is reported, it should be documented or the counting approach should be made consistent across all rule types (built-in rules count each callback invocation).

Suggested change
entry.0 += Duration::from_secs_f64(time_ms / 1000.0);
entry.0 += Duration::from_secs_f64(time_ms / 1000.0);
// For JS plugins, this count is "per rule per file" (one increment per
// file where the rule ran), not per individual visitor callback
// invocation. Built-in rules may count each callback invocation.

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +32
let mut inner = self.inner.lock().unwrap();
for (name, (dur, count)) in local {
let entry = inner.entry(name).or_default();
entry.0 += dur;
entry.1 += count;
}

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

The unwrap() calls on mutex locks can cause panics if the mutex is poisoned (which happens if a thread panics while holding the lock). While this is unlikely in normal operation, consider using expect() with a descriptive message to aid debugging, or handle the potential PoisonError more gracefully.

Copilot uses AI. Check for mistakes.
if dur.is_zero() {
return;
}
*self.overhead.lock().unwrap().entry(name.to_owned()).or_default() += dur;

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

The unwrap() calls on mutex locks can cause panics if the mutex is poisoned. Consider using expect() with descriptive messages instead.

Copilot uses AI. Check for mistakes.
Comment on lines +690 to +698
// Record JS plugin bridge overhead: total call time minus JS rule execution time.
// When js_timings is None (JS runtime doesn't support timing), the full call
// duration is recorded (includes rule execution time in that case).
if let Some(store) = &self.timing_store {
let js_rules_ms: f64 =
js_timings.as_ref().map(|t| t.iter().sum()).unwrap_or(0.0);
let bridge_dur =
js_call_dur.saturating_sub(Duration::from_secs_f64(js_rules_ms / 1000.0));
store.record_overhead("JS plugin bridge", bridge_dur);

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

When js_timings is None, the overhead calculation records the full JS call duration as "JS plugin bridge" overhead, which includes actual JS rule execution time. While documented in a comment, this could be misleading to users who see large "JS plugin bridge" overhead values when timing isn't enabled in the JS runtime. Consider only recording overhead when js_timings.is_some(), or using a different label like "JS plugin (incl. rules)" when timing data is unavailable.

Suggested change
// Record JS plugin bridge overhead: total call time minus JS rule execution time.
// When js_timings is None (JS runtime doesn't support timing), the full call
// duration is recorded (includes rule execution time in that case).
if let Some(store) = &self.timing_store {
let js_rules_ms: f64 =
js_timings.as_ref().map(|t| t.iter().sum()).unwrap_or(0.0);
let bridge_dur =
js_call_dur.saturating_sub(Duration::from_secs_f64(js_rules_ms / 1000.0));
store.record_overhead("JS plugin bridge", bridge_dur);
// Record JS plugin overhead. When per-rule JS timings are available, record
// only the bridge overhead (total call time minus JS rule execution time).
// When js_timings is None (JS runtime doesn't support timing), record the full
// call duration under a label that makes it clear it includes rule time.
if let Some(store) = &self.timing_store {
match js_timings.as_ref() {
Some(timings) => {
let js_rules_ms: f64 = timings.iter().sum();
let bridge_dur = js_call_dur.saturating_sub(Duration::from_secs_f64(
js_rules_ms / 1000.0,
));
store.record_overhead("JS plugin bridge", bridge_dur);
}
None => {
store.record_overhead("JS plugin (incl. rules)", js_call_dur);
}
}

Copilot uses AI. Check for mistakes.
Ok(TsGoLintMessage::Error(err)) => {
return Err(err.error);
}
Ok(TsGoLintMessage::Timing(timings)) => {

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

The PR description mentions "tsgolint PR #740" which refers to a proxy detection feature, not timing support. This appears to be a copy-paste error. The correct PR number for timing support in tsgolint should be verified and updated in the PR description.

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +95
let wall_ms = wall_clock.as_secs_f64() * 1000.0;

let mut out = String::new();
out.push_str(&format!("{:<w$} | Time (ms) | Relative\n", "Rule", w = rule_col));
out.push_str(&format!("{:-<w$}-|----------:|---------:\n", "", w = rule_col));
for (name, dur, _) in &top {
let ms = dur.as_secs_f64() * 1000.0;
let rel = if wall_ms > 0.0 { ms / wall_ms * 100.0 } else { 0.0 };
out.push_str(&format!("{:<w$} | {:>9.2} | {:>7.1}%\n", name, ms, rel, w = rule_col));

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

The third element of the timing tuple (count: u64) is collected but never displayed in the output table. Consider either adding a "Count" or "Calls" column to the table to show how many times each rule was invoked, or documenting why the count is tracked but not displayed. This information could be valuable for understanding rule performance characteristics.

Suggested change
let wall_ms = wall_clock.as_secs_f64() * 1000.0;
let mut out = String::new();
out.push_str(&format!("{:<w$} | Time (ms) | Relative\n", "Rule", w = rule_col));
out.push_str(&format!("{:-<w$}-|----------:|---------:\n", "", w = rule_col));
for (name, dur, _) in &top {
let ms = dur.as_secs_f64() * 1000.0;
let rel = if wall_ms > 0.0 { ms / wall_ms * 100.0 } else { 0.0 };
out.push_str(&format!("{:<w$} | {:>9.2} | {:>7.1}%\n", name, ms, rel, w = rule_col));
let count_col = top
.iter()
.map(|(_, _, count)| count.to_string().len())
.max()
.unwrap_or(5)
.max(5);
let wall_ms = wall_clock.as_secs_f64() * 1000.0;
let mut out = String::new();
out.push_str(&format!(
"{:<rule_w$} | {:>count_w$} | Time (ms) | Relative\n",
"Rule",
"Calls",
rule_w = rule_col,
count_w = count_col
));
out.push_str(&format!(
"{:-<rule_w$}-|{:-<count_w$}-|----------:|---------:\n",
"",
"",
rule_w = rule_col,
count_w = count_col
));
for (name, dur, count) in &top {
let ms = dur.as_secs_f64() * 1000.0;
let rel = if wall_ms > 0.0 { ms / wall_ms * 100.0 } else { 0.0 };
out.push_str(&format!(
"{:<rule_w$} | {:>count_w$} | {:>9.2} | {:>7.1}%\n",
name,
count,
ms,
rel,
rule_w = rule_col,
count_w = count_col
));

Copilot uses AI. Check for mistakes.
Comment on lines +78 to +81
/// Render a per-rule timing table (top 10 rules by total time).
/// `Relative` is expressed as a percentage of the wall-clock run time.
fn format_timing_table(timings: &[(String, Duration, u64)], wall_clock: Duration) -> String {
let top: Vec<_> = timings.iter().take(10).collect();

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

The timing table is hardcoded to show only the top 10 rules by duration. For comprehensive performance analysis, users might want to see all rules or configure the limit. Consider either documenting this limitation in the --timing flag help text, or making the limit configurable (e.g., via an environment variable like TIMING_LIMIT).

Suggested change
/// Render a per-rule timing table (top 10 rules by total time).
/// `Relative` is expressed as a percentage of the wall-clock run time.
fn format_timing_table(timings: &[(String, Duration, u64)], wall_clock: Duration) -> String {
let top: Vec<_> = timings.iter().take(10).collect();
/// Render a per-rule timing table (top N rules by total time, default 10).
/// `Relative` is expressed as a percentage of the wall-clock run time.
/// The number of rules shown can be configured via the `TIMING_LIMIT` environment variable.
fn format_timing_table(timings: &[(String, Duration, u64)], wall_clock: Duration) -> String {
let limit = std::env::var("TIMING_LIMIT")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or(10);
let top: Vec<_> = timings.iter().take(limit).collect();

Copilot uses AI. Check for mistakes.
@codspeed-hq

codspeed-hq Bot commented Feb 24, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 15.62%

❌ 4 regressed benchmarks
⏩ 51 skipped benchmarks1

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation linter[binder.ts] 51.7 ms 61.2 ms -15.62%
Simulation linter[react.development.js] 19.9 ms 23.1 ms -13.62%
Simulation linter[RadixUIAdoptionSection.jsx] 776.1 µs 878.5 µs -11.66%
Simulation linter[cal.com.tsx] 521.5 ms 614.5 ms -15.14%

Comparing wagenet:feat/per-rule-timing (72473b8) with main (ce7e253)

Open in CodSpeed

Footnotes

  1. 51 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@camc314

camc314 commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

Thanks for working on this - however losing as not planned due to the performance impace on eslint - we have a separate issue to track timiings of JS plugins rules #19745

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-enhancement Category - New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants