fix(linter): clamp invalid JS plugin locations (#26144)

Follow-up to #26138, closes #25867.

External plugins can report a node range past the source text. The default formatter rejects that invalid span and previously panicked. Clamp external diagnostic offsets to the source's UTF-16 length before normalizing reversed ranges.

The end-to-end fixture now covers:
 - reversed locations
 - negative loc values
 - negative node ranges
 - an out-of-range node range in Unicode source text
This commit is contained in:
camc314 committed 2026-08-28 13:33:32 +00:00
1 parent c3dedc9b0c
commit e412cf2ce3
11 files changed
+252 -177

No files matched your search

@@ -8,16 +8,6 @@
| Error: `after` hook threw
| at after (<fixture>/plugin.ts:108:15)
x Error running JS plugin.
| File path: <fixture>/files/throwInBefore/1.js
| Error: `before` hook threw
| at before (<fixture>/plugin.ts:82:15)
x Error running JS plugin.
| File path: <fixture>/files/throwInVisit/1.js
| Error: `Identifier` visit function threw
| at Identifier (<fixture>/plugin.ts:124:15)
x create-once-errors-plugin(before-and-after-late): after hook:
| id: create-once-errors-plugin/before-and-after-late
| filename: <fixture>/files/throwInAfter/2.js
@@ -44,6 +34,11 @@
: ^
`----
x Error running JS plugin.
| File path: <fixture>/files/throwInBefore/1.js
| Error: `before` hook threw
| at before (<fixture>/plugin.ts:82:15)
x create-once-errors-plugin(before-and-after-late): after hook:
| id: create-once-errors-plugin/before-and-after-late
| filename: <fixture>/files/throwInBefore/2.js
@@ -64,6 +59,11 @@
: ^
`----
x Error running JS plugin.
| File path: <fixture>/files/throwInVisit/1.js
| Error: `Identifier` visit function threw
| at Identifier (<fixture>/plugin.ts:124:15)
x create-once-errors-plugin(before-and-after-late): after hook:
| id: create-once-errors-plugin/before-and-after-late
| filename: <fixture>/files/throwInVisit/2.js
@@ -0,0 +1 @@
debugger;
@@ -0,0 +1 @@
debugger;
@@ -0,0 +1,2 @@
/* 😀 */
debugger;
+17 -2
View File
@@ -39,8 +39,23 @@
: ^^^^^^
`----
Found 0 warnings and 5 errors.
Finished in Xms on 1 file with 1 rules using X threads.
x Error running JS plugin.
| File path: <fixture>/files/negative-loc.js
| RangeError: Line/column pair translates to an out of range offset
| at Program (<fixture>/plugin.ts:14:23)
x Error running JS plugin.
| File path: <fixture>/files/negative-node.js
| TypeError: `node.range[0]` and `node.range[1]` must be non-negative integers
| at Program (<fixture>/plugin.ts:22:23)
x loc-plugin(no-bugger): Out-of-range node
,-[files/out-of-range-node.js:3:1]
2 | debugger;
`----
Found 0 warnings and 8 errors.
Finished in Xms on 4 files with 1 rules using X threads.
```
# stderr
+26
View File
@@ -10,6 +10,30 @@ const plugin: Plugin = {
let debuggerCount = 0;
return {
Program(_node) {
if (context.filename.endsWith("negative-loc.js")) {
context.report({
message: "Negative location",
loc: { start: { line: 1, column: -1 } },
});
return;
}
if (context.filename.endsWith("negative-node.js")) {
context.report({
message: "Negative node",
node: { range: [-1, 0] } as never,
});
return;
}
if (context.filename.endsWith("out-of-range-node.js")) {
context.report({
message: "Out-of-range node",
node: { range: [999, 1000] } as never,
});
return;
}
context.report({
message: "Misaligned location",
loc: {
@@ -26,6 +50,8 @@ const plugin: Plugin = {
});
},
DebuggerStatement(_node) {
if (context.filename.endsWith("out-of-range-node.js")) return;
debuggerCount++;
context.report({
message: "Bugger!",
+48 -48
View File
@@ -3,54 +3,6 @@
# stdout
```
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x fixes-plugin(fixes): Replace "a" with "daddy"
,-[files/bom_remove.js:1:4]
1 | a = c;
@@ -65,6 +17,14 @@
2 | d = b
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x fixes-plugin(fixes): end negative
,-[files/range_end_negative.js:1:5]
1 | let x;
@@ -77,6 +37,14 @@
: ^
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x fixes-plugin(fixes): end out of bounds
,-[files/range_end_out_of_bounds.js:1:5]
1 | let x;
@@ -89,6 +57,14 @@
: ^
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x fixes-plugin(fixes): end too large
,-[files/range_end_too_large.js:1:5]
1 | let x;
@@ -101,6 +77,14 @@
: ^
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x fixes-plugin(fixes): start after end
,-[files/range_start_after_end.js:1:5]
1 | let x;
@@ -113,6 +97,14 @@
: ^
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x fixes-plugin(fixes): start negative
,-[files/range_start_negative.js:1:5]
1 | let x;
@@ -125,6 +117,14 @@
: ^
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x fixes-plugin(fixes): start too large
,-[files/range_start_too_large.js:1:5]
1 | let x;
+48 -48
View File
@@ -3,54 +3,6 @@
# stdout
```
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x fixes-plugin(fixes): Replace "a" with "daddy"
,-[files/bom.js:1:4]
1 | a = c;
@@ -269,6 +221,14 @@
: ^^^^^^^^^
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x fixes-plugin(fixes): end negative
,-[files/range_end_negative.js:1:5]
1 | let x;
@@ -281,6 +241,14 @@
: ^
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x fixes-plugin(fixes): end out of bounds
,-[files/range_end_out_of_bounds.js:1:5]
1 | let x;
@@ -293,6 +261,14 @@
: ^
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x fixes-plugin(fixes): end too large
,-[files/range_end_too_large.js:1:5]
1 | let x;
@@ -305,6 +281,14 @@
: ^
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x fixes-plugin(fixes): start after end
,-[files/range_start_after_end.js:1:5]
1 | let x;
@@ -317,6 +301,14 @@
: ^
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x fixes-plugin(fixes): start negative
,-[files/range_start_negative.js:1:5]
1 | let x;
@@ -329,6 +321,14 @@
: ^
`----
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x Plugin `fixes-plugin/fixes` returned invalid fixes.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x fixes-plugin(fixes): start too large
,-[files/range_start_too_large.js:1:5]
1 | let x;
+48 -48
View File
@@ -3,54 +3,6 @@
# stdout
```
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x suggestions-plugin(suggestions): Replace "a" with "daddy"
,-[files/bom_remove.js:1:4]
1 | a = c;
@@ -65,6 +17,14 @@
2 | d = b
`----
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_negative.js
| Invalid range: 0..0
x suggestions-plugin(suggestions): end negative
,-[files/range_end_negative.js:1:5]
1 | let x;
@@ -77,6 +37,14 @@
: ^
`----
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_out_of_bounds.js
| Invalid range: 7..7
x suggestions-plugin(suggestions): end out of bounds
,-[files/range_end_out_of_bounds.js:1:5]
1 | let x;
@@ -89,6 +57,14 @@
: ^
`----
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_end_too_large.js
| Invalid range: 0..0
x suggestions-plugin(suggestions): end too large
,-[files/range_end_too_large.js:1:5]
1 | let x;
@@ -101,6 +77,14 @@
: ^
`----
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_after_end.js
| Negative range is invalid: Span { start: 3, end: 2 }
x suggestions-plugin(suggestions): start after end
,-[files/range_start_after_end.js:1:5]
1 | let x;
@@ -113,6 +97,14 @@
: ^
`----
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_negative.js
| Invalid range: 0..0
x suggestions-plugin(suggestions): start negative
,-[files/range_start_negative.js:1:5]
1 | let x;
@@ -125,6 +117,14 @@
: ^
`----
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x Plugin `suggestions-plugin/suggestions` returned invalid suggestions.
| File path: <fixture>/files/range_start_too_large.js
| Invalid range: 0..0
x suggestions-plugin(suggestions): start too large
,-[files/range_start_too_large.js:1:5]
1 | let x;
+34 -17
View File
@@ -173,29 +173,46 @@ impl Info {
let mut severity = Severity::Warning;
let rule_id = diagnostic.code().map(|code| code.to_string());
if let Some(source) = diagnostic.source_code()
&& let Some(label) = diagnostic.labels().first()
&& let Ok(start_offset) = usize::try_from(label.offset())
&& let Some(end_offset) = label.offset().checked_add(label.len())
&& let Ok(end_offset) = usize::try_from(end_offset)
&& source.data().get(start_offset..end_offset).is_some()
&& let Some(start_position) = line_column(source.data(), start_offset)
{
start = start_position;
if let Some(end_position) = line_column(source.data(), end_offset) {
end = end_position;
}
if let Some(source) = diagnostic.source_code() {
if let Some(name) = source.name() {
filename = name.to_string();
}
if matches!(diagnostic.severity(), Some(Severity::Error)) {
severity = Severity::Error;
}
message = diagnostic.to_string();
if let Some(label) = diagnostic.labels().first()
&& let Ok(start_offset) = usize::try_from(label.offset())
&& let Some(end_offset) = label.offset().checked_add(label.len())
&& let Ok(end_offset) = usize::try_from(end_offset)
&& source.data().get(start_offset..end_offset).is_some()
&& let Some(start_position) = line_column(source.data(), start_offset)
{
start = start_position;
if let Some(end_position) = line_column(source.data(), end_offset) {
end = end_position;
}
if matches!(diagnostic.severity(), Some(Severity::Error)) {
severity = Severity::Error;
}
message = diagnostic.to_string();
}
}
Self { start, end, filename, message, severity, rule_id }
}
}
#[cfg(test)]
mod test {
use crate::{Error, NamedSource, OxcDiagnostic};
use super::Info;
#[test]
fn includes_source_name_for_unlabelled_diagnostic() {
let error: Error = OxcDiagnostic::error("Something went wrong")
.with_source_code(NamedSource::new("file.js", ""));
assert_eq!(Info::new(&error).filename, "file.js");
}
}
+17 -4
View File
@@ -741,6 +741,10 @@ impl Linter {
program.source_text = source_text;
}
#[expect(clippy::cast_possible_truncation)]
let source_text_utf8_end =
source_text.len() as u32 + if has_bom { BOM_LEN as u32 } else { trim_leading };
// Create span converter.
// If source starts with BOM, create converter which ignores the BOM.
let span_converter = if has_bom {
@@ -752,6 +756,12 @@ impl Linter {
Utf8ToUtf16::new(source_text)
};
// Convert the end of the (trimmed) source text to the UTF-16 offsets used by JS plugins.
let mut source_text_utf16_len = source_text_utf8_end;
if let Some(mut converter) = span_converter.converter() {
converter.convert_offset(&mut source_text_utf16_len);
}
// Convert token spans to UTF-16 and update token kinds
#[expect(clippy::if_not_else, clippy::cast_possible_truncation)]
let (tokens_offset, tokens_len) = if !tokens.is_empty() {
@@ -848,12 +858,15 @@ impl Linter {
Ok(diagnostics) => {
for diagnostic in diagnostics {
// Convert UTF-16 offsets back to UTF-8.
// ESLint permits a reported location to end before it starts. Represent those
// as an empty span at the reported start, preserving its displayed position.
// External plugins may report locations outside the source text, or which end
// before they start. Clamp them to the end of the source, and represent
// reversed locations as empty spans at their stated start.
//
// Also make sure offsets do not fall in middle of a multi-byte UTF-8 character.
// That's possible if UTF-16 offset points to middle of a surrogate pair.
let mut span =
Span::new(diagnostic.start, diagnostic.end.max(diagnostic.start));
let start = diagnostic.start.min(source_text_utf16_len);
let end = diagnostic.end.min(source_text_utf16_len).max(start);
let mut span = Span::new(start, end);
span_converter.convert_span_back(&mut span);
let (external_rule_id, _options_id, severity) =