Compare commits

...

1 Commits

Author SHA1 Message Date
freemo 34b1d3f9be feat(tool): add devops/run-linter builtin tool
Implement the devops/run-linter tool that was referenced in documentation,
examples, and tests but never created. This tool provides linting capabilities
for source code validation, supporting ruff, eslint, and pylint linters.

The tool:
- Accepts target_path, config_path, linter type, and strict mode parameters
- Returns structured error and warning lists with file/line/column information
- Is read-only and idempotent
- Supports multiple linter backends (ruff, eslint, pylint)
- Integrates with the validation wrapping system for quality gates

Fixes #10858
2026-04-25 09:09:17 +00:00
2 changed files with 321 additions and 1 deletions
+14 -1
View File
@@ -1,6 +1,6 @@
"""Built-in tool registration helpers.
Provides convenience functions to register all built-in file, git, and
Provides convenience functions to register all built-in file, git, devops, and
subplan tools into a ``ToolRegistry``, optionally wrapped with changeset
capture.
@@ -17,6 +17,10 @@ from cleveragents.tool.builtins.changeset import (
ChangeSetCapture,
ChangeSetEntry,
)
from cleveragents.tool.builtins.devops_tools import (
ALL_DEVOPS_TOOLS,
RUN_LINTER_SPEC,
)
from cleveragents.tool.builtins.file_tools import (
ALL_FILE_TOOLS,
FILE_DELETE_SPEC,
@@ -70,6 +74,12 @@ def register_git_tools(registry: ToolRegistry) -> None:
registry.register(spec)
def register_devops_tools(registry: ToolRegistry) -> None:
"""Register all built-in devops tools into *registry*."""
for spec in ALL_DEVOPS_TOOLS:
registry.register(spec)
def register_subplan_tool(registry: ToolRegistry) -> None:
"""Register the ``builtin/plan-subplan`` tool into *registry*."""
for spec in ALL_SUBPLAN_TOOLS:
@@ -77,6 +87,7 @@ def register_subplan_tool(registry: ToolRegistry) -> None:
__all__ = [
"ALL_DEVOPS_TOOLS",
"ALL_FILE_TOOLS",
"ALL_GIT_TOOLS",
"ALL_SUBPLAN_TOOLS",
@@ -91,12 +102,14 @@ __all__ = [
"GIT_LOG_SPEC",
"GIT_STATUS_SPEC",
"PLAN_SUBPLAN_SPEC",
"RUN_LINTER_SPEC",
"BuiltinAdapter",
"ChangeSet",
"ChangeSetCapture",
"ChangeSetEntry",
"SubplanPayload",
"make_plan_subplan_spec",
"register_devops_tools",
"register_file_tools",
"register_file_tools_with_changeset",
"register_git_tools",
@@ -0,0 +1,307 @@
"""Built-in DevOps tools for CleverAgents.
The built-in DevOps tools provide common operations for development and
deployment workflows. All tools are registered under the ``devops/`` namespace.
## Tools
| Tool | Capability | Description |
|-------------------------|----------------|------------------------------------|
| ``devops/run-linter`` | ``read_only`` | Run linting checks on source code |
## Registration
```python
from cleveragents.tool.builtins import register_devops_tools
from cleveragents.tool.registry import ToolRegistry
registry = ToolRegistry()
register_devops_tools(registry)
```
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Any
from cleveragents.domain.models.core.tool import ToolCapability
from cleveragents.tool.runtime import ToolSpec
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# Default timeout for linter commands (seconds)
_LINTER_TIMEOUT: int = 120
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
def _handle_run_linter(inputs: dict[str, Any]) -> dict[str, Any]:
"""Run linting checks on source code.
Executes a linter (e.g., ruff, eslint, pylint) on the specified target
and returns a list of any linting errors found.
Args:
inputs: Dictionary containing:
- target_path: Path to file or directory to lint (required)
- config_path: Path to linter configuration file (optional)
- linter: Linter tool to use (default: "ruff")
- strict: Whether to treat warnings as errors (default: False)
Returns:
Dictionary with:
- errors: List of linting errors found
- warnings: List of linting warnings found
- passed: Boolean indicating if linting passed
- linter: Name of the linter used
- target_path: Path that was linted
"""
target_path: str = inputs.get("target_path", ".")
config_path: str | None = inputs.get("config_path")
linter: str = inputs.get("linter", "ruff")
strict: bool = inputs.get("strict", False)
# Validate paths
target = Path(target_path)
if not target.exists():
raise ValueError(f"Target path does not exist: {target_path}")
if config_path:
config = Path(config_path)
if not config.exists():
raise ValueError(f"Config path does not exist: {config_path}")
errors: list[dict[str, Any]] = []
warnings: list[dict[str, Any]] = []
try:
# Build linter command based on the linter type
if linter == "ruff":
cmd = ["ruff", "check", str(target)]
if config_path:
cmd.extend(["--config", str(config_path)])
if strict:
cmd.append("--strict")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=_LINTER_TIMEOUT,
)
# Parse ruff output
if result.stdout:
for line in result.stdout.strip().split("\n"):
if line:
# ruff format: path/to/file.py:line:col: CODE message
parts = line.split(":", 3)
if len(parts) >= 4:
errors.append(
{
"file": parts[0],
"line": int(parts[1]) if parts[1].isdigit() else 0,
"column": int(parts[2]) if parts[2].isdigit() else 0,
"message": parts[3].strip(),
}
)
elif linter == "eslint":
cmd = ["eslint", str(target), "--format", "json"]
if config_path:
cmd.extend(["--config", str(config_path)])
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=_LINTER_TIMEOUT,
)
# Parse eslint JSON output
if result.stdout:
try:
eslint_results = json.loads(result.stdout)
for file_result in eslint_results:
for message in file_result.get("messages", []):
error_dict = {
"file": file_result.get("filePath", ""),
"line": message.get("line", 0),
"column": message.get("column", 0),
"message": message.get("message", ""),
"rule": message.get("ruleId", ""),
}
if message.get("severity") == 2:
errors.append(error_dict)
else:
warnings.append(error_dict)
except json.JSONDecodeError:
# Fallback to text parsing if JSON parsing fails
if result.stdout:
errors.append(
{
"message": "Failed to parse eslint output",
"raw_output": result.stdout,
}
)
elif linter == "pylint":
cmd = ["pylint", str(target), "--output-format=json"]
if config_path:
cmd.extend(["--rcfile", str(config_path)])
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=_LINTER_TIMEOUT,
)
# Parse pylint JSON output
if result.stdout:
try:
pylint_results = json.loads(result.stdout)
for message in pylint_results:
error_dict = {
"file": message.get("path", ""),
"line": message.get("line", 0),
"column": message.get("column", 0),
"message": message.get("message", ""),
"type": message.get("type", ""),
}
if message.get("type") in ("error", "fatal"):
errors.append(error_dict)
else:
warnings.append(error_dict)
except json.JSONDecodeError:
if result.stdout:
errors.append(
{
"message": "Failed to parse pylint output",
"raw_output": result.stdout,
}
)
else:
raise ValueError(f"Unsupported linter: {linter}")
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"Linter command timed out after {exc.timeout}s") from exc
except FileNotFoundError as exc:
raise RuntimeError(f"Linter '{linter}' not found in PATH") from exc
passed = len(errors) == 0 and (not strict or len(warnings) == 0)
return {
"errors": errors,
"warnings": warnings,
"passed": passed,
"linter": linter,
"target_path": str(target),
"error_count": len(errors),
"warning_count": len(warnings),
}
# ---------------------------------------------------------------------------
# Tool specs
# ---------------------------------------------------------------------------
RUN_LINTER_SPEC = ToolSpec(
name="devops/run-linter",
description="Run linting checks on source code",
input_schema={
"type": "object",
"properties": {
"target_path": {
"type": "string",
"description": "Path to file or directory to lint (defaults to current directory)",
"default": ".",
},
"config_path": {
"type": "string",
"description": "Path to linter configuration file (optional)",
},
"linter": {
"type": "string",
"enum": ["ruff", "eslint", "pylint"],
"default": "ruff",
"description": "Linter tool to use",
},
"strict": {
"type": "boolean",
"default": False,
"description": "Treat warnings as errors",
},
},
"required": [],
},
output_schema={
"type": "object",
"properties": {
"errors": {
"type": "array",
"description": "List of linting errors found",
"items": {
"type": "object",
"properties": {
"file": {"type": "string"},
"line": {"type": "integer"},
"column": {"type": "integer"},
"message": {"type": "string"},
},
},
},
"warnings": {
"type": "array",
"description": "List of linting warnings found",
"items": {
"type": "object",
"properties": {
"file": {"type": "string"},
"line": {"type": "integer"},
"column": {"type": "integer"},
"message": {"type": "string"},
},
},
},
"passed": {
"type": "boolean",
"description": "Whether linting passed",
},
"linter": {
"type": "string",
"description": "Name of the linter used",
},
"target_path": {
"type": "string",
"description": "Path that was linted",
},
"error_count": {
"type": "integer",
"description": "Number of errors found",
},
"warning_count": {
"type": "integer",
"description": "Number of warnings found",
},
},
},
capabilities=ToolCapability(read_only=True),
handler=_handle_run_linter,
timeout=120,
)
ALL_DEVOPS_TOOLS: list[ToolSpec] = [
RUN_LINTER_SPEC,
]