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

1"""Oxlint tool definition. 

2 

3Oxlint is a fast JavaScript/TypeScript linter with 661+ built-in rules. 

4It provides fast linting with minimal configuration. 

5""" 

6 

7from __future__ import annotations 

8 

9import subprocess # nosec B404 - used safely with shell disabled 

10from dataclasses import dataclass 

11from typing import Any 

12 

13from loguru import logger 

14 

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) 

33 

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] 

50 

51 

52@register_tool 

53@dataclass 

54class OxlintPlugin(BaseToolPlugin): 

55 """Oxlint JavaScript/TypeScript linter plugin. 

56 

57 This plugin integrates Oxlint with Lintro for linting JavaScript, 

58 TypeScript, and related framework files. 

59 """ 

60 

61 @property 

62 def definition(self) -> ToolDefinition: 

63 """Return the tool definition. 

64 

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 ) 

85 

86 def __post_init__(self) -> None: 

87 """Initialize the tool with default options.""" 

88 super().__post_init__() 

89 self.options.setdefault("quiet", False) 

90 

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. 

106 

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") 

126 

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") 

131 

132 if exclude_patterns is not None: 

133 self.exclude_patterns = exclude_patterns.copy() 

134 self.include_venv = include_venv 

135 

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) 

147 

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. 

156 

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. 

162 

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 ) 

195 

196 def _build_oxlint_args(self, options: dict[str, object]) -> list[str]: 

197 """Build CLI arguments from options. 

198 

199 Args: 

200 options: Options dict to build args from (use merged_options). 

201 

202 Returns: 

203 List of CLI arguments to pass to oxlint. 

204 """ 

205 args: list[str] = [] 

206 

207 # Config file override 

208 config = options.get("config") 

209 if config: 

210 args.extend(["--config", str(config)]) 

211 

212 # TypeScript config 

213 tsconfig = options.get("tsconfig") 

214 if tsconfig: 

215 args.extend(["--tsconfig", str(tsconfig)]) 

216 

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]) 

222 

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]) 

227 

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]) 

232 

233 return args 

234 

235 def doc_url(self, code: str) -> str | None: 

236 """Return oxlint documentation URL for the given rule. 

237 

238 Args: 

239 code: Oxlint rule in "category/rule" format 

240 (e.g., "deepscan/bad-comparison-sequence"). 

241 

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 

248 

249 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

250 """Check files with Oxlint without making changes. 

251 

252 Args: 

253 paths: List of file or directory paths to check. 

254 options: Runtime options that override defaults. 

255 

256 Returns: 

257 ToolResult with check results. 

258 """ 

259 # Merge runtime options 

260 merged_options = dict(self.options) 

261 merged_options.update(options) 

262 

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 

272 

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}") 

285 

286 # Build Oxlint command with JSON format 

287 cmd: list[str] = self._get_executable_command(tool_name="oxlint") + [ 

288 "--format", 

289 "json", 

290 ] 

291 

292 # Add quiet flag if enabled (suppress warnings, only report errors) 

293 if self.options.get("quiet", False): 

294 cmd.append("--quiet") 

295 

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") 

301 

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) 

306 

307 cmd.extend(ctx.rel_files) 

308 logger.debug(f"[OxlintPlugin] Running: {' '.join(cmd)} (cwd={ctx.cwd})") 

309 

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) 

318 

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 

323 

324 # Standardize: suppress Oxlint's informational output when no issues 

325 final_output: str | None = output 

326 if success: 

327 final_output = None 

328 

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 ) 

337 

338 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

339 """Fix auto-fixable issues in files with Oxlint. 

340 

341 Args: 

342 paths: List of file or directory paths to fix. 

343 options: Runtime options that override defaults. 

344 

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) 

351 

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 

361 

362 # Get Lintro config injection args if available 

363 config_args = self._build_config_args() 

364 

365 # Add Oxlint-specific CLI arguments from options 

366 oxlint_args = self._build_oxlint_args(merged_options) 

367 

368 # Build check command for counting issues 

369 check_cmd: list[str] = self._get_executable_command(tool_name="oxlint") + [ 

370 "--format", 

371 "json", 

372 ] 

373 

374 # Add quiet flag if enabled (suppress warnings, only report errors) 

375 if self.options.get("quiet", False): 

376 check_cmd.append("--quiet") 

377 

378 if config_args: 

379 check_cmd.extend(config_args) 

380 if oxlint_args: 

381 check_cmd.extend(oxlint_args) 

382 

383 check_cmd.extend(ctx.rel_files) 

384 logger.debug( 

385 f"[OxlintPlugin] Checking: {' '.join(check_cmd)} (cwd={ctx.cwd})", 

386 ) 

387 

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) 

397 

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) 

401 

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})") 

412 

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] 

427 

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 ) 

442 

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) 

448 

449 # Calculate fixed issues 

450 fixed_count: int = max(0, initial_count - remaining_count) 

451 

452 # Build output message 

453 output_lines: list[str] = [] 

454 if fixed_count > 0: 

455 output_lines.append(f"Fixed {fixed_count} issue(s)") 

456 

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") 

467 

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}") 

475 

476 final_output: str | None = "\n".join(output_lines) if output_lines else None 

477 

478 # Success means no remaining issues 

479 success: bool = remaining_count == 0 

480 

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 )