mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
refactor: standardize linter output data structure and interface (#4077)
Co-authored-by: Graham Neubig <neubig@gmail.com>
This commit is contained in:
parent
13901b4b5a
commit
54ac340e0b
9
openhands/linter/__init__.py
Normal file
9
openhands/linter/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""Linter module for OpenHands.
|
||||
|
||||
Part of this Linter module is adapted from Aider (Apache 2.0 License, [original code](https://github.com/paul-gauthier/aider/blob/main/aider/linter.py)). Please see the [original repository](https://github.com/paul-gauthier/aider) for more information.
|
||||
"""
|
||||
|
||||
from openhands.linter.base import LintResult
|
||||
from openhands.linter.linter import DefaultLinter
|
||||
|
||||
__all__ = ['DefaultLinter', 'LintResult']
|
||||
79
openhands/linter/base.py
Normal file
79
openhands/linter/base.py
Normal file
@ -0,0 +1,79 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LintResult(BaseModel):
|
||||
file: str
|
||||
line: int # 1-indexed
|
||||
column: int # 1-indexed
|
||||
message: str
|
||||
|
||||
def visualize(self, half_window: int = 3) -> str:
|
||||
"""Visualize the lint result by print out all the lines where the lint result is found.
|
||||
|
||||
Args:
|
||||
half_window: The number of context lines to display around the error on each side.
|
||||
"""
|
||||
with open(self.file, 'r') as f:
|
||||
file_lines = f.readlines()
|
||||
|
||||
# Add line numbers
|
||||
_span_size = len(str(len(file_lines)))
|
||||
file_lines = [
|
||||
f'{i + 1:>{_span_size}}|{line.rstrip()}'
|
||||
for i, line in enumerate(file_lines)
|
||||
]
|
||||
|
||||
# Get the window of lines to display
|
||||
assert self.line <= len(file_lines) and self.line > 0
|
||||
line_idx = self.line - 1
|
||||
begin_window = max(0, line_idx - half_window)
|
||||
end_window = min(len(file_lines), line_idx + half_window + 1)
|
||||
|
||||
selected_lines = file_lines[begin_window:end_window]
|
||||
line_idx_in_window = line_idx - begin_window
|
||||
|
||||
# Add character hint
|
||||
_character_hint = (
|
||||
_span_size * ' '
|
||||
+ ' ' * (self.column)
|
||||
+ '^'
|
||||
+ ' ERROR HERE: '
|
||||
+ self.message
|
||||
)
|
||||
selected_lines[line_idx_in_window] = (
|
||||
f'\033[91m{selected_lines[line_idx_in_window]}\033[0m'
|
||||
+ '\n'
|
||||
+ _character_hint
|
||||
)
|
||||
return '\n'.join(selected_lines)
|
||||
|
||||
|
||||
class LinterException(Exception):
|
||||
"""Base class for all linter exceptions."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BaseLinter(ABC):
|
||||
"""Base class for all linters.
|
||||
|
||||
Each linter should be able to lint files of a specific type and return a list of (parsed) lint results.
|
||||
"""
|
||||
|
||||
encoding: str = 'utf-8'
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supported_extensions(self) -> list[str]:
|
||||
"""The file extensions that this linter supports, such as .py or .tsx."""
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
"""Lint the given file.
|
||||
|
||||
file_path: The path to the file to lint. Required to be absolute.
|
||||
"""
|
||||
pass
|
||||
77
openhands/linter/languages/python.py
Normal file
77
openhands/linter/languages/python.py
Normal file
@ -0,0 +1,77 @@
|
||||
from typing import List
|
||||
|
||||
from openhands.linter.base import BaseLinter, LintResult
|
||||
from openhands.linter.utils import run_cmd
|
||||
|
||||
|
||||
def python_compile_lint(fname: str) -> list[LintResult]:
|
||||
try:
|
||||
with open(fname, 'r') as f:
|
||||
code = f.read()
|
||||
compile(code, fname, 'exec') # USE TRACEBACK BELOW HERE
|
||||
return []
|
||||
except SyntaxError as err:
|
||||
err_lineno = getattr(err, 'end_lineno', err.lineno)
|
||||
err_offset = getattr(err, 'end_offset', err.offset)
|
||||
if err_offset and err_offset < 0:
|
||||
err_offset = err.offset
|
||||
return [
|
||||
LintResult(
|
||||
file=fname, line=err_lineno, column=err_offset or 1, message=err.msg
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def flake_lint(filepath: str) -> list[LintResult]:
|
||||
fatal = 'F821,F822,F831,E112,E113,E999,E902'
|
||||
flake8_cmd = f'flake8 --select={fatal} --isolated {filepath}'
|
||||
|
||||
try:
|
||||
cmd_outputs = run_cmd(flake8_cmd)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
results: list[LintResult] = []
|
||||
if not cmd_outputs:
|
||||
return results
|
||||
for line in cmd_outputs.splitlines():
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 4:
|
||||
_msg = parts[3].strip()
|
||||
if len(parts) > 4:
|
||||
_msg += ': ' + parts[4].strip()
|
||||
results.append(
|
||||
LintResult(
|
||||
file=filepath,
|
||||
line=int(parts[1]),
|
||||
column=int(parts[2]),
|
||||
message=_msg,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
class PythonLinter(BaseLinter):
|
||||
@property
|
||||
def supported_extensions(self) -> List[str]:
|
||||
return ['.py']
|
||||
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
error = flake_lint(file_path)
|
||||
if not error:
|
||||
error = python_compile_lint(file_path)
|
||||
return error
|
||||
|
||||
def compile_lint(self, file_path: str, code: str) -> List[LintResult]:
|
||||
try:
|
||||
compile(code, file_path, 'exec')
|
||||
return []
|
||||
except SyntaxError as e:
|
||||
return [
|
||||
LintResult(
|
||||
file=file_path,
|
||||
line=e.lineno,
|
||||
column=e.offset,
|
||||
message=str(e),
|
||||
rule='SyntaxError',
|
||||
)
|
||||
]
|
||||
74
openhands/linter/languages/treesitter.py
Normal file
74
openhands/linter/languages/treesitter.py
Normal file
@ -0,0 +1,74 @@
|
||||
import warnings
|
||||
|
||||
from grep_ast import TreeContext, filename_to_lang
|
||||
from grep_ast.parsers import PARSERS
|
||||
from tree_sitter_languages import get_parser
|
||||
|
||||
from openhands.linter.base import BaseLinter, LintResult
|
||||
|
||||
# tree_sitter is throwing a FutureWarning
|
||||
warnings.simplefilter('ignore', category=FutureWarning)
|
||||
|
||||
|
||||
def tree_context(fname, code, line_nums):
|
||||
context = TreeContext(
|
||||
fname,
|
||||
code,
|
||||
color=False,
|
||||
line_number=True,
|
||||
child_context=False,
|
||||
last_line=False,
|
||||
margin=0,
|
||||
mark_lois=True,
|
||||
loi_pad=3,
|
||||
# header_max=30,
|
||||
show_top_of_file_parent_scope=False,
|
||||
)
|
||||
line_nums = set(line_nums)
|
||||
context.add_lines_of_interest(line_nums)
|
||||
context.add_context()
|
||||
output = context.format()
|
||||
return output
|
||||
|
||||
|
||||
def traverse_tree(node):
|
||||
"""Traverses the tree to find errors."""
|
||||
errors = []
|
||||
if node.type == 'ERROR' or node.is_missing:
|
||||
line_no = node.start_point[0] + 1
|
||||
col_no = node.start_point[1] + 1
|
||||
error_type = 'Missing node' if node.is_missing else 'Syntax error'
|
||||
errors.append((line_no, col_no, error_type))
|
||||
|
||||
for child in node.children:
|
||||
errors += traverse_tree(child)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
class TreesitterBasicLinter(BaseLinter):
|
||||
@property
|
||||
def supported_extensions(self) -> list[str]:
|
||||
return list(PARSERS.keys())
|
||||
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
"""Use tree-sitter to look for syntax errors, display them with tree context."""
|
||||
lang = filename_to_lang(file_path)
|
||||
if not lang:
|
||||
return []
|
||||
parser = get_parser(lang)
|
||||
with open(file_path, 'r') as f:
|
||||
code = f.read()
|
||||
tree = parser.parse(bytes(code, 'utf-8'))
|
||||
errors = traverse_tree(tree.root_node)
|
||||
if not errors:
|
||||
return []
|
||||
return [
|
||||
LintResult(
|
||||
file=file_path,
|
||||
line=int(line),
|
||||
column=int(col),
|
||||
message=error_details,
|
||||
)
|
||||
for line, col, error_details in errors
|
||||
]
|
||||
35
openhands/linter/linter.py
Normal file
35
openhands/linter/linter.py
Normal file
@ -0,0 +1,35 @@
|
||||
import os
|
||||
from collections import defaultdict
|
||||
|
||||
from openhands.linter.base import BaseLinter, LinterException, LintResult
|
||||
from openhands.linter.languages.python import PythonLinter
|
||||
from openhands.linter.languages.treesitter import TreesitterBasicLinter
|
||||
|
||||
|
||||
class DefaultLinter(BaseLinter):
|
||||
def __init__(self):
|
||||
self.linters: dict[str, list[BaseLinter]] = defaultdict(list)
|
||||
self.linters['.py'] = [PythonLinter()]
|
||||
|
||||
# Add treesitter linter as a fallback for all linters
|
||||
self.basic_linter = TreesitterBasicLinter()
|
||||
for extension in self.basic_linter.supported_extensions:
|
||||
self.linters[extension].append(self.basic_linter)
|
||||
self._supported_extensions = list(self.linters.keys())
|
||||
|
||||
@property
|
||||
def supported_extensions(self) -> list[str]:
|
||||
return self._supported_extensions
|
||||
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
if not os.path.isabs(file_path):
|
||||
raise LinterException(f'File path {file_path} is not an absolute path')
|
||||
file_extension = os.path.splitext(file_path)[1]
|
||||
|
||||
linters: list[BaseLinter] = self.linters.get(file_extension, [])
|
||||
for linter in linters:
|
||||
res = linter.lint(file_path)
|
||||
# We always return the first linter's result (higher priority)
|
||||
if res:
|
||||
return res
|
||||
return []
|
||||
3
openhands/linter/utils/__init__.py
Normal file
3
openhands/linter/utils/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .cmd import run_cmd, check_tool_installed
|
||||
|
||||
__all__ = ['run_cmd', 'check_tool_installed']
|
||||
36
openhands/linter/utils/cmd.py
Normal file
36
openhands/linter/utils/cmd.py
Normal file
@ -0,0 +1,36 @@
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
def run_cmd(cmd: str, cwd: str | None = None) -> str | None:
|
||||
"""Run a command and return the output.
|
||||
|
||||
If the command succeeds, return None. If the command fails, return the stdout.
|
||||
"""
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd.split(),
|
||||
cwd=cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
)
|
||||
stdout, _ = process.communicate()
|
||||
if process.returncode == 0:
|
||||
return None
|
||||
return stdout
|
||||
|
||||
|
||||
def check_tool_installed(tool_name: str) -> bool:
|
||||
"""Check if a tool is installed."""
|
||||
try:
|
||||
subprocess.run(
|
||||
[tool_name, '--version'],
|
||||
check=True,
|
||||
cwd=os.getcwd(),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
@ -22,10 +22,7 @@ import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
if __package__ is None or __package__ == '':
|
||||
from aider import Linter
|
||||
else:
|
||||
from openhands.runtime.plugins.agent_skills.utils.aider import Linter
|
||||
from openhands.linter import DefaultLinter, LintResult
|
||||
|
||||
CURRENT_FILE: str | None = None
|
||||
CURRENT_LINE = 1
|
||||
@ -98,13 +95,16 @@ def _lint_file(file_path: str) -> tuple[str | None, int | None]:
|
||||
Returns:
|
||||
tuple[str | None, int | None]: (lint_error, first_error_line_number)
|
||||
"""
|
||||
linter = Linter(root=os.getcwd())
|
||||
lint_error = linter.lint(file_path)
|
||||
linter = DefaultLinter()
|
||||
lint_error: list[LintResult] = linter.lint(file_path)
|
||||
if not lint_error:
|
||||
# Linting successful. No issues found.
|
||||
return None, None
|
||||
first_error_line = lint_error.lines[0] if lint_error.lines else None
|
||||
return 'ERRORS:\n' + lint_error.text, first_error_line
|
||||
first_error_line = lint_error[0].line if len(lint_error) > 0 else None
|
||||
error_text = 'ERRORS:\n' + '\n'.join(
|
||||
[f'{file_path}:{err.line}:{err.column}: {err.message}' for err in lint_error]
|
||||
)
|
||||
return error_text, first_error_line
|
||||
|
||||
|
||||
def _print_window(
|
||||
@ -518,7 +518,8 @@ def _edit_file_impl(
|
||||
with open(original_file_backup_path, 'w') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
lint_error, first_error_line = _lint_file(file_name)
|
||||
file_name_abs = os.path.abspath(file_name)
|
||||
lint_error, first_error_line = _lint_file(file_name_abs)
|
||||
|
||||
# Select the errors caused by the modification
|
||||
def extract_last_part(line):
|
||||
|
||||
@ -1,202 +0,0 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@ -1,8 +0,0 @@
|
||||
# Aider is AI pair programming in your terminal
|
||||
|
||||
Aider lets you pair program with LLMs,
|
||||
to edit code in your local git repository.
|
||||
|
||||
Please see the [original repository](https://github.com/paul-gauthier/aider) for more information.
|
||||
|
||||
OpenHands has adapted and integrated its linter module ([original code](https://github.com/paul-gauthier/aider/blob/main/aider/linter.py)).
|
||||
@ -1,9 +0,0 @@
|
||||
if __package__ is None or __package__ == '':
|
||||
from linter import Linter, LintResult
|
||||
else:
|
||||
from openhands.runtime.plugins.agent_skills.utils.aider.linter import (
|
||||
Linter,
|
||||
LintResult,
|
||||
)
|
||||
|
||||
__all__ = ['Linter', 'LintResult']
|
||||
@ -1,378 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from grep_ast import TreeContext, filename_to_lang
|
||||
from tree_sitter_languages import get_parser # noqa: E402
|
||||
|
||||
# tree_sitter is throwing a FutureWarning
|
||||
warnings.simplefilter('ignore', category=FutureWarning)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LintResult:
|
||||
text: str
|
||||
lines: list
|
||||
|
||||
|
||||
class Linter:
|
||||
def __init__(self, encoding='utf-8', root=None):
|
||||
self.encoding = encoding
|
||||
self.root = root
|
||||
|
||||
self.ts_installed = self._check_tool_installed('tsc')
|
||||
self.eslint_installed = self._check_tool_installed('eslint')
|
||||
|
||||
self.languages = dict(
|
||||
python=self.py_lint,
|
||||
)
|
||||
if self.eslint_installed:
|
||||
self.languages['javascript'] = self.ts_eslint
|
||||
self.languages['typescript'] = self.ts_eslint
|
||||
elif self.ts_installed:
|
||||
self.languages['javascript'] = self.ts_tsc_lint
|
||||
self.languages['typescript'] = self.ts_tsc_lint
|
||||
self.all_lint_cmd = None
|
||||
|
||||
def set_linter(self, lang, cmd):
|
||||
if lang:
|
||||
self.languages[lang] = cmd
|
||||
return
|
||||
|
||||
self.all_lint_cmd = cmd
|
||||
|
||||
def get_rel_fname(self, fname):
|
||||
if self.root:
|
||||
return os.path.relpath(fname, self.root)
|
||||
else:
|
||||
return fname
|
||||
|
||||
def run_cmd(self, cmd, rel_fname, code):
|
||||
cmd += ' ' + rel_fname
|
||||
cmd = cmd.split()
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=self.root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE, # Add stdin parameter
|
||||
)
|
||||
stdout, _ = process.communicate(
|
||||
input=code.encode()
|
||||
) # Pass the code to the process
|
||||
errors = stdout.decode().strip()
|
||||
self.returncode = process.returncode
|
||||
if self.returncode == 0:
|
||||
return # zero exit status
|
||||
|
||||
cmd = ' '.join(cmd)
|
||||
res = ''
|
||||
res += errors
|
||||
line_num = extract_error_line_from(res)
|
||||
return LintResult(text=res, lines=[line_num])
|
||||
|
||||
def get_abs_fname(self, fname):
|
||||
if os.path.isabs(fname):
|
||||
return fname
|
||||
elif os.path.isfile(fname):
|
||||
rel_fname = self.get_rel_fname(fname)
|
||||
return os.path.abspath(rel_fname)
|
||||
else: # if a temp file
|
||||
return self.get_rel_fname(fname)
|
||||
|
||||
def lint(self, fname, cmd=None) -> LintResult | None:
|
||||
code = Path(fname).read_text(self.encoding)
|
||||
absolute_fname = self.get_abs_fname(fname)
|
||||
if cmd:
|
||||
cmd = cmd.strip()
|
||||
if not cmd:
|
||||
lang = filename_to_lang(fname)
|
||||
if not lang:
|
||||
return None
|
||||
if self.all_lint_cmd:
|
||||
cmd = self.all_lint_cmd
|
||||
else:
|
||||
cmd = self.languages.get(lang)
|
||||
if callable(cmd):
|
||||
linkres = cmd(fname, absolute_fname, code)
|
||||
elif cmd:
|
||||
linkres = self.run_cmd(cmd, absolute_fname, code)
|
||||
else:
|
||||
linkres = basic_lint(absolute_fname, code)
|
||||
return linkres
|
||||
|
||||
def flake_lint(self, rel_fname, code):
|
||||
fatal = 'F821,F822,F831,E112,E113,E999,E902'
|
||||
flake8 = f'flake8 --select={fatal} --isolated'
|
||||
|
||||
try:
|
||||
flake_res = self.run_cmd(flake8, rel_fname, code)
|
||||
except FileNotFoundError:
|
||||
flake_res = None
|
||||
return flake_res
|
||||
|
||||
def py_lint(self, fname, rel_fname, code):
|
||||
error = self.flake_lint(rel_fname, code)
|
||||
if not error:
|
||||
error = lint_python_compile(fname, code)
|
||||
if not error:
|
||||
error = basic_lint(rel_fname, code)
|
||||
return error
|
||||
|
||||
def _check_tool_installed(self, tool_name: str) -> bool:
|
||||
"""Check if a tool is installed."""
|
||||
try:
|
||||
subprocess.run(
|
||||
[tool_name, '--version'],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def print_lint_result(self, lint_result: LintResult) -> None:
|
||||
print(f'\n{lint_result.text.strip()}')
|
||||
if isinstance(lint_result.lines, list) and lint_result.lines:
|
||||
if isinstance(lint_result.lines[0], LintResult):
|
||||
self.print_lint_result(lint_result.lines[0])
|
||||
|
||||
def ts_eslint(self, fname: str, rel_fname: str, code: str) -> Optional[LintResult]:
|
||||
"""Use ESLint to check for errors. If ESLint is not installed return None."""
|
||||
if not self.eslint_installed:
|
||||
return None
|
||||
|
||||
# Enhanced ESLint configuration with React support
|
||||
eslint_config = {
|
||||
'env': {'es6': True, 'browser': True, 'node': True},
|
||||
'extends': ['eslint:recommended', 'plugin:react/recommended'],
|
||||
'parserOptions': {
|
||||
'ecmaVersion': 2021,
|
||||
'sourceType': 'module',
|
||||
'ecmaFeatures': {'jsx': True},
|
||||
},
|
||||
'plugins': ['react'],
|
||||
'rules': {
|
||||
'no-unused-vars': 'warn',
|
||||
'no-console': 'off',
|
||||
'react/prop-types': 'warn',
|
||||
'semi': ['error', 'always'],
|
||||
},
|
||||
'settings': {'react': {'version': 'detect'}},
|
||||
}
|
||||
|
||||
# Write config to a temporary file
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w', suffix='.json', delete=False
|
||||
) as temp_config:
|
||||
json.dump(eslint_config, temp_config)
|
||||
temp_config_path = temp_config.name
|
||||
|
||||
try:
|
||||
# Point to frontend node_modules directory
|
||||
if self.root:
|
||||
plugin_path = f'{self.root}/frontend/node_modules/'
|
||||
else:
|
||||
return None
|
||||
|
||||
eslint_cmd = f'eslint --no-eslintrc --config {temp_config_path} --resolve-plugins-relative-to {plugin_path} --format json'
|
||||
eslint_res = ''
|
||||
try:
|
||||
eslint_res = self.run_cmd(eslint_cmd, rel_fname, code)
|
||||
if eslint_res and hasattr(eslint_res, 'text'):
|
||||
# Parse the ESLint JSON output
|
||||
eslint_output = json.loads(eslint_res.text)
|
||||
error_lines = []
|
||||
error_messages = []
|
||||
for result in eslint_output:
|
||||
for message in result.get('messages', []):
|
||||
line = message.get('line', 0)
|
||||
error_lines.append(line)
|
||||
error_messages.append(
|
||||
f"{rel_fname}:{line}:{message.get('column', 0)}: {message.get('message')} ({message.get('ruleId')})"
|
||||
)
|
||||
if not error_messages:
|
||||
return None
|
||||
|
||||
return LintResult(text='\n'.join(error_messages), lines=error_lines)
|
||||
except json.JSONDecodeError as e:
|
||||
return LintResult(text=f'\nJSONDecodeError: {e}', lines=[eslint_res])
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception as e:
|
||||
return LintResult(text=f'\nUnexpected error: {e}', lines=[])
|
||||
finally:
|
||||
os.unlink(temp_config_path)
|
||||
return None
|
||||
|
||||
def ts_tsc_lint(self, fname, rel_fname, code):
|
||||
"""Use typescript compiler to check for errors. If TypeScript is not installed return None."""
|
||||
if self.ts_installed:
|
||||
tsc_cmd = 'tsc --noEmit --allowJs --checkJs --strict --noImplicitAny --strictNullChecks --strictFunctionTypes --strictBindCallApply --strictPropertyInitialization --noImplicitThis --alwaysStrict'
|
||||
try:
|
||||
tsc_res = self.run_cmd(tsc_cmd, rel_fname, code)
|
||||
if tsc_res:
|
||||
# Parse the TSC output
|
||||
error_lines = []
|
||||
for line in tsc_res.text.split('\n'):
|
||||
# Extract lines and column numbers
|
||||
if ': error TS' in line or ': warning TS' in line:
|
||||
try:
|
||||
location_part = line.split('(')[1].split(')')[0]
|
||||
line_num, _ = map(int, location_part.split(','))
|
||||
error_lines.append(line_num)
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
return LintResult(text=tsc_res.text, lines=error_lines)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# If still no errors, check for missing semicolons
|
||||
lines = code.split('\n')
|
||||
error_lines = []
|
||||
for i, line in enumerate(lines):
|
||||
stripped_line = line.strip()
|
||||
if (
|
||||
stripped_line
|
||||
and not stripped_line.endswith(';')
|
||||
and not stripped_line.endswith('{')
|
||||
and not stripped_line.endswith('}')
|
||||
and not stripped_line.startswith('//')
|
||||
):
|
||||
error_lines.append(i + 1)
|
||||
|
||||
if error_lines:
|
||||
error_message = (
|
||||
f"{rel_fname}({error_lines[0]},1): error TS1005: ';' expected."
|
||||
)
|
||||
return LintResult(text=error_message, lines=error_lines)
|
||||
|
||||
# If tsc is not available return None (basic_lint causes other problems!)
|
||||
return None
|
||||
|
||||
|
||||
def lint_python_compile(fname, code):
|
||||
try:
|
||||
compile(code, fname, 'exec') # USE TRACEBACK BELOW HERE
|
||||
return
|
||||
except IndentationError as err:
|
||||
end_lineno = getattr(err, 'end_lineno', err.lineno)
|
||||
if isinstance(end_lineno, int):
|
||||
line_numbers = list(range(end_lineno - 1, end_lineno))
|
||||
else:
|
||||
line_numbers = []
|
||||
|
||||
tb_lines = traceback.format_exception(type(err), err, err.__traceback__)
|
||||
last_file_i = 0
|
||||
|
||||
target = '# USE TRACEBACK'
|
||||
target += ' BELOW HERE'
|
||||
for i in range(len(tb_lines)):
|
||||
if target in tb_lines[i]:
|
||||
last_file_i = i
|
||||
break
|
||||
tb_lines = tb_lines[:1] + tb_lines[last_file_i + 1 :]
|
||||
|
||||
res = ''.join(tb_lines)
|
||||
return LintResult(text=res, lines=line_numbers)
|
||||
|
||||
|
||||
def basic_lint(fname, code):
|
||||
"""Use tree-sitter to look for syntax errors, display them with tree context."""
|
||||
lang = filename_to_lang(fname)
|
||||
if not lang:
|
||||
return
|
||||
|
||||
parser = get_parser(lang)
|
||||
tree = parser.parse(bytes(code, 'utf-8'))
|
||||
|
||||
errors = traverse_tree(tree.root_node)
|
||||
if not errors:
|
||||
return
|
||||
|
||||
error_messages = [
|
||||
f'{fname}:{line}:{col}: {error_details}' for line, col, error_details in errors
|
||||
]
|
||||
return LintResult(
|
||||
text='\n'.join(error_messages), lines=[line for line, _, _ in errors]
|
||||
)
|
||||
|
||||
|
||||
def extract_error_line_from(lint_error):
|
||||
# TODO: this is a temporary fix to extract the error line from the error message
|
||||
# it should be replaced with a more robust/unified solution
|
||||
first_error_line = None
|
||||
for line in lint_error.splitlines(True):
|
||||
if line.strip():
|
||||
# The format of the error message is: <filename>:<line>:<column>: <error code> <error message>
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
first_error_line = int(parts[1])
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
return first_error_line
|
||||
|
||||
|
||||
def tree_context(fname, code, line_nums):
|
||||
context = TreeContext(
|
||||
fname,
|
||||
code,
|
||||
color=False,
|
||||
line_number=True,
|
||||
child_context=False,
|
||||
last_line=False,
|
||||
margin=0,
|
||||
mark_lois=True,
|
||||
loi_pad=3,
|
||||
# header_max=30,
|
||||
show_top_of_file_parent_scope=False,
|
||||
)
|
||||
line_nums = set(line_nums)
|
||||
context.add_lines_of_interest(line_nums)
|
||||
context.add_context()
|
||||
output = context.format()
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def traverse_tree(node):
|
||||
"""Traverses the tree to find errors"""
|
||||
errors = []
|
||||
if node.type == 'ERROR' or node.is_missing:
|
||||
line_no = node.start_point[0] + 1
|
||||
col_no = node.start_point[1] + 1
|
||||
error_type = 'Missing node' if node.is_missing else 'Syntax error'
|
||||
errors.append((line_no, col_no, error_type))
|
||||
|
||||
for child in node.children:
|
||||
errors += traverse_tree(child)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to parse files provided as command line arguments."""
|
||||
if len(sys.argv) < 2:
|
||||
print('Usage: python linter.py <file1> <file2> ...')
|
||||
sys.exit(1)
|
||||
|
||||
linter = Linter(root=os.getcwd())
|
||||
for file_path in sys.argv[1:]:
|
||||
errors = linter.lint(file_path)
|
||||
if errors:
|
||||
print(errors)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
70
tests/unit/linters/conftest.py
Normal file
70
tests/unit/linters/conftest.py
Normal file
@ -0,0 +1,70 @@
|
||||
import pytest
|
||||
|
||||
@pytest.fixture
|
||||
def syntax_error_py_file(tmp_path):
|
||||
file_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
print("Wrong indent")
|
||||
foo(
|
||||
"""
|
||||
file_path = tmp_path / "test_file.py"
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wrongly_indented_py_file(tmp_path):
|
||||
file_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
"""
|
||||
file_path = tmp_path / "test_file.py"
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
@pytest.fixture
|
||||
def simple_correct_py_file(tmp_path):
|
||||
file_content = 'print("Hello, World!")\n'
|
||||
file_path = tmp_path / "test_file.py"
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
@pytest.fixture
|
||||
def simple_correct_py_func_def(tmp_path):
|
||||
file_content = """def foo():
|
||||
print("Hello, World!")
|
||||
foo()
|
||||
"""
|
||||
file_path = tmp_path / "test_file.py"
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
@pytest.fixture
|
||||
def simple_correct_ruby_file(tmp_path):
|
||||
file_content ="""def foo
|
||||
puts "Hello, World!"
|
||||
end
|
||||
foo
|
||||
"""
|
||||
file_path = tmp_path / "test_file.rb"
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_incorrect_ruby_file(tmp_path):
|
||||
file_content ="""def foo():
|
||||
print("Hello, World!")
|
||||
foo()
|
||||
"""
|
||||
file_path = tmp_path / "test_file.rb"
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
@pytest.fixture
|
||||
def parenthesis_incorrect_ruby_file(tmp_path):
|
||||
file_content = """def print_hello_world()\n puts 'Hello World'\n"""
|
||||
file_path = tmp_path / "test_file.rb"
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
84
tests/unit/linters/test_python_linter.py
Normal file
84
tests/unit/linters/test_python_linter.py
Normal file
@ -0,0 +1,84 @@
|
||||
from openhands.linter import DefaultLinter, LintResult
|
||||
from openhands.linter.languages.python import (
|
||||
PythonLinter,
|
||||
flake_lint,
|
||||
python_compile_lint,
|
||||
)
|
||||
|
||||
|
||||
def test_wrongly_indented_py_file(wrongly_indented_py_file):
|
||||
# Test Python linter
|
||||
linter = PythonLinter()
|
||||
assert '.py' in linter.supported_extensions
|
||||
result = linter.lint(wrongly_indented_py_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 1
|
||||
assert result[0] == LintResult(
|
||||
file=wrongly_indented_py_file,
|
||||
line=2,
|
||||
column=5,
|
||||
message='E999 IndentationError: unexpected indent',
|
||||
)
|
||||
print(result[0].visualize())
|
||||
assert result[0].visualize() == (
|
||||
'1|\n'
|
||||
'\033[91m2| def foo():\033[0m\n'
|
||||
' ^ ERROR HERE: E999 IndentationError: unexpected indent\n'
|
||||
'3| print("Hello, World!")\n'
|
||||
'4|'
|
||||
)
|
||||
|
||||
# General linter should have same result as Python linter
|
||||
# bc it uses PythonLinter under the hood
|
||||
general_linter = DefaultLinter()
|
||||
assert '.py' in general_linter.supported_extensions
|
||||
result = general_linter.lint(wrongly_indented_py_file)
|
||||
assert result == linter.lint(wrongly_indented_py_file)
|
||||
|
||||
# Test flake8_lint
|
||||
assert result == flake_lint(wrongly_indented_py_file)
|
||||
|
||||
# Test python_compile_lint
|
||||
compile_result = python_compile_lint(wrongly_indented_py_file)
|
||||
assert isinstance(compile_result, list) and len(compile_result) == 1
|
||||
assert compile_result[0] == LintResult(
|
||||
file=wrongly_indented_py_file, line=2, column=4, message='unexpected indent'
|
||||
)
|
||||
|
||||
|
||||
def test_simple_correct_py_file(simple_correct_py_file):
|
||||
linter = PythonLinter()
|
||||
assert '.py' in linter.supported_extensions
|
||||
result = linter.lint(simple_correct_py_file)
|
||||
assert result == []
|
||||
|
||||
general_linter = DefaultLinter()
|
||||
assert '.py' in general_linter.supported_extensions
|
||||
result = general_linter.lint(simple_correct_py_file)
|
||||
assert result == linter.lint(simple_correct_py_file)
|
||||
|
||||
# Test python_compile_lint
|
||||
compile_result = python_compile_lint(simple_correct_py_file)
|
||||
assert compile_result == []
|
||||
|
||||
# Test flake_lint
|
||||
flake_result = flake_lint(simple_correct_py_file)
|
||||
assert flake_result == []
|
||||
|
||||
|
||||
def test_simple_correct_py_func_def(simple_correct_py_func_def):
|
||||
linter = PythonLinter()
|
||||
result = linter.lint(simple_correct_py_func_def)
|
||||
assert result == []
|
||||
|
||||
general_linter = DefaultLinter()
|
||||
assert '.py' in general_linter.supported_extensions
|
||||
result = general_linter.lint(simple_correct_py_func_def)
|
||||
assert result == linter.lint(simple_correct_py_func_def)
|
||||
|
||||
# Test flake_lint
|
||||
assert result == flake_lint(simple_correct_py_func_def)
|
||||
|
||||
# Test python_compile_lint
|
||||
compile_result = python_compile_lint(simple_correct_py_func_def)
|
||||
assert compile_result == []
|
||||
113
tests/unit/linters/test_treesitter_linter.py
Normal file
113
tests/unit/linters/test_treesitter_linter.py
Normal file
@ -0,0 +1,113 @@
|
||||
from openhands.linter import DefaultLinter, LintResult
|
||||
from openhands.linter.languages.treesitter import TreesitterBasicLinter
|
||||
|
||||
|
||||
def test_syntax_error_py_file(syntax_error_py_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(syntax_error_py_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 1
|
||||
assert result[0] == LintResult(
|
||||
file=syntax_error_py_file,
|
||||
line=5,
|
||||
column=5,
|
||||
message='Syntax error',
|
||||
)
|
||||
|
||||
assert (
|
||||
result[0].visualize()
|
||||
== (
|
||||
'2| def foo():\n'
|
||||
'3| print("Hello, World!")\n'
|
||||
'4| print("Wrong indent")\n'
|
||||
'\033[91m5| foo(\033[0m\n' # color red
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
'6|'
|
||||
)
|
||||
)
|
||||
print(result[0].visualize())
|
||||
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(syntax_error_py_file)
|
||||
# NOTE: general linter returns different result
|
||||
# because it uses flake8 first, which is different from treesitter
|
||||
assert general_result != result
|
||||
|
||||
|
||||
def test_simple_correct_ruby_file(simple_correct_ruby_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(simple_correct_ruby_file)
|
||||
assert isinstance(result, list) and len(result) == 0
|
||||
|
||||
# Test that the general linter also returns the same result
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(simple_correct_ruby_file)
|
||||
assert general_result == result
|
||||
|
||||
|
||||
def test_simple_incorrect_ruby_file(simple_incorrect_ruby_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(simple_incorrect_ruby_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 2
|
||||
assert result[0] == LintResult(
|
||||
file=simple_incorrect_ruby_file,
|
||||
line=1,
|
||||
column=1,
|
||||
message='Syntax error',
|
||||
)
|
||||
print(result[0].visualize())
|
||||
assert (
|
||||
result[0].visualize()
|
||||
== (
|
||||
'\033[91m1|def foo():\033[0m\n' # color red
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
'2| print("Hello, World!")\n'
|
||||
'3|foo()'
|
||||
)
|
||||
)
|
||||
assert result[1] == LintResult(
|
||||
file=simple_incorrect_ruby_file,
|
||||
line=1,
|
||||
column=10,
|
||||
message='Syntax error',
|
||||
)
|
||||
print(result[1].visualize())
|
||||
assert (
|
||||
result[1].visualize()
|
||||
== (
|
||||
'\033[91m1|def foo():\033[0m\n' # color red
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
'2| print("Hello, World!")\n'
|
||||
'3|foo()'
|
||||
)
|
||||
)
|
||||
|
||||
# Test that the general linter also returns the same result
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(simple_incorrect_ruby_file)
|
||||
assert general_result == result
|
||||
|
||||
|
||||
def test_parenthesis_incorrect_ruby_file(parenthesis_incorrect_ruby_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(parenthesis_incorrect_ruby_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 1
|
||||
assert result[0] == LintResult(
|
||||
file=parenthesis_incorrect_ruby_file,
|
||||
line=1,
|
||||
column=1,
|
||||
message='Syntax error',
|
||||
)
|
||||
print(result[0].visualize())
|
||||
assert result[0].visualize() == (
|
||||
'\033[91m1|def print_hello_world()\033[0m\n'
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
"2| puts 'Hello World'"
|
||||
)
|
||||
|
||||
# Test that the general linter also returns the same result
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(parenthesis_incorrect_ruby_file)
|
||||
assert general_result == result
|
||||
86
tests/unit/linters/test_visualize.py
Normal file
86
tests/unit/linters/test_visualize.py
Normal file
@ -0,0 +1,86 @@
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.linter.base import LintResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_file_content():
|
||||
return '\n'.join([f'Line {i}' for i in range(1, 21)])
|
||||
|
||||
|
||||
def test_visualize_standard_case(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=10, column=5, message='Test error message'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=3)
|
||||
|
||||
expected_output = (
|
||||
" 7|Line 7\n"
|
||||
" 8|Line 8\n"
|
||||
" 9|Line 9\n"
|
||||
"\033[91m10|Line 10\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: Test error message\n"
|
||||
"11|Line 11\n"
|
||||
"12|Line 12\n"
|
||||
"13|Line 13"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
def test_visualize_small_window(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=10, column=5, message='Test error message'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=1)
|
||||
|
||||
expected_output = (
|
||||
" 9|Line 9\n"
|
||||
"\033[91m10|Line 10\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: Test error message\n"
|
||||
"11|Line 11"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
def test_visualize_error_at_start(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=1, column=3, message='Start error'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=2)
|
||||
|
||||
expected_output = (
|
||||
"\033[91m 1|Line 1\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: Start error\n"
|
||||
" 2|Line 2\n"
|
||||
" 3|Line 3"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
def test_visualize_error_at_end(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=20, column=1, message='End error'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=2)
|
||||
|
||||
expected_output = (
|
||||
"18|Line 18\n"
|
||||
"19|Line 19\n"
|
||||
"\033[91m20|Line 20\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: End error"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
@ -29,7 +29,6 @@ from openhands.runtime.plugins.agent_skills.file_reader.file_readers import (
|
||||
parse_pdf,
|
||||
parse_pptx,
|
||||
)
|
||||
from openhands.runtime.plugins.agent_skills.utils.aider import Linter
|
||||
|
||||
|
||||
# CURRENT_FILE must be reset for each test
|
||||
@ -1569,65 +1568,3 @@ def test_lint_file_fail_non_python(tmp_path, capsys):
|
||||
'DO NOT re-run the same failed edit command. Running it again will lead to the same error.\n'
|
||||
)
|
||||
assert result.split('\n') == expected.split('\n')
|
||||
|
||||
|
||||
def test_lint_file_fail_typescript(tmp_path, capsys):
|
||||
linter = Linter()
|
||||
with patch.dict(os.environ, {'ENABLE_AUTO_LINT': 'True'}):
|
||||
current_line = 1
|
||||
file_path = tmp_path / 'test.ts'
|
||||
file_path.write_text('')
|
||||
|
||||
open_file(str(file_path), current_line)
|
||||
insert_content_at_line(
|
||||
str(file_path),
|
||||
1,
|
||||
"function greet(name: string) {\n console.log('Hello, ' + name)",
|
||||
)
|
||||
result = capsys.readouterr().out
|
||||
assert result is not None
|
||||
|
||||
# Note: the tsc (typescript compiler) message is different from a
|
||||
# compared to a python linter message, like line and column in brackets:
|
||||
expected_lines = [
|
||||
f'[File: {file_path} (1 lines total)]',
|
||||
'(this is the beginning of the file)',
|
||||
'1|',
|
||||
'(this is the end of the file)',
|
||||
'[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]',
|
||||
'ERRORS:',
|
||||
f"{file_path}(3,1): error TS1005: '}}' expected.",
|
||||
'[This is how your edit would have looked if applied]',
|
||||
'-------------------------------------------------',
|
||||
'(this is the beginning of the file)',
|
||||
'1|function greet(name: string) {',
|
||||
"2| console.log('Hello, ' + name)",
|
||||
'(this is the end of the file)',
|
||||
'-------------------------------------------------',
|
||||
'',
|
||||
'[This is the original code before your edit]',
|
||||
'-------------------------------------------------',
|
||||
'(this is the beginning of the file)',
|
||||
'1|',
|
||||
'(this is the end of the file)',
|
||||
'-------------------------------------------------',
|
||||
'Your changes have NOT been applied. Please fix your edit command and try again.',
|
||||
'You either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.',
|
||||
'DO NOT re-run the same failed edit command. Running it again will lead to the same error.',
|
||||
'',
|
||||
]
|
||||
result_lines = result.split('\n')
|
||||
assert len(result_lines) == len(expected_lines), "Number of lines doesn't match"
|
||||
|
||||
for i, (result_line, expected_line) in enumerate(
|
||||
zip(result_lines, expected_lines)
|
||||
):
|
||||
if i == 6:
|
||||
if linter.ts_installed and result_line != expected_lines[6]:
|
||||
assert (
|
||||
'ts:1:20:' in result_line or '(3,1):' in result_line
|
||||
), f"Line {i+1} doesn't match"
|
||||
else:
|
||||
assert result_line.lstrip('./') == expected_line.lstrip(
|
||||
'./'
|
||||
), f"Line {i+1} doesn't match"
|
||||
|
||||
@ -1,522 +0,0 @@
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.runtime.plugins.agent_skills.utils.aider import Linter, LintResult
|
||||
|
||||
|
||||
def get_parent_directory(levels=3):
|
||||
current_file = os.path.abspath(__file__)
|
||||
parent_directory = current_file
|
||||
for _ in range(levels):
|
||||
parent_directory = os.path.dirname(parent_directory)
|
||||
return parent_directory
|
||||
|
||||
|
||||
print(f'\nRepo root folder: {get_parent_directory()}\n')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_file(tmp_path):
|
||||
# Fixture to create a temporary file
|
||||
temp_name = os.path.join(tmp_path, 'lint-test.py')
|
||||
with open(temp_name, 'w', encoding='utf-8') as tmp_file:
|
||||
tmp_file.write("""def foo():
|
||||
print("Hello, World!")
|
||||
foo()
|
||||
""")
|
||||
tmp_file.close()
|
||||
yield temp_name
|
||||
os.remove(temp_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_ruby_file_errors(tmp_path):
|
||||
# Fixture to create a temporary file
|
||||
temp_name = os.path.join(tmp_path, 'lint-test.rb')
|
||||
with open(temp_name, 'w', encoding='utf-8') as tmp_file:
|
||||
tmp_file.write("""def foo():
|
||||
print("Hello, World!")
|
||||
foo()
|
||||
""")
|
||||
tmp_file.close()
|
||||
yield temp_name
|
||||
os.remove(temp_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_ruby_file_errors_parentheses(tmp_path):
|
||||
# Fixture to create a temporary file
|
||||
temp_name = os.path.join(tmp_path, 'lint-test.rb')
|
||||
with open(temp_name, 'w', encoding='utf-8') as tmp_file:
|
||||
tmp_file.write("""def print_hello_world()\n puts 'Hello World'\n""")
|
||||
tmp_file.close()
|
||||
yield temp_name
|
||||
os.remove(temp_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_ruby_file_correct(tmp_path):
|
||||
# Fixture to create a temporary file
|
||||
temp_name = os.path.join(tmp_path, 'lint-test.rb')
|
||||
with open(temp_name, 'w', encoding='utf-8') as tmp_file:
|
||||
tmp_file.write("""def foo
|
||||
puts "Hello, World!"
|
||||
end
|
||||
foo
|
||||
""")
|
||||
tmp_file.close()
|
||||
yield temp_name
|
||||
os.remove(temp_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def linter(tmp_path):
|
||||
return Linter(root=tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_typescript_file_errors(tmp_path):
|
||||
# Fixture to create a temporary TypeScript file with errors
|
||||
temp_name = os.path.join(tmp_path, 'lint-test.ts')
|
||||
with open(temp_name, 'w', encoding='utf-8') as tmp_file:
|
||||
tmp_file.write("""function foo() {
|
||||
console.log("Hello, World!")
|
||||
foo()
|
||||
""")
|
||||
tmp_file.close()
|
||||
yield temp_name
|
||||
os.remove(temp_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_typescript_file_errors_semicolon(tmp_path):
|
||||
# Fixture to create a temporary TypeScript file with missing semicolon
|
||||
temp_name = os.path.join(tmp_path, 'lint-test.ts')
|
||||
with open(temp_name, 'w', encoding='utf-8') as tmp_file:
|
||||
tmp_file.write("""function printHelloWorld() {
|
||||
console.log('Hello World')
|
||||
}""")
|
||||
tmp_file.close()
|
||||
yield temp_name
|
||||
os.remove(temp_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_typescript_file_correct(tmp_path):
|
||||
# Fixture to create a temporary TypeScript file with correct code
|
||||
temp_name = os.path.join(tmp_path, 'lint-test.ts')
|
||||
with open(temp_name, 'w', encoding='utf-8') as tmp_file:
|
||||
tmp_file.write("""function foo(): void {
|
||||
console.log("Hello, World!");
|
||||
}
|
||||
foo();
|
||||
""")
|
||||
tmp_file.close()
|
||||
yield temp_name
|
||||
os.remove(temp_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_typescript_file_eslint_pass(tmp_path):
|
||||
temp_name = tmp_path / 'lint-test-pass.ts'
|
||||
temp_name.write_text("""
|
||||
function greet(name: string): void {
|
||||
console.log(`Hello, ${name}!`);
|
||||
}
|
||||
greet("World");
|
||||
""")
|
||||
return str(temp_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_typescript_file_eslint_fail(tmp_path):
|
||||
temp_name = tmp_path / 'lint-test-fail.ts'
|
||||
temp_name.write_text("""
|
||||
function greet(name) {
|
||||
console.log("Hello, " + name + "!")
|
||||
var unused = "This variable is never used";
|
||||
}
|
||||
greet("World")
|
||||
""")
|
||||
return str(temp_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_react_file_pass(tmp_path):
|
||||
temp_name = tmp_path / 'react-component-pass.tsx'
|
||||
temp_name.write_text("""
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const Greeting: React.FC<Props> = ({ name }) => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Hello, {name}!</h1>
|
||||
<p>You clicked {count} times</p>
|
||||
<button onClick={() => setCount(count + 1)}>
|
||||
Click me
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Greeting;
|
||||
""")
|
||||
return str(temp_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_react_file_fail(tmp_path):
|
||||
temp_name = tmp_path / 'react-component-fail.tsx'
|
||||
temp_name.write_text("""
|
||||
import React from 'react';
|
||||
|
||||
const Greeting = (props) => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Hello, {props.name}!</h1>
|
||||
<button onClick={() => console.log('Clicked')}>
|
||||
Click me
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Greeting;
|
||||
""")
|
||||
return str(temp_name)
|
||||
|
||||
|
||||
def test_get_rel_fname(linter, temp_file, tmp_path):
|
||||
# Test get_rel_fname method
|
||||
rel_fname = linter.get_rel_fname(temp_file)
|
||||
|
||||
assert rel_fname == os.path.relpath(temp_file, tmp_path)
|
||||
|
||||
|
||||
def test_run_cmd(linter, temp_file):
|
||||
# Test run_cmd method with a simple command
|
||||
result = linter.run_cmd('echo', temp_file, '')
|
||||
|
||||
assert result is None # echo command should return zero exit status
|
||||
|
||||
|
||||
def test_set_linter(linter):
|
||||
# Test set_linter method
|
||||
def custom_linter(fname, rel_fname, code):
|
||||
return LintResult(text='Custom Linter', lines=[1])
|
||||
|
||||
linter.set_linter('custom', custom_linter)
|
||||
|
||||
assert 'custom' in linter.languages
|
||||
assert linter.languages['custom'] == custom_linter
|
||||
|
||||
|
||||
def test_py_lint(linter, temp_file):
|
||||
# Test py_lint method
|
||||
result = linter.py_lint(
|
||||
temp_file, linter.get_rel_fname(temp_file), "print('Hello, World!')\n"
|
||||
)
|
||||
|
||||
assert result is None # No lint errors expected for this simple code
|
||||
|
||||
|
||||
def test_py_lint_fail(linter, temp_file):
|
||||
# Test py_lint method
|
||||
result = linter.py_lint(
|
||||
temp_file, linter.get_rel_fname(temp_file), "print('Hello, World!')\n"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_basic_lint(temp_file):
|
||||
from openhands.runtime.plugins.agent_skills.utils.aider.linter import basic_lint
|
||||
|
||||
poorly_formatted_code = """
|
||||
def foo()
|
||||
print("Hello, World!")
|
||||
print("Wrong indent")
|
||||
foo(
|
||||
"""
|
||||
result = basic_lint(temp_file, poorly_formatted_code)
|
||||
|
||||
assert isinstance(result, LintResult)
|
||||
assert result.text.startswith(f'{temp_file}:2:9')
|
||||
assert 2 in result.lines
|
||||
|
||||
|
||||
def test_basic_lint_fail_returns_text_and_lines(temp_file):
|
||||
from openhands.runtime.plugins.agent_skills.utils.aider.linter import basic_lint
|
||||
|
||||
poorly_formatted_code = """
|
||||
def foo()
|
||||
print("Hello, World!")
|
||||
print("Wrong indent")
|
||||
foo(
|
||||
"""
|
||||
|
||||
result = basic_lint(temp_file, poorly_formatted_code)
|
||||
|
||||
assert isinstance(result, LintResult)
|
||||
assert result.text.startswith(f'{temp_file}:2:9')
|
||||
assert 2 in result.lines
|
||||
|
||||
|
||||
def test_lint_python_compile(temp_file):
|
||||
from openhands.runtime.plugins.agent_skills.utils.aider.linter import (
|
||||
lint_python_compile,
|
||||
)
|
||||
|
||||
result = lint_python_compile(temp_file, "print('Hello, World!')\n")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_lint_python_compile_fail_returns_text_and_lines(temp_file):
|
||||
from openhands.runtime.plugins.agent_skills.utils.aider.linter import (
|
||||
lint_python_compile,
|
||||
)
|
||||
|
||||
poorly_formatted_code = """
|
||||
def foo()
|
||||
print("Hello, World!")
|
||||
print("Wrong indent")
|
||||
foo(
|
||||
"""
|
||||
result = lint_python_compile(temp_file, poorly_formatted_code)
|
||||
|
||||
assert temp_file in result.text
|
||||
assert 1 in result.lines
|
||||
|
||||
|
||||
def test_lint(linter, temp_file):
|
||||
result = linter.lint(temp_file)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_lint_fail(linter, temp_file):
|
||||
# Test lint method
|
||||
with open(temp_file, 'w', encoding='utf-8') as lint_file:
|
||||
lint_file.write("""
|
||||
def foo()
|
||||
print("Hello, World!")
|
||||
print("Wrong indent")
|
||||
foo(
|
||||
""")
|
||||
errors = linter.lint(temp_file)
|
||||
|
||||
assert errors is not None
|
||||
|
||||
|
||||
def test_lint_pass_ruby(linter, temp_ruby_file_correct):
|
||||
result = linter.lint(temp_ruby_file_correct)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_lint_fail_ruby(linter, temp_ruby_file_errors):
|
||||
errors = linter.lint(temp_ruby_file_errors)
|
||||
assert errors is not None
|
||||
|
||||
|
||||
def test_lint_fail_ruby_no_parentheses(linter, temp_ruby_file_errors_parentheses):
|
||||
errors = linter.lint(temp_ruby_file_errors_parentheses)
|
||||
assert errors is not None
|
||||
|
||||
|
||||
def test_lint_pass_typescript(linter, temp_typescript_file_correct):
|
||||
if linter.ts_installed:
|
||||
with patch.object(linter, 'root', return_value=get_parent_directory()):
|
||||
result = linter.lint(temp_typescript_file_correct)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_lint_fail_typescript(linter, temp_typescript_file_errors):
|
||||
if linter.ts_installed:
|
||||
errors = linter.lint(temp_typescript_file_errors)
|
||||
assert errors is not None
|
||||
|
||||
|
||||
def test_lint_fail_typescript_missing_semicolon(
|
||||
linter, temp_typescript_file_errors_semicolon
|
||||
):
|
||||
if linter.ts_installed:
|
||||
with patch.dict(os.environ, {'ENABLE_AUTO_LINT': 'True'}):
|
||||
errors = linter.lint(temp_typescript_file_errors_semicolon)
|
||||
assert errors is not None
|
||||
|
||||
|
||||
def test_ts_eslint_pass(linter, temp_typescript_file_eslint_pass):
|
||||
with patch.object(linter, 'eslint_installed', return_value=True):
|
||||
with patch.object(linter, 'root', return_value=get_parent_directory()):
|
||||
with patch.object(linter, 'run_cmd') as mock_run_cmd:
|
||||
mock_run_cmd.return_value = MagicMock(text='[]') # Empty ESLint output
|
||||
result = linter.ts_eslint(
|
||||
temp_typescript_file_eslint_pass, 'lint-test-pass.ts', ''
|
||||
)
|
||||
assert result is None # No lint errors expected
|
||||
|
||||
|
||||
def test_ts_eslint_not_installed(linter, temp_typescript_file_eslint_pass):
|
||||
with patch.object(linter, 'eslint_installed', return_value=False):
|
||||
with patch.object(linter, 'root', return_value=get_parent_directory()):
|
||||
result = linter.lint(temp_typescript_file_eslint_pass)
|
||||
assert result is None # Should return None when ESLint is not installed
|
||||
|
||||
|
||||
def test_ts_eslint_run_cmd_error(linter, temp_typescript_file_eslint_pass):
|
||||
with patch.object(linter, 'eslint_installed', return_value=True):
|
||||
with patch.object(linter, 'run_cmd', side_effect=FileNotFoundError):
|
||||
result = linter.ts_eslint(
|
||||
temp_typescript_file_eslint_pass, 'lint-test-pass.ts', ''
|
||||
)
|
||||
assert result is None # Should return None when run_cmd raises an exception
|
||||
|
||||
|
||||
def test_ts_eslint_react_pass(linter, temp_react_file_pass):
|
||||
if not linter.eslint_installed:
|
||||
pytest.skip('ESLint is not installed. Skipping this test.')
|
||||
|
||||
with patch.object(linter, 'eslint_installed', return_value=True):
|
||||
with patch.object(linter, 'run_cmd') as mock_run_cmd:
|
||||
mock_run_cmd.return_value = MagicMock(text='[]') # Empty ESLint output
|
||||
result = linter.ts_eslint(
|
||||
temp_react_file_pass, 'react-component-pass.tsx', ''
|
||||
)
|
||||
assert result is None # No lint errors expected
|
||||
|
||||
|
||||
def test_ts_eslint_react_fail(linter, temp_react_file_fail):
|
||||
if not linter.eslint_installed:
|
||||
pytest.skip('ESLint is not installed. Skipping this test.')
|
||||
|
||||
with patch.object(linter, 'run_cmd') as mock_run_cmd:
|
||||
mock_eslint_output = """[
|
||||
{
|
||||
"filePath": "react-component-fail.tsx",
|
||||
"messages": [
|
||||
{
|
||||
"ruleId": "react/prop-types",
|
||||
"severity": 1,
|
||||
"message": "Missing prop type for 'name'",
|
||||
"line": 5,
|
||||
"column": 22,
|
||||
"nodeType": "Identifier",
|
||||
"messageId": "missingPropType",
|
||||
"endLine": 5,
|
||||
"endColumn": 26
|
||||
},
|
||||
{
|
||||
"ruleId": "no-console",
|
||||
"severity": 1,
|
||||
"message": "Unexpected console statement.",
|
||||
"line": 7,
|
||||
"column": 29,
|
||||
"nodeType": "MemberExpression",
|
||||
"messageId": "unexpected",
|
||||
"endLine": 7,
|
||||
"endColumn": 40
|
||||
}
|
||||
],
|
||||
"errorCount": 0,
|
||||
"warningCount": 2,
|
||||
"fixableErrorCount": 0,
|
||||
"fixableWarningCount": 0,
|
||||
"source": "..."
|
||||
}
|
||||
]"""
|
||||
mock_run_cmd.return_value = MagicMock(text=mock_eslint_output)
|
||||
linter.root = get_parent_directory()
|
||||
result = linter.ts_eslint(temp_react_file_fail, 'react-component-fail.tsx', '')
|
||||
if not linter.eslint_installed:
|
||||
assert result is None
|
||||
return
|
||||
|
||||
assert isinstance(result, LintResult)
|
||||
assert (
|
||||
"react-component-fail.tsx:5:22: Missing prop type for 'name' (react/prop-types)"
|
||||
in result.text
|
||||
)
|
||||
assert (
|
||||
'react-component-fail.tsx:7:29: Unexpected console statement. (no-console)'
|
||||
in result.text
|
||||
)
|
||||
assert 5 in result.lines
|
||||
assert 7 in result.lines
|
||||
|
||||
|
||||
def test_ts_eslint_react_config(linter, temp_react_file_pass):
|
||||
if not linter.eslint_installed:
|
||||
pytest.skip('ESLint is not installed. Skipping this test.')
|
||||
|
||||
with patch.object(linter, 'root', return_value=get_parent_directory()):
|
||||
with patch.object(linter, 'run_cmd') as mock_run_cmd:
|
||||
mock_run_cmd.return_value = MagicMock(text='[]') # Empty ESLint output
|
||||
linter.root = get_parent_directory()
|
||||
result = linter.ts_eslint(
|
||||
temp_react_file_pass, 'react-component-pass.tsx', ''
|
||||
)
|
||||
assert result is None
|
||||
# Check if the ESLint command includes React-specific configuration
|
||||
called_cmd = mock_run_cmd.call_args[0][0]
|
||||
assert 'resolve-plugins-relative-to' in called_cmd
|
||||
# Additional assertions to ensure React configuration is present
|
||||
assert '--config /tmp/' in called_cmd
|
||||
|
||||
|
||||
def test_ts_eslint_react_missing_semicolon(linter, tmp_path):
|
||||
if not linter.eslint_installed:
|
||||
pytest.skip('ESLint is not installed. Skipping this test.')
|
||||
|
||||
temp_react_file = tmp_path / 'App.tsx'
|
||||
temp_react_file.write_text("""import React, { useState, useEffect, useCallback } from 'react'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setDarkMode(!darkMode);
|
||||
document.body.classList.toggle('dark-mode');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`App ${darkMode ? 'dark-mode' : ''}`}>
|
||||
<button onClick={toggleDarkMode}>
|
||||
{darkMode ? 'Light Mode' : 'Dark Mode'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
""")
|
||||
|
||||
linter.root = get_parent_directory()
|
||||
result = linter.ts_eslint(str(temp_react_file), str(temp_react_file), '')
|
||||
assert isinstance(result, LintResult)
|
||||
|
||||
if 'JSONDecodeError' in result.text:
|
||||
linter.print_lint_result(result)
|
||||
pytest.skip(
|
||||
'ESLint returned a JSONDecodeError. This might be due to a configuration issue.'
|
||||
)
|
||||
|
||||
if 'eslint-plugin-react' in result.text and "wasn't found" in result.text:
|
||||
linter.print_lint_result(result)
|
||||
pytest.skip(
|
||||
'eslint-plugin-react is not installed. This test requires the React ESLint plugin.'
|
||||
)
|
||||
|
||||
assert any(
|
||||
'Missing semicolon' in message for message in result.text.split('\n')
|
||||
), "Expected 'Missing semicolon' error not found"
|
||||
assert 1 in result.lines, 'Expected line 1 to be flagged for missing semicolon'
|
||||
assert 21 in result.lines, 'Expected line 21 to be flagged for missing semicolon'
|
||||
Loading…
x
Reference in New Issue
Block a user