Coverage for lintro / tools / definitions / oxlint.py: 94%
172 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Oxlint tool definition.
3Oxlint is a fast JavaScript/TypeScript linter with 661+ built-in rules.
4It provides fast linting with minimal configuration.
5"""
7from __future__ import annotations
9import subprocess # nosec B404 - used safely with shell disabled
10from dataclasses import dataclass
11from typing import Any
13from loguru import logger
15from lintro._tool_versions import get_min_version
16from lintro.enums.doc_url_template import DocUrlTemplate
17from lintro.enums.tool_name import ToolName
18from lintro.enums.tool_type import ToolType
19from lintro.models.core.tool_result import ToolResult
20from lintro.parsers.oxlint.oxlint_issue import OxlintIssue
21from lintro.parsers.oxlint.oxlint_parser import parse_oxlint_output
22from lintro.plugins.base import BaseToolPlugin
23from lintro.plugins.protocol import ToolDefinition
24from lintro.plugins.registry import register_tool
25from lintro.tools.core.option_validators import (
26 filter_none_options,
27 normalize_str_or_list,
28 validate_bool,
29 validate_list,
30 validate_positive_int,
31 validate_str,
32)
34# Constants for Oxlint configuration
35OXLINT_DEFAULT_TIMEOUT: int = 30
36OXLINT_DEFAULT_PRIORITY: int = 50
37OXLINT_FILE_PATTERNS: list[str] = [
38 "*.js",
39 "*.mjs",
40 "*.cjs",
41 "*.jsx",
42 "*.ts",
43 "*.mts",
44 "*.cts",
45 "*.tsx",
46 "*.vue",
47 "*.svelte",
48 "*.astro",
49]
52@register_tool
53@dataclass
54class OxlintPlugin(BaseToolPlugin):
55 """Oxlint JavaScript/TypeScript linter plugin.
57 This plugin integrates Oxlint with Lintro for linting JavaScript,
58 TypeScript, and related framework files.
59 """
61 @property
62 def definition(self) -> ToolDefinition:
63 """Return the tool definition.
65 Returns:
66 ToolDefinition containing tool metadata.
67 """
68 return ToolDefinition(
69 name="oxlint",
70 description=("Fast JavaScript/TypeScript linter with 661+ built-in rules"),
71 can_fix=True,
72 tool_type=ToolType.LINTER,
73 file_patterns=OXLINT_FILE_PATTERNS,
74 priority=OXLINT_DEFAULT_PRIORITY,
75 conflicts_with=[],
76 native_configs=[".oxlintrc.json"],
77 version_command=["oxlint", "--version"],
78 min_version=get_min_version(ToolName.OXLINT),
79 default_options={
80 "timeout": OXLINT_DEFAULT_TIMEOUT,
81 "quiet": False,
82 },
83 default_timeout=OXLINT_DEFAULT_TIMEOUT,
84 )
86 def __post_init__(self) -> None:
87 """Initialize the tool with default options."""
88 super().__post_init__()
89 self.options.setdefault("quiet", False)
91 def set_options(
92 self,
93 exclude_patterns: list[str] | None = None,
94 include_venv: bool = False,
95 timeout: int | None = None,
96 quiet: bool | None = None,
97 verbose_fix_output: bool | None = None,
98 config: str | None = None,
99 tsconfig: str | None = None,
100 allow: list[str] | str | None = None,
101 deny: list[str] | str | None = None,
102 warn: list[str] | str | None = None,
103 **kwargs: Any,
104 ) -> None:
105 """Set Oxlint-specific options.
107 Args:
108 exclude_patterns: List of patterns to exclude.
109 include_venv: Whether to include virtual environment directories.
110 timeout: Timeout in seconds (default: 30).
111 quiet: If True, suppress warnings and only report errors.
112 verbose_fix_output: If True, include raw Oxlint output in fix().
113 config: Path to Oxlint config file (--config).
114 tsconfig: Path to tsconfig.json for TypeScript support (--tsconfig).
115 allow: Rules to allow/turn off (--allow). Can be string or list.
116 deny: Rules to deny/report as errors (--deny). Can be string or list.
117 warn: Rules to warn on (--warn). Can be string or list.
118 **kwargs: Additional options (ignored for compatibility).
119 """
120 validate_list(exclude_patterns, "exclude_patterns")
121 validate_positive_int(timeout, "timeout")
122 validate_bool(quiet, "quiet")
123 validate_bool(verbose_fix_output, "verbose_fix_output")
124 validate_str(config, "config")
125 validate_str(tsconfig, "tsconfig")
127 # Normalize rule lists (accept string or list)
128 allow_list = normalize_str_or_list(allow, "allow")
129 deny_list = normalize_str_or_list(deny, "deny")
130 warn_list = normalize_str_or_list(warn, "warn")
132 if exclude_patterns is not None:
133 self.exclude_patterns = exclude_patterns.copy()
134 self.include_venv = include_venv
136 options = filter_none_options(
137 timeout=timeout,
138 quiet=quiet,
139 verbose_fix_output=verbose_fix_output,
140 config=config,
141 tsconfig=tsconfig,
142 allow=allow_list,
143 deny=deny_list,
144 warn=warn_list,
145 )
146 super().set_options(**options, **kwargs)
148 def _create_timeout_result(
149 self,
150 timeout_val: int,
151 initial_issues: list[OxlintIssue] | None = None,
152 initial_count: int = 0,
153 cwd: str | None = None,
154 ) -> ToolResult:
155 """Create a ToolResult for timeout scenarios.
157 Args:
158 timeout_val: The timeout value that was exceeded.
159 initial_issues: Optional list of issues found before timeout.
160 initial_count: Optional count of initial issues.
161 cwd: Working directory for the tool result.
163 Returns:
164 ToolResult: ToolResult instance representing timeout failure.
165 """
166 timeout_msg = (
167 f"Oxlint execution timed out ({timeout_val}s limit exceeded).\n\n"
168 "This may indicate:\n"
169 " - Large codebase taking too long to process\n"
170 " - Need to increase timeout via --tool-options oxlint:timeout=N"
171 )
172 timeout_issue = OxlintIssue(
173 file="execution",
174 line=1,
175 column=1,
176 code="TIMEOUT",
177 message=timeout_msg,
178 severity="error",
179 fixable=False,
180 )
181 if initial_issues is not None:
182 pre_fix_count = len(initial_issues)
183 else:
184 pre_fix_count = initial_count
185 return ToolResult(
186 name=self.definition.name,
187 success=False,
188 output=timeout_msg,
189 issues_count=1,
190 issues=[timeout_issue],
191 initial_issues_count=pre_fix_count,
192 initial_issues=initial_issues if initial_issues is not None else None,
193 cwd=cwd,
194 )
196 def _build_oxlint_args(self, options: dict[str, object]) -> list[str]:
197 """Build CLI arguments from options.
199 Args:
200 options: Options dict to build args from (use merged_options).
202 Returns:
203 List of CLI arguments to pass to oxlint.
204 """
205 args: list[str] = []
207 # Config file override
208 config = options.get("config")
209 if config:
210 args.extend(["--config", str(config)])
212 # TypeScript config
213 tsconfig = options.get("tsconfig")
214 if tsconfig:
215 args.extend(["--tsconfig", str(tsconfig)])
217 # Rule severity options
218 allow_rules = options.get("allow")
219 if allow_rules and isinstance(allow_rules, list):
220 for rule in allow_rules:
221 args.extend(["--allow", rule])
223 deny_rules = options.get("deny")
224 if deny_rules and isinstance(deny_rules, list):
225 for rule in deny_rules:
226 args.extend(["--deny", rule])
228 warn_rules = options.get("warn")
229 if warn_rules and isinstance(warn_rules, list):
230 for rule in warn_rules:
231 args.extend(["--warn", rule])
233 return args
235 def doc_url(self, code: str) -> str | None:
236 """Return oxlint documentation URL for the given rule.
238 Args:
239 code: Oxlint rule in "category/rule" format
240 (e.g., "deepscan/bad-comparison-sequence").
242 Returns:
243 URL to the oxlint rule documentation.
244 """
245 if code and "/" in code:
246 return DocUrlTemplate.OXLINT.format(code=code)
247 return None
249 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
250 """Check files with Oxlint without making changes.
252 Args:
253 paths: List of file or directory paths to check.
254 options: Runtime options that override defaults.
256 Returns:
257 ToolResult with check results.
258 """
259 # Merge runtime options
260 merged_options = dict(self.options)
261 merged_options.update(options)
263 # Use shared preparation for version check, path validation, file discovery
264 ctx = self._prepare_execution(
265 paths,
266 merged_options,
267 no_files_message="No files to check.",
268 )
269 if ctx.should_skip:
270 assert ctx.early_result is not None
271 return ctx.early_result
273 logger.debug(
274 f"[OxlintPlugin] Discovered {len(ctx.files)} files matching patterns: "
275 f"{self.definition.file_patterns}",
276 )
277 logger.debug(
278 f"[OxlintPlugin] Exclude patterns applied: {self.exclude_patterns}",
279 )
280 if ctx.files:
281 logger.debug(
282 f"[OxlintPlugin] Files to check (first 10): {ctx.files[:10]}",
283 )
284 logger.debug(f"[OxlintPlugin] Working directory: {ctx.cwd}")
286 # Build Oxlint command with JSON format
287 cmd: list[str] = self._get_executable_command(tool_name="oxlint") + [
288 "--format",
289 "json",
290 ]
292 # Add quiet flag if enabled (suppress warnings, only report errors)
293 if self.options.get("quiet", False):
294 cmd.append("--quiet")
296 # Add Lintro config injection args if available
297 config_args = self._build_config_args()
298 if config_args:
299 cmd.extend(config_args)
300 logger.debug("[OxlintPlugin] Using Lintro config injection")
302 # Add Oxlint-specific CLI arguments from options
303 oxlint_args = self._build_oxlint_args(merged_options)
304 if oxlint_args:
305 cmd.extend(oxlint_args)
307 cmd.extend(ctx.rel_files)
308 logger.debug(f"[OxlintPlugin] Running: {' '.join(cmd)} (cwd={ctx.cwd})")
310 try:
311 result = self._run_subprocess(
312 cmd=cmd,
313 timeout=ctx.timeout,
314 cwd=ctx.cwd,
315 )
316 except subprocess.TimeoutExpired:
317 return self._create_timeout_result(timeout_val=ctx.timeout, cwd=ctx.cwd)
319 output: str = result[1]
320 issues: list[OxlintIssue] = parse_oxlint_output(output=output)
321 issues_count: int = len(issues)
322 success: bool = issues_count == 0
324 # Standardize: suppress Oxlint's informational output when no issues
325 final_output: str | None = output
326 if success:
327 final_output = None
329 return ToolResult(
330 name=self.definition.name,
331 success=success,
332 output=final_output,
333 issues_count=issues_count,
334 issues=issues,
335 cwd=ctx.cwd,
336 )
338 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
339 """Fix auto-fixable issues in files with Oxlint.
341 Args:
342 paths: List of file or directory paths to fix.
343 options: Runtime options that override defaults.
345 Returns:
346 ToolResult: Result object with counts and messages.
347 """
348 # Merge runtime options
349 merged_options = dict(self.options)
350 merged_options.update(options)
352 # Use shared preparation for version check, path validation, file discovery
353 ctx = self._prepare_execution(
354 paths,
355 merged_options,
356 no_files_message="No files to fix.",
357 )
358 if ctx.should_skip:
359 assert ctx.early_result is not None
360 return ctx.early_result
362 # Get Lintro config injection args if available
363 config_args = self._build_config_args()
365 # Add Oxlint-specific CLI arguments from options
366 oxlint_args = self._build_oxlint_args(merged_options)
368 # Build check command for counting issues
369 check_cmd: list[str] = self._get_executable_command(tool_name="oxlint") + [
370 "--format",
371 "json",
372 ]
374 # Add quiet flag if enabled (suppress warnings, only report errors)
375 if self.options.get("quiet", False):
376 check_cmd.append("--quiet")
378 if config_args:
379 check_cmd.extend(config_args)
380 if oxlint_args:
381 check_cmd.extend(oxlint_args)
383 check_cmd.extend(ctx.rel_files)
384 logger.debug(
385 f"[OxlintPlugin] Checking: {' '.join(check_cmd)} (cwd={ctx.cwd})",
386 )
388 # Check for initial issues
389 try:
390 check_result = self._run_subprocess(
391 cmd=check_cmd,
392 timeout=ctx.timeout,
393 cwd=ctx.cwd,
394 )
395 except subprocess.TimeoutExpired:
396 return self._create_timeout_result(timeout_val=ctx.timeout, cwd=ctx.cwd)
398 check_output: str = check_result[1]
399 initial_issues: list[OxlintIssue] = parse_oxlint_output(output=check_output)
400 initial_count: int = len(initial_issues)
402 # Now fix the issues
403 fix_cmd: list[str] = self._get_executable_command(tool_name="oxlint") + [
404 "--fix",
405 ]
406 if config_args:
407 fix_cmd.extend(config_args)
408 if oxlint_args:
409 fix_cmd.extend(oxlint_args)
410 fix_cmd.extend(ctx.rel_files)
411 logger.debug(f"[OxlintPlugin] Fixing: {' '.join(fix_cmd)} (cwd={ctx.cwd})")
413 try:
414 fix_result = self._run_subprocess(
415 cmd=fix_cmd,
416 timeout=ctx.timeout,
417 cwd=ctx.cwd,
418 )
419 except subprocess.TimeoutExpired:
420 return self._create_timeout_result(
421 timeout_val=ctx.timeout,
422 initial_issues=initial_issues,
423 initial_count=initial_count,
424 cwd=ctx.cwd,
425 )
426 fix_output: str = fix_result[1]
428 # Check for remaining issues after fixing
429 try:
430 final_check_result = self._run_subprocess(
431 cmd=check_cmd,
432 timeout=ctx.timeout,
433 cwd=ctx.cwd,
434 )
435 except subprocess.TimeoutExpired:
436 return self._create_timeout_result(
437 timeout_val=ctx.timeout,
438 initial_issues=initial_issues,
439 initial_count=initial_count,
440 cwd=ctx.cwd,
441 )
443 final_check_output: str = final_check_result[1]
444 remaining_issues: list[OxlintIssue] = parse_oxlint_output(
445 output=final_check_output,
446 )
447 remaining_count: int = len(remaining_issues)
449 # Calculate fixed issues
450 fixed_count: int = max(0, initial_count - remaining_count)
452 # Build output message
453 output_lines: list[str] = []
454 if fixed_count > 0:
455 output_lines.append(f"Fixed {fixed_count} issue(s)")
457 if remaining_count > 0:
458 output_lines.append(
459 f"Found {remaining_count} issue(s) that cannot be auto-fixed",
460 )
461 for issue in remaining_issues[:5]:
462 output_lines.append(f" {issue.file} - {issue.message}")
463 if len(remaining_issues) > 5:
464 output_lines.append(f" ... and {len(remaining_issues) - 5} more")
465 elif remaining_count == 0 and fixed_count > 0:
466 output_lines.append("All issues were successfully auto-fixed")
468 # Add verbose raw fix output only when explicitly requested
469 if (
470 merged_options.get("verbose_fix_output", False)
471 and fix_output
472 and fix_output.strip()
473 ):
474 output_lines.append(f"Fix output:\n{fix_output}")
476 final_output: str | None = "\n".join(output_lines) if output_lines else None
478 # Success means no remaining issues
479 success: bool = remaining_count == 0
481 return ToolResult(
482 name=self.definition.name,
483 success=success,
484 output=final_output,
485 issues_count=remaining_count,
486 issues=remaining_issues or [],
487 initial_issues_count=initial_count,
488 fixed_issues_count=fixed_count,
489 remaining_issues_count=remaining_count,
490 initial_issues=initial_issues if initial_issues is not None else None,
491 cwd=ctx.cwd,
492 )