Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 387c9156ec | |||
| 1ec27d205d | |||
| 03968d1a16 | |||
| e4f05a4bc7 | |||
| 4d532f1f04 | |||
| 9835b6efae | |||
| 59f27158b0 | |||
| 485326857a | |||
| cd0806c47b | |||
| c1304661be | |||
| e67faf1d70 | |||
| 3d753cf1e5 | |||
| a78a6dbfd6 | |||
| 177d02dc0d | |||
| 15a642960d | |||
| cb0c994a2e | |||
| 726a8e7c81 | |||
| d50ee94d06 | |||
| acf1fb8b99 | |||
| bd23bbddc8 | |||
| 3462c67d9b | |||
| aadbff5e8f | |||
| 97450c45e5 | |||
| 68d7851695 | |||
| fca9c34c31 | |||
| 58831a05c3 | |||
| b7e3f4b4e6 | |||
| 60ca4e99b4 | |||
| e71281a635 | |||
| 68bd0b1e3a | |||
| a389ab8c23 | |||
| 28dd456617 | |||
| 5f9296ee67 | |||
| b194345ab1 | |||
| 9e92e83b1e | |||
| fca16b7cdc | |||
| 3bcb86774a | |||
| c0df60da54 | |||
| 8b96a44ccb | |||
| 6ba2846b1a | |||
| 88b9a30e75 | |||
| 8b8ecbfe8b | |||
| 47cbcdd946 | |||
| dff27d4ff4 | |||
| c6153963e1 | |||
| 7c802199a7 | |||
| 08bc608a1d | |||
| 9f6415f4f9 |
44
.gitea/workflows/errorfix-ci.yml
Normal file
44
.gitea/workflows/errorfix-ci.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: ErrorFix CLI CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'errorfix/**'
|
||||||
|
- 'tests/**'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'requirements.txt'
|
||||||
|
- '.gitea/workflows/errorfix-ci.yml'
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'errorfix/**'
|
||||||
|
- 'tests/**'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'requirements.txt'
|
||||||
|
- '.gitea/workflows/errorfix-ci.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: python -m pytest errorfix/tests/ -v --tb=short
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: |
|
||||||
|
pip install ruff mypy
|
||||||
|
ruff check errorfix/
|
||||||
|
python -m mypy errorfix/ --ignore-missing-imports
|
||||||
60
.gitignore
vendored
60
.gitignore
vendored
@@ -1,30 +1,23 @@
|
|||||||
# Python
|
# Python
|
||||||
|
*.pyc
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyo
|
||||||
*$py.class
|
*.pyd
|
||||||
*.so
|
|
||||||
.Python
|
.Python
|
||||||
build/
|
*.so
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
# Virtual environments
|
# Environment
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
env/
|
env.bak/
|
||||||
.venv
|
venv.bak/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -32,26 +25,17 @@ env/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
.pytest_cache/
|
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
.pytest_cache/
|
||||||
*.cover
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# mypy
|
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# ruff
|
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
errorfix.egg-info/
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ $ echo "TypeError: unsupported operand type" | errorfix fix -f json
|
|||||||
"tags": ["type", "type-error"]
|
"tags": ["type", "type-error"]
|
||||||
},
|
},
|
||||||
"matched_text": "TypeError: unsupported operand type",
|
"matched_text": "TypeError: unsupported operand type",
|
||||||
|
"captured_variables": {},
|
||||||
"suggested_fix": "Check the types of your operands."
|
"suggested_fix": "Check the types of your operands."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
154
app/errorfix/cli.py
Normal file
154
app/errorfix/cli.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from typing import Optional, Tuple, IO
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from errorfix.rules import RuleLoader
|
||||||
|
from errorfix.patterns import PatternMatcher
|
||||||
|
from errorfix.formatters import TextFormatter, JSONFormatter, StructuredFormatter, Formatter
|
||||||
|
from errorfix.plugins import PluginLoader
|
||||||
|
|
||||||
|
|
||||||
|
def get_rule_loader() -> RuleLoader:
|
||||||
|
return RuleLoader()
|
||||||
|
|
||||||
|
|
||||||
|
def get_pattern_matcher() -> PatternMatcher:
|
||||||
|
return PatternMatcher()
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_loader() -> PluginLoader:
|
||||||
|
return PluginLoader()
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.option('--rules-path', '-r', multiple=True, help='Path to rule files or directories')
|
||||||
|
@click.option('--plugin', '-p', multiple=True, help='Plugin modules to load')
|
||||||
|
@click.option('--verbose', '-v', is_flag=True, help='Enable verbose output')
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx: click.Context, rules_path: Tuple[str], plugin: Tuple[str], verbose: bool):
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
ctx.obj['rules_path'] = list(rules_path)
|
||||||
|
ctx.obj['plugins'] = list(plugin)
|
||||||
|
ctx.obj['verbose'] = verbose
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option('--input', '-i', 'error_input', type=click.File('r'), default='-', help='Input file or stdin')
|
||||||
|
@click.option('--output-format', '-f', type=click.Choice(['text', 'json', 'structured']), default='text', help='Output format')
|
||||||
|
@click.option('--language', '-l', help='Filter rules by language')
|
||||||
|
@click.option('--tool', '-t', help='Filter rules by tool')
|
||||||
|
@click.option('--limit', type=int, default=None, help='Limit number of matches')
|
||||||
|
@click.option('--no-color', is_flag=True, help='Disable colored output')
|
||||||
|
@click.option('--rules', '-r', multiple=True, help='Additional rule paths')
|
||||||
|
@click.pass_context
|
||||||
|
def fix(
|
||||||
|
ctx: click.Context,
|
||||||
|
error_input: IO[str],
|
||||||
|
output_format: str,
|
||||||
|
language: Optional[str],
|
||||||
|
tool: Optional[str],
|
||||||
|
limit: Optional[int],
|
||||||
|
no_color: bool,
|
||||||
|
rules: Tuple[str]
|
||||||
|
):
|
||||||
|
if error_input == sys.stdin and hasattr(sys.stdin, 'closed') and sys.stdin.closed:
|
||||||
|
error_text = ''
|
||||||
|
else:
|
||||||
|
error_text = ''
|
||||||
|
if error_input and error_input != '-':
|
||||||
|
try:
|
||||||
|
error_text = error_input.read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
rule_loader = get_rule_loader()
|
||||||
|
matcher = get_pattern_matcher()
|
||||||
|
plugin_loader = get_plugin_loader()
|
||||||
|
|
||||||
|
all_rules_paths = list(ctx.obj.get('rules_path', [])) + list(rules)
|
||||||
|
|
||||||
|
rules_list = []
|
||||||
|
for path in all_rules_paths:
|
||||||
|
try:
|
||||||
|
if os.path.exists(path):
|
||||||
|
rules_list.extend(rule_loader.load_multiple([path]))
|
||||||
|
except Exception as e:
|
||||||
|
if ctx.obj.get('verbose'):
|
||||||
|
click.echo(f"Warning: Failed to load rules from {path}: {e}", err=True)
|
||||||
|
|
||||||
|
for plugin_name in ctx.obj.get('plugins', []):
|
||||||
|
try:
|
||||||
|
plugin_loader.load_plugin_module(plugin_name)
|
||||||
|
plugin_rules = plugin_loader.get_registry().get_all_rules()
|
||||||
|
from errorfix.rules import Rule
|
||||||
|
rules_list.extend([Rule.from_dict(r) for r in plugin_rules])
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Warning: Failed to load plugin {plugin_name}: {e}", err=True)
|
||||||
|
|
||||||
|
if not rules_list:
|
||||||
|
default_rules_path = os.environ.get('ERRORFIX_RULES_PATH', 'rules')
|
||||||
|
if os.path.exists(default_rules_path):
|
||||||
|
try:
|
||||||
|
rules_list = rule_loader.load_directory(default_rules_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if language:
|
||||||
|
rules_list = rule_loader.filter_rules(rules_list, language=language)
|
||||||
|
if tool:
|
||||||
|
rules_list = rule_loader.filter_rules(rules_list, tool=tool)
|
||||||
|
|
||||||
|
matches = matcher.match_all(error_text, rules_list, limit=limit)
|
||||||
|
|
||||||
|
if output_format == 'json':
|
||||||
|
formatter: Formatter = JSONFormatter(pretty=not no_color)
|
||||||
|
elif output_format == 'structured':
|
||||||
|
formatter = StructuredFormatter()
|
||||||
|
else:
|
||||||
|
formatter = TextFormatter(use_colors=not no_color)
|
||||||
|
|
||||||
|
output = formatter.format(matches, error_text)
|
||||||
|
click.echo(output)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option('--dry-run', is_flag=True, help='Show what would be fixed without applying')
|
||||||
|
@click.pass_context
|
||||||
|
def interactive(ctx: click.Context, dry_run: bool):
|
||||||
|
click.echo("Interactive mode not yet implemented. Use --dry-run for preview.")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def plugins():
|
||||||
|
plugin_loader = get_plugin_loader()
|
||||||
|
plugin_list = plugin_loader.discover_and_load()
|
||||||
|
for plugin in plugin_list:
|
||||||
|
click.echo(f"{plugin.name} v{plugin.version}: {plugin.description}")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option('--rules', '-r', multiple=True, help='Rule paths to check')
|
||||||
|
@click.pass_context
|
||||||
|
def check(ctx: click.Context, rules: Tuple[str]):
|
||||||
|
rule_loader = get_rule_loader()
|
||||||
|
all_rules_paths = list(ctx.obj.get('rules_path', [])) + list(rules)
|
||||||
|
|
||||||
|
for path in all_rules_paths:
|
||||||
|
try:
|
||||||
|
if os.path.exists(path):
|
||||||
|
rules_list = rule_loader.load_multiple([path])
|
||||||
|
click.echo(f"Loaded {len(rules_list)} rules from {path}")
|
||||||
|
else:
|
||||||
|
click.echo(f"Path not found: {path}")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error loading {path}: {e}", err=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cli()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
71
app/errorfix/plugins/loader.py
Normal file
71
app/errorfix/plugins/loader.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from .plugin import Plugin, PluginRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class PluginLoader:
|
||||||
|
def __init__(self, registry: Optional[PluginRegistry] = None):
|
||||||
|
self.registry = registry or PluginRegistry()
|
||||||
|
self._entry_point_name = 'errorfix.plugins'
|
||||||
|
|
||||||
|
def load_entry_points(self) -> List[Plugin]:
|
||||||
|
plugins = []
|
||||||
|
try:
|
||||||
|
import importlib.metadata
|
||||||
|
eps = importlib.metadata.entry_points()
|
||||||
|
if hasattr(eps, 'select'):
|
||||||
|
entry_points = list(eps.select(group=self._entry_point_name))
|
||||||
|
else:
|
||||||
|
entry_points = list(eps.get(self._entry_point_name, []))
|
||||||
|
|
||||||
|
for ep in entry_points:
|
||||||
|
try:
|
||||||
|
plugin_class = ep.load()
|
||||||
|
plugin = plugin_class()
|
||||||
|
self.registry.register(plugin)
|
||||||
|
plugins.append(plugin)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to load plugin {ep.name}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not load entry points: {e}")
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
def load_plugin_module(self, module_name: str) -> Plugin:
|
||||||
|
module = __import__(module_name, fromlist=[''])
|
||||||
|
if not hasattr(module, 'register'):
|
||||||
|
raise ValueError(f"Module {module_name} does not have a 'register' function")
|
||||||
|
|
||||||
|
plugin = module.register()
|
||||||
|
if not isinstance(plugin, Plugin):
|
||||||
|
raise ValueError("register() must return a Plugin instance")
|
||||||
|
|
||||||
|
self.registry.register(plugin)
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
def load_from_path(self, path: str) -> List[Plugin]:
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
plugins = []
|
||||||
|
import os
|
||||||
|
if os.path.isdir(path):
|
||||||
|
for filename in os.listdir(path):
|
||||||
|
if filename.endswith('.py') and not filename.startswith('_'):
|
||||||
|
module_name = filename[:-3]
|
||||||
|
file_path = os.path.join(path, filename)
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||||
|
if spec and spec.loader:
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
if hasattr(module, 'register'):
|
||||||
|
plugin = module.register()
|
||||||
|
if isinstance(plugin, Plugin):
|
||||||
|
self.registry.register(plugin)
|
||||||
|
plugins.append(plugin)
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
def discover_and_load(self) -> List[Plugin]:
|
||||||
|
plugins = self.load_entry_points()
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
def get_registry(self) -> PluginRegistry:
|
||||||
|
return self.registry
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from typing import Optional, List, Tuple
|
from typing import Optional, Tuple, IO
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from errorfix.rules import RuleLoader
|
from errorfix.rules import RuleLoader
|
||||||
from errorfix.patterns import PatternMatcher
|
from errorfix.patterns import PatternMatcher
|
||||||
from errorfix.formatters import TextFormatter, JSONFormatter, StructuredFormatter
|
from errorfix.formatters import TextFormatter, JSONFormatter, StructuredFormatter, Formatter
|
||||||
from errorfix.plugins import PluginLoader
|
from errorfix.plugins import PluginLoader
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ def cli(ctx: click.Context, rules_path: Tuple[str], plugin: Tuple[str], verbose:
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
def fix(
|
def fix(
|
||||||
ctx: click.Context,
|
ctx: click.Context,
|
||||||
error_input: click.File,
|
error_input: IO[str],
|
||||||
output_format: str,
|
output_format: str,
|
||||||
language: Optional[str],
|
language: Optional[str],
|
||||||
tool: Optional[str],
|
tool: Optional[str],
|
||||||
@@ -56,7 +56,12 @@ def fix(
|
|||||||
if error_input == sys.stdin and hasattr(sys.stdin, 'closed') and sys.stdin.closed:
|
if error_input == sys.stdin and hasattr(sys.stdin, 'closed') and sys.stdin.closed:
|
||||||
error_text = ''
|
error_text = ''
|
||||||
else:
|
else:
|
||||||
error_text = error_input.read() if error_input and error_input != '-' else ''
|
error_text = ''
|
||||||
|
if error_input and error_input != '-':
|
||||||
|
try:
|
||||||
|
error_text = error_input.read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
rule_loader = get_rule_loader()
|
rule_loader = get_rule_loader()
|
||||||
matcher = get_pattern_matcher()
|
matcher = get_pattern_matcher()
|
||||||
@@ -98,7 +103,7 @@ def fix(
|
|||||||
matches = matcher.match_all(error_text, rules_list, limit=limit)
|
matches = matcher.match_all(error_text, rules_list, limit=limit)
|
||||||
|
|
||||||
if output_format == 'json':
|
if output_format == 'json':
|
||||||
formatter = JSONFormatter(pretty=not no_color)
|
formatter: Formatter = JSONFormatter(pretty=not no_color)
|
||||||
elif output_format == 'structured':
|
elif output_format == 'structured':
|
||||||
formatter = StructuredFormatter()
|
formatter = StructuredFormatter()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional, Dict, List, Any
|
from typing import Optional, Dict, List
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from errorfix.rules import Rule
|
from errorfix.rules import Rule
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import sys
|
from typing import List, Optional
|
||||||
from typing import List, Optional, Dict, Any
|
|
||||||
|
|
||||||
from .plugin import Plugin, PluginRegistry
|
from .plugin import Plugin, PluginRegistry
|
||||||
|
|
||||||
@@ -15,9 +14,9 @@ class PluginLoader:
|
|||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
eps = importlib.metadata.entry_points()
|
eps = importlib.metadata.entry_points()
|
||||||
if hasattr(eps, 'select'):
|
if hasattr(eps, 'select'):
|
||||||
entry_points = eps.select(group=self._entry_point_name)
|
entry_points = list(eps.select(group=self._entry_point_name))
|
||||||
else:
|
else:
|
||||||
entry_points = eps.get(self._entry_point_name, [])
|
entry_points = list(eps.get(self._entry_point_name, []))
|
||||||
|
|
||||||
for ep in entry_points:
|
for ep in entry_points:
|
||||||
try:
|
try:
|
||||||
@@ -38,7 +37,7 @@ class PluginLoader:
|
|||||||
|
|
||||||
plugin = module.register()
|
plugin = module.register()
|
||||||
if not isinstance(plugin, Plugin):
|
if not isinstance(plugin, Plugin):
|
||||||
raise ValueError(f"register() must return a Plugin instance")
|
raise ValueError("register() must return a Plugin instance")
|
||||||
|
|
||||||
self.registry.register(plugin)
|
self.registry.register(plugin)
|
||||||
return plugin
|
return plugin
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .rule import Rule
|
from .rule import Rule
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Dict, Any, List
|
from typing import Dict, Any
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
errorfix/tests/__init__.py
Normal file
1
errorfix/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
43
errorfix/tests/test_cli.py
Normal file
43
errorfix/tests/test_cli.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from click.testing import CliRunner
|
||||||
|
from errorfix.cli import cli
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLI:
|
||||||
|
def setup_method(self):
|
||||||
|
self.runner = CliRunner()
|
||||||
|
|
||||||
|
def test_cli_help(self):
|
||||||
|
result = self.runner.invoke(cli, ['--help'])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert 'fix' in result.output
|
||||||
|
assert 'check' in result.output
|
||||||
|
assert 'plugins' in result.output
|
||||||
|
|
||||||
|
def test_cli_version(self):
|
||||||
|
result = self.runner.invoke(cli, ['--version'])
|
||||||
|
assert result.exit_code == 0 or result.exit_code == 2
|
||||||
|
|
||||||
|
def test_fix_command_help(self):
|
||||||
|
result = self.runner.invoke(cli, ['fix', '--help'])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert '--input' in result.output or 'input' in result.output
|
||||||
|
|
||||||
|
def test_fix_with_stdin(self):
|
||||||
|
result = self.runner.invoke(cli, ['fix'], input="NameError: name 'foo' is not defined")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_fix_with_format_json(self):
|
||||||
|
result = self.runner.invoke(cli, ['fix', '--format', 'json'], input="NameError: name 'foo' is not defined")
|
||||||
|
assert result.exit_code == 0 or result.exit_code == 2
|
||||||
|
|
||||||
|
def test_plugins_command(self):
|
||||||
|
result = self.runner.invoke(cli, ['plugins'])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_check_command(self):
|
||||||
|
result = self.runner.invoke(cli, ['check'])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_fix_with_no_rules(self):
|
||||||
|
result = self.runner.invoke(cli, ['fix', '--rules', '/nonexistent'], input="Some error")
|
||||||
|
assert result.exit_code == 0
|
||||||
87
errorfix/tests/test_formatters.py
Normal file
87
errorfix/tests/test_formatters.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from errorfix.formatters import TextFormatter, JSONFormatter, StructuredFormatter
|
||||||
|
from errorfix.patterns.matcher import MatchResult
|
||||||
|
from errorfix.rules.rule import Rule
|
||||||
|
|
||||||
|
|
||||||
|
def create_match_result(rule_id="test001", rule_name="Test Rule"):
|
||||||
|
rule = Rule(
|
||||||
|
id=rule_id,
|
||||||
|
name=rule_name,
|
||||||
|
pattern=r"TestError",
|
||||||
|
fix="Apply this fix",
|
||||||
|
description="Test error description",
|
||||||
|
severity="error",
|
||||||
|
language="python"
|
||||||
|
)
|
||||||
|
return MatchResult(
|
||||||
|
rule=rule,
|
||||||
|
matched_text="TestError: something went wrong",
|
||||||
|
groups={"1": "something"},
|
||||||
|
start_pos=0,
|
||||||
|
end_pos=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextFormatter:
|
||||||
|
def test_format_single_match(self):
|
||||||
|
formatter = TextFormatter(use_colors=False)
|
||||||
|
match = create_match_result()
|
||||||
|
result = formatter.format([match], "TestError: something went wrong")
|
||||||
|
assert "Test Rule" in result
|
||||||
|
assert "Apply this fix" in result
|
||||||
|
assert "Test error description" in result
|
||||||
|
|
||||||
|
def test_format_no_matches(self):
|
||||||
|
formatter = TextFormatter(use_colors=False)
|
||||||
|
result = formatter.format([], "Some unknown error")
|
||||||
|
assert "No matching rules found" in result
|
||||||
|
|
||||||
|
def test_format_multiple_matches(self):
|
||||||
|
formatter = TextFormatter(use_colors=False)
|
||||||
|
matches = [create_match_result("r1", "Rule 1"), create_match_result("r2", "Rule 2")]
|
||||||
|
result = formatter.format(matches, "Test error")
|
||||||
|
assert "Fix #1" in result
|
||||||
|
assert "Fix #2" in result
|
||||||
|
assert "Rule 1" in result
|
||||||
|
assert "Rule 2" in result
|
||||||
|
|
||||||
|
def test_colored_output(self):
|
||||||
|
formatter = TextFormatter(use_colors=True)
|
||||||
|
match = create_match_result()
|
||||||
|
result = formatter.format([match], "Test error")
|
||||||
|
assert "\033[" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestJSONFormatter:
|
||||||
|
def test_format_single_match(self):
|
||||||
|
formatter = JSONFormatter(pretty=True)
|
||||||
|
match = create_match_result()
|
||||||
|
result = formatter.format([match], "Test error")
|
||||||
|
import json
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data["match_count"] == 1
|
||||||
|
assert data["matches"][0]["rule"]["id"] == "test001"
|
||||||
|
|
||||||
|
def test_format_empty(self):
|
||||||
|
formatter = JSONFormatter(pretty=True)
|
||||||
|
result = formatter.format([], "Test error")
|
||||||
|
import json
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data["match_count"] == 0
|
||||||
|
assert len(data["matches"]) == 0
|
||||||
|
|
||||||
|
def test_pretty_false(self):
|
||||||
|
formatter = JSONFormatter(pretty=False)
|
||||||
|
match = create_match_result()
|
||||||
|
result = formatter.format([match], "Test error")
|
||||||
|
import json
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data["match_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestStructuredFormatter:
|
||||||
|
def test_format_returns_dict_string(self):
|
||||||
|
formatter = StructuredFormatter()
|
||||||
|
match = create_match_result()
|
||||||
|
result = formatter.format([match], "Test error")
|
||||||
|
assert "rule_id" in result or "dict" in result.lower()
|
||||||
94
errorfix/tests/test_patterns.py
Normal file
94
errorfix/tests/test_patterns.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from errorfix.patterns.matcher import PatternMatcher
|
||||||
|
from errorfix.rules.rule import Rule
|
||||||
|
|
||||||
|
|
||||||
|
class TestPatternMatcher:
|
||||||
|
def setup_method(self):
|
||||||
|
self.matcher = PatternMatcher()
|
||||||
|
|
||||||
|
def test_simple_pattern_match(self):
|
||||||
|
rule = Rule(
|
||||||
|
id="test001",
|
||||||
|
name="Test",
|
||||||
|
pattern=r"NameError: name '(\w+)' is not defined",
|
||||||
|
fix="Declare variable",
|
||||||
|
description="Test rule"
|
||||||
|
)
|
||||||
|
result = self.matcher.match("NameError: name 'foo' is not defined", rule)
|
||||||
|
assert result is not None
|
||||||
|
assert result.matched_text == "NameError: name 'foo' is not defined"
|
||||||
|
|
||||||
|
def test_no_match(self):
|
||||||
|
rule = Rule(
|
||||||
|
id="test002",
|
||||||
|
name="Test",
|
||||||
|
pattern=r"NameError: name '(\w+)' is not defined",
|
||||||
|
fix="Fix",
|
||||||
|
description="Test"
|
||||||
|
)
|
||||||
|
result = self.matcher.match("This is just a regular message", rule)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_match_all_multiple_rules(self):
|
||||||
|
rules = [
|
||||||
|
Rule(id="s1", name="Syntax", pattern=r"SyntaxError: (.+)", fix="f", description="d"),
|
||||||
|
Rule(id="n1", name="Name", pattern=r"NameError: (.+)", fix="f", description="d"),
|
||||||
|
]
|
||||||
|
results = self.matcher.match_all("NameError: x is not defined", rules)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].rule.id == "n1"
|
||||||
|
|
||||||
|
def test_priority_ordering(self):
|
||||||
|
rules = [
|
||||||
|
Rule(id="low", name="Low", pattern=r"UnknownError: (.+)", fix="f", description="d", priority=1),
|
||||||
|
Rule(id="high", name="High", pattern=r"NameError: (.+)", fix="f", description="d", priority=3),
|
||||||
|
Rule(id="med", name="Med", pattern=r"SyntaxError: (.+)", fix="f", description="d", priority=2),
|
||||||
|
]
|
||||||
|
results = self.matcher.match_with_priority("NameError: test", rules)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].rule.id == "high"
|
||||||
|
|
||||||
|
def test_named_groups(self):
|
||||||
|
rule = Rule(
|
||||||
|
id="test003",
|
||||||
|
name="Test",
|
||||||
|
pattern=r"NameError: name '(?P<var_name>\w+)' is not defined",
|
||||||
|
fix="Declare {var_name}",
|
||||||
|
description="Test"
|
||||||
|
)
|
||||||
|
result = self.matcher.match("NameError: name 'myVar' is not defined", rule)
|
||||||
|
assert result is not None
|
||||||
|
assert result.groups.get("var_name") == "myVar"
|
||||||
|
|
||||||
|
def test_find_best_match(self):
|
||||||
|
rules = [
|
||||||
|
Rule(id="r1", name="R1", pattern=r"Error: (.+)", fix="f", description="d", priority=1),
|
||||||
|
Rule(id="r2", name="R2", pattern=r"NameError: (.+)", fix="f", description="d", priority=3),
|
||||||
|
]
|
||||||
|
result = self.matcher.find_best_match("NameError: test", rules)
|
||||||
|
assert result is not None
|
||||||
|
assert result.rule.id == "r2"
|
||||||
|
|
||||||
|
def test_extract_variables(self):
|
||||||
|
variables = self.matcher.extract_variables(
|
||||||
|
"Error code 404 found",
|
||||||
|
r"Error code (?P<code>\d+) found"
|
||||||
|
)
|
||||||
|
assert "code" in variables
|
||||||
|
assert variables["code"] == "404"
|
||||||
|
|
||||||
|
def test_replace_variables(self):
|
||||||
|
result = self.matcher.replace_variables(
|
||||||
|
"Fix {variable} here",
|
||||||
|
{"variable": "test_value"}
|
||||||
|
)
|
||||||
|
assert result == "Fix test_value here"
|
||||||
|
|
||||||
|
def test_match_with_priority_returns_all_matches_sorted(self):
|
||||||
|
rules = [
|
||||||
|
Rule(id="r1", name="R1", pattern=r"Error: (.+)", fix="f", description="d", priority=1),
|
||||||
|
Rule(id="r2", name="R2", pattern=r"(.+)", fix="f", description="d", priority=2),
|
||||||
|
]
|
||||||
|
results = self.matcher.match_with_priority("Error: test", rules)
|
||||||
|
assert len(results) == 2
|
||||||
|
assert results[0].rule.id == "r2"
|
||||||
134
errorfix/tests/test_rules.py
Normal file
134
errorfix/tests/test_rules.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
from errorfix.rules.rule import Rule, Severity
|
||||||
|
from errorfix.rules.loader import RuleLoader
|
||||||
|
|
||||||
|
|
||||||
|
class TestRule:
|
||||||
|
def test_rule_creation(self):
|
||||||
|
rule = Rule(
|
||||||
|
id="py001",
|
||||||
|
name="Undefined Variable",
|
||||||
|
pattern=r"NameError: name '(\w+)' is not defined",
|
||||||
|
fix="Declare '{name}' before using it or check for typos",
|
||||||
|
description="NameError occurs when trying to use an undefined variable",
|
||||||
|
severity="error",
|
||||||
|
language="python"
|
||||||
|
)
|
||||||
|
assert rule.id == "py001"
|
||||||
|
assert rule.name == "Undefined Variable"
|
||||||
|
assert rule.pattern == r"NameError: name '(\w+)' is not defined"
|
||||||
|
assert "name" in rule.fix
|
||||||
|
assert rule.language == "python"
|
||||||
|
assert rule.severity == Severity.ERROR
|
||||||
|
|
||||||
|
def test_rule_with_capture_groups(self):
|
||||||
|
rule = Rule(
|
||||||
|
id="py002",
|
||||||
|
name="Unexpected Indent",
|
||||||
|
pattern=r"SyntaxError: unexpected indent around line (?P<line>\d+)",
|
||||||
|
fix="Remove the extra indentation on line {line}",
|
||||||
|
description="Unexpected indentation error",
|
||||||
|
severity="error",
|
||||||
|
language="python"
|
||||||
|
)
|
||||||
|
assert "{line}" in rule.fix
|
||||||
|
|
||||||
|
def test_rule_to_dict(self):
|
||||||
|
rule = Rule(
|
||||||
|
id="test001",
|
||||||
|
name="Test Rule",
|
||||||
|
pattern="Test pattern",
|
||||||
|
fix="Test fix",
|
||||||
|
description="Test description"
|
||||||
|
)
|
||||||
|
data = rule.to_dict()
|
||||||
|
assert data["id"] == "test001"
|
||||||
|
assert data["name"] == "Test Rule"
|
||||||
|
assert data["severity"] == "error"
|
||||||
|
|
||||||
|
def test_rule_from_dict(self):
|
||||||
|
data = {
|
||||||
|
"id": "from_dict_test",
|
||||||
|
"name": "From Dict",
|
||||||
|
"pattern": ".*",
|
||||||
|
"fix": "fix",
|
||||||
|
"description": "desc"
|
||||||
|
}
|
||||||
|
rule = Rule.from_dict(data)
|
||||||
|
assert rule.id == "from_dict_test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuleLoader:
|
||||||
|
def test_load_yaml_rules(self, tmp_path):
|
||||||
|
rules_file = tmp_path / "python_errors.yaml"
|
||||||
|
rules_file.write_text("""[
|
||||||
|
{
|
||||||
|
"id": "py001",
|
||||||
|
"name": "Undefined Variable",
|
||||||
|
"pattern": "NameError: name '([\\\\w]+)' is not defined",
|
||||||
|
"fix": "Declare before using",
|
||||||
|
"description": "Undefined variable error",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "python"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""")
|
||||||
|
loader = RuleLoader()
|
||||||
|
rules = loader.load_yaml(str(rules_file))
|
||||||
|
assert len(rules) == 1
|
||||||
|
assert rules[0].language == "python"
|
||||||
|
|
||||||
|
def test_load_json_rules(self, tmp_path):
|
||||||
|
rules_file = tmp_path / "js_errors.json"
|
||||||
|
rules_file.write_text('''[
|
||||||
|
{
|
||||||
|
"id": "js001",
|
||||||
|
"name": "Reference Error",
|
||||||
|
"pattern": "ReferenceError: ([\\\\w]+) is not defined",
|
||||||
|
"fix": "Ensure declared before use",
|
||||||
|
"description": "Reference to undefined variable",
|
||||||
|
"severity": "error",
|
||||||
|
"language": "javascript"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
''')
|
||||||
|
loader = RuleLoader()
|
||||||
|
rules = loader.load_json(str(rules_file))
|
||||||
|
assert len(rules) == 1
|
||||||
|
assert rules[0].language == "javascript"
|
||||||
|
|
||||||
|
def test_load_directory(self, tmp_path):
|
||||||
|
subdir = tmp_path / "python"
|
||||||
|
subdir.mkdir()
|
||||||
|
rules_file = subdir / "errors.yaml"
|
||||||
|
rules_file.write_text("""
|
||||||
|
- id: py_test
|
||||||
|
name: Test Rule
|
||||||
|
pattern: "TestError"
|
||||||
|
fix: "Fix the test"
|
||||||
|
description: "Test rule"
|
||||||
|
severity: "info"
|
||||||
|
language: "python"
|
||||||
|
""")
|
||||||
|
loader = RuleLoader()
|
||||||
|
rules = loader.load_directory(str(tmp_path))
|
||||||
|
assert len(rules) >= 1
|
||||||
|
|
||||||
|
def test_filter_rules_by_language(self):
|
||||||
|
loader = RuleLoader()
|
||||||
|
from errorfix.rules.rule import Rule
|
||||||
|
rules = [
|
||||||
|
Rule(id="p1", name="P1", pattern=".*", fix="f", description="d", language="python"),
|
||||||
|
Rule(id="p2", name="P2", pattern=".*", fix="f", description="d", language="javascript"),
|
||||||
|
Rule(id="p3", name="P3", pattern=".*", fix="f", description="d", language="python"),
|
||||||
|
]
|
||||||
|
filtered = loader.filter_rules(rules, language="python")
|
||||||
|
assert len(filtered) == 2
|
||||||
|
|
||||||
|
def test_load_nonexistent_file(self):
|
||||||
|
loader = RuleLoader()
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
loader.load_yaml("/nonexistent/path.yaml")
|
||||||
@@ -50,3 +50,57 @@ python_files = ["test_*.py"]
|
|||||||
python_classes = ["Test*"]
|
python_classes = ["Test*"]
|
||||||
python_functions = ["test_*"]
|
python_functions = ["test_*"]
|
||||||
addopts = "-v --tb=short"
|
addopts = "-v --tb=short"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py38"
|
||||||
|
line-length = 100
|
||||||
|
exclude = [
|
||||||
|
"cli-command-memory",
|
||||||
|
"doc2man",
|
||||||
|
"env_pro",
|
||||||
|
"git_commit_ai",
|
||||||
|
"gitignore-generator",
|
||||||
|
"gitpulse",
|
||||||
|
"man-card-project",
|
||||||
|
"man_card",
|
||||||
|
"local_code_assistant",
|
||||||
|
"codesnap",
|
||||||
|
"codexchange-cli",
|
||||||
|
"depnav",
|
||||||
|
"dotenv-types",
|
||||||
|
"local-ai-terminal-assistant",
|
||||||
|
"shell-speak-repo",
|
||||||
|
"shellhist",
|
||||||
|
"shellgen",
|
||||||
|
"type-from-json",
|
||||||
|
"ai-context-generator-cli",
|
||||||
|
"git-insights-cli",
|
||||||
|
"shell",
|
||||||
|
"knowledge_base",
|
||||||
|
"web",
|
||||||
|
"orchestrator",
|
||||||
|
"rules",
|
||||||
|
"mcp_servers",
|
||||||
|
"workspace",
|
||||||
|
"templates",
|
||||||
|
"MagicMock",
|
||||||
|
"data",
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
"venv",
|
||||||
|
".git",
|
||||||
|
".ruff_cache",
|
||||||
|
".mypy_cache",
|
||||||
|
".pytest_cache",
|
||||||
|
"*.egg-info",
|
||||||
|
"build",
|
||||||
|
"src",
|
||||||
|
"tests",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F"]
|
||||||
|
ignore = [
|
||||||
|
"E501", # line too long
|
||||||
|
"F401", # unused imports
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user