refactor: standardize linter output data structure and interface (#4077)

Co-authored-by: Graham Neubig <neubig@gmail.com>
This commit is contained in:
Xingyao Wang 2024-09-30 13:40:23 -05:00 committed by GitHub
parent 13901b4b5a
commit 54ac340e0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 676 additions and 1191 deletions

View 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
View 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

View 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',
)
]

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

View 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 []

View File

@ -0,0 +1,3 @@
from .cmd import run_cmd, check_tool_installed
__all__ = ['run_cmd', 'check_tool_installed']

View 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

View File

@ -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):

View File

@ -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.

View File

@ -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)).

View File

@ -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']

View File

@ -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()

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

View 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 == []

View 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

View 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

View File

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

View File

@ -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'