plugins
This guide explains how to create **third-party tool plugins** for Lintro. A plugin is a normal Python package published to PyPI (or installed from anywhere) that adds one or
Creating Lintro Plugins
This guide explains how to create third-party tool plugins for Lintro. A plugin is a normal Python package published to PyPI (or installed from anywhere) that adds one or more tools to Lintro without any change to the Lintro core repository.
Overview
Lintro uses a plugin architecture that lets you add support for new linting and
formatting tools. Every built-in tool is one package under lintro/tools/<tool>/;
external tools ship in their own distributions and are discovered automatically at
startup via Python entry points in the lintro.tools group.
A built-in tool package looks like this (#2311):
lintro/tools/<tool>/
├── __init__.py # re-exports the plugin and the helpers other packages use
├── definition.py # @register_tool plugin class + its ToolDefinition
└── ... # command builders, executors, per-tool processing
ruff and pytest are the worked examples. Plugin discovery enters each package
through its definition module — importing it runs the package __init__, so the
package’s own re-export surface comes along — which is what registers the tool. There is
no central list to append to.
One exception: a shared helper package with no definition.py registers nothing and is
indexed by its public modules instead. lintro/tools/ts_checker/ is the only such
package today; it holds the base, command and execution helpers that tsc and vue_tsc
share, and it still appears in the duplicate-code gate scope.
An external plugin gets the exact same lifecycle as a built-in tool: config injection, file discovery, subprocess execution, output normalization, and per-invocation execution isolation.
Security note: Installing a Lintro plugin means running its code. A plugin is ordinary Python and executes with your privileges the moment it is discovered. This is the same trust model as installing any
pippackage — only install plugins from sources you trust.
Entry Point Registration
Register your plugin in the installing package’s pyproject.toml. The entry-point
name is only a label; the actual tool name comes from the plugin’s
ToolDefinition.name.
[project.entry-points."lintro.tools"]
my-tool = "my_package.plugin:MyToolPlugin"
The value points to the plugin class (module:ClassName).
Plugins packaged against the previously documented lintro.plugins group are still
discovered for backward compatibility, with a deprecation warning logged at startup.
Update existing packages to the lintro.tools group.
Plugin API Version
The plugin-facing contract is versioned so that core refactors never silently break — or
crash — an installed plugin. The current version is exposed as
lintro.plugins.LINTRO_PLUGIN_API_VERSION.
Declare the version your plugin targets as a class attribute:
from lintro.plugins import LINTRO_PLUGIN_API_VERSION
class MyToolPlugin(BaseToolPlugin):
LINTRO_PLUGIN_API_VERSION = LINTRO_PLUGIN_API_VERSION
...
At load time Lintro compares this against its own version. A plugin built for an incompatible major version is logged and skipped, never loaded. Declaring the attribute is optional (an undeclared plugin is assumed compatible) but strongly recommended for forward safety.
Failure Isolation
Discovery is fully fault-tolerant. A plugin that fails to import, is malformed (missing the required methods), declares an incompatible API version, collides with a built-in tool name, or raises on construction is logged as a warning and skipped. One broken plugin never crashes Lintro and never blocks discovery of the other plugins. Built-in tools always win a name collision, so an external plugin can never silently shadow a curated core tool.
Seeing Where a Tool Came From
Run lintro list-tools to see every registered tool with an Origin column:
builtin for core tools, or the distribution/package name for a third-party plugin. The
same field is present in lintro list-tools --json as "origin".
Plugin Implementation
Create a plugin class that inherits from BaseToolPlugin:
Do not use
@register_toolin a third-party plugin. That decorator is for built-in tools, which are imported eagerly. External plugins are registered by the entry-point loader — decorating would attempt a second (duplicate) registration.
from dataclasses import dataclass
from lintro.enums.capability import Cap
from lintro.enums.tool_type import ToolType
from lintro.models.core.claim import Claim
from lintro.models.core.tool_result import ToolResult
from lintro.plugins import LINTRO_PLUGIN_API_VERSION
from lintro.plugins.base import BaseToolPlugin
from lintro.plugins.protocol import ToolDefinition
@dataclass
class MyToolPlugin(BaseToolPlugin):
"""My custom linting tool plugin."""
# Declare the plugin API version this plugin was built against.
LINTRO_PLUGIN_API_VERSION = LINTRO_PLUGIN_API_VERSION
@property
def definition(self) -> ToolDefinition:
"""Return the tool definition."""
return ToolDefinition(
name="my-tool",
description="My custom linting tool",
can_fix=False, # Set to True if tool can auto-fix issues
tool_type=ToolType.LINTER, # LINTER, FORMATTER, or SECURITY
file_patterns=["*.py"], # Glob patterns for files to check
claims=[ # What the tool touches and what it does to it
Claim(patterns=["*.py"], capabilities={Cap.CHECK}),
],
reads_tree=True, # Must run after mutating tools settle
partitionable=False, # True if the file set can be sharded
native_configs=["pyproject.toml", ".mytool.yaml"], # Config files
version_command=["my-tool", "--version"], # Command to get version
min_version="1.0.0", # Minimum supported version
default_options={
"timeout": 30,
# Add tool-specific options here
},
default_timeout=30,
)
def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
"""Check files with the tool.
Args:
paths: List of file or directory paths to check.
options: Runtime options that override defaults.
Returns:
ToolResult with check results.
"""
# Use prepare() for common setup (version check, file discovery). It
# returns the finished ToolResult when execution must stop early.
ctx = self.prepare(paths, options)
if isinstance(ctx, ToolResult):
return ctx
# Build and run the tool command
cmd = ["my-tool", "check"] + ctx.rel_files
success, output = self._run_subprocess(cmd, timeout=ctx.timeout, cwd=ctx.cwd)
# Parse output into issues (create a parser in lintro/parsers/)
issues = parse_my_tool_output(output)
return ToolResult(
name=self.definition.name,
# success=True means the check passed (tool ran AND no issues found)
# If you want success to only reflect tool execution, use just `success`
success=success and len(issues) == 0,
output=output if not success else None,
issues_count=len(issues),
issues=issues,
)
def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
"""Fix issues in files (optional - only if can_fix=True).
Args:
paths: List of file or directory paths to fix.
options: Runtime options that override defaults.
Returns:
ToolResult with fix results.
"""
# Similar to check() but runs fix command
raise NotImplementedError("This tool does not support auto-fixing.")
Key Components
ToolDefinition
The ToolDefinition dataclass defines your tool’s metadata:
| Field | Type | Description |
|---|---|---|
name | str | Unique tool identifier |
description | str | Brief description |
can_fix | bool | Whether tool supports auto-fixing |
tool_type | ToolType | LINTER, FORMATTER, or SECURITY |
file_patterns | list[str] | Glob patterns for target files |
claims | list[Claim] | Patterns plus the capabilities applied to them |
reads_tree | bool | Reads the working tree, so runs after mutation |
partitionable | bool | File set may be sharded without changing the verdict |
native_configs | list[str] | Config file names |
version_command | list[str] | Command to check version |
min_version | str | Minimum supported version |
default_options | dict | Default tool options |
default_timeout | int | Default timeout in seconds |
Claims and capabilities
claims declares what a tool touches and what it does to it. It is the only
input to execution ordering: the scalar priority integer it replaced, and the unused
conflicts_with list, were deleted in #1742. A Claim pairs glob patterns with a set
of Cap values:
| Capability | Meaning |
|---|---|
Cap.FIX | Rewrites a file to remove diagnostics (ruff check --fix) |
Cap.FORMAT | Rewrites a file to a canonical layout (black) |
Cap.CHECK | Reports diagnostics only; never mutates |
There is deliberately no LINT or ANALYZE: both would be synonyms for CHECK
distinguished only by analysis depth, which is not a scheduling input. Depth is carried
by the two orthogonal scope booleans instead:
reads_tree— must the tool run after mutation settles? False only for a tool that reads something other than the working tree (commitlint reads git commit messages), which is therefore unordered and may run first.partitionable— can its file set be sharded or narrowed without changing its verdict? False for project-scoped analysis such as mypy, pylint’s cross-module checks, import-linter contracts and dependency audits.
Ordering per pattern is FIX → FORMAT → CHECK, and at most one tool may hold
FORMAT for a given pattern. This derivation is execution order (issue #1742):
there is no tool_order strategy and no DEFAULT_TOOL_PRIORITIES table to fall back
on, and parallel batching reads the same graph. Run lintro check --explain-order to
see the order a run would use with the claim behind each constraint, or lintro doctor
for the summary. See
Tool Ordering Configuration in the
configuration guide.
ToolResult
The ToolResult dataclass represents execution results:
| Field | Type | Description |
|---|---|---|
name | str | Tool name |
success | bool | Whether execution succeeded |
output | str | None | Raw output (errors/warnings) |
issues_count | int | Number of issues found |
issues | list[BaseIssue] | None | Parsed issues |
BaseToolPlugin Helpers
The BaseToolPlugin base class provides useful methods:
prepare(paths, options)- Common setup (version check, file discovery); returns anExecutionContext, or aToolResultto return as-is when execution stops early_run_subprocess(cmd, timeout, cwd)- Run tool command safely_get_executable_command(tool_name)- Get command with proper path_discover_files(paths, patterns)- Find files matching patterns
Per-file check runs
Tools that lint one file at a time should not write their own loop either. Call
lintro.tools.core.check_runner.run_per_file_check() from check() with the command
builder and the parser; it runs the command per file, turns a timeout or an OS error
into a per-file failure, and aggregates every issue into a single ToolResult.
from lintro.tools.core.check_runner import PerFileCheckPolicy, run_per_file_check
def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
ctx = self.prepare(paths, options)
if isinstance(ctx, ToolResult):
return ctx
return run_per_file_check(
ctx,
plugin=self,
command=lambda f: [*self._build_command(), str(f)],
parse=lambda output: parse_mytool_output(output=output),
)
PerFileCheckPolicy is optional and carries the only two classification choices the
runner cannot infer: issues_imply_failure marks a file unsuccessful when the parser
found issues even though the command exited zero, and failure_message records an
execution error when the command exits non-zero without producing a parseable issue
(leave it None when the tool’s exit status is a reliable verdict on its own). label
renames the progress bar.
Per-file fix runs
Tools that fix one file at a time should not write their own loop. Call
lintro.tools.core.fix_runner.run_per_file_fix() from fix() with the two command
builders, the parser and a PerFileFixPolicy; it runs check -> fix -> optional
verification per file and aggregates the initial/fixed/remaining counts into a single
ToolResult.
from lintro.tools.core.fix_runner import (
PerFileFixPolicy,
VerifyMode,
run_per_file_fix,
)
def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
ctx = self.prepare(paths, options, no_files_message="No files to format.")
if isinstance(ctx, ToolResult):
return ctx
return run_per_file_fix(
ctx,
plugin=self,
check_command=self._diff_command,
fix_command=self._write_command,
parse=lambda output: parse_mytool_output(output=output),
policy=PerFileFixPolicy(
check_failure_message="mytool check failed before fix",
verify=VerifyMode.AFTER_SUCCESS,
verify_failure_message="mytool recheck failed",
),
)
VerifyMode picks how surviving issues are counted. NEVER trusts the fix command’s
exit status and is what every built-in uses: declare CHECK in the tool’s claim and the
run-level verify pass measures the residual once, after every mutating tool has run,
discarding the tool’s own post-fix opinion (#2607). AFTER_SUCCESS re-checks each file
after a clean fix and is for an external plugin that declares no CHECK claim, since
nothing else will measure its residual. There is no mode that re-checks after a failed
fix: a failed fix reports every initial issue as remaining.
Both runners classify a single check-style invocation through the same
check_runner.check_one_file() step, so a timeout, an execution error and a parser
failure are reported identically whether the tool is checking or fixing. A non-zero exit
that produced no parseable issue is the one outcome the two sides can differ on: it
becomes an execution error only when a message is configured, and failure_message is
optional on the check side while check_failure_message is mandatory on the fix side.
Batch check and fix runs
Tools that hand their whole file list to one invocation use
lintro.tools.core.batch_runner instead. run_batch_check() runs the command once,
parses it and classifies the outcome; run_batch_fix() runs check -> fix -> re-check
and scores the difference.
from lintro.tools.core.batch_runner import (
BatchCheckPolicy,
BatchCommands,
BatchFixPolicy,
BatchSuccess,
run_batch_check,
run_batch_fix,
)
def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
ctx = self.prepare(paths, options)
if isinstance(ctx, ToolResult):
return ctx
return run_batch_check(
ctx,
plugin=self,
cmd=[*self._build_command(), *ctx.rel_files],
parse=lambda output: parse_mytool_output(output=output),
policy=BatchCheckPolicy(
success=BatchSuccess.ISSUES_ONLY,
report_cwd=True,
),
cwd=ctx.cwd,
)
BatchCheckPolicy carries the two classification choices. BatchSuccess says what the
verdict is derived from: ISSUES_ONLY for a tool that exits non-zero purely to report
findings, EXIT_STATUS for one whose exit code is the whole verdict, and
EXIT_AND_ISSUES (the default) when both must be clean. BatchOutput says when the raw
output is surfaced — NEVER, ON_FAILURE (the default),
ON_EXIT_FAILURE_WITHOUT_ISSUES for tools where unparseable output is the only sign of
a compilation or config error, and ON_ISSUES_OR_EXIT_FAILURE. Both policies also carry
tool_name (the name timeout messages use when it differs from the registered one) and
report_cwd (whether the working directory is recorded on the ToolResult, which tools
emitting issue paths relative to it need).
run_batch_fix() takes both fully built command lines as a
BatchCommands(check=..., fix=...) bundle — the check command runs twice, once before
the fix and once to score it — plus a BatchFixPolicy holding the wording of the
summary (fixed_label, all_fixed_message, verbose_output_label) and two reporting
switches: report_initial_issues prefixes ToolResult.issues with the pre-fix set for
tools that render a two-table view, and always_report_initial_issues passes an empty
list rather than None when nothing was detected.
Both entry points take on_timeout and on_error hooks. Leave on_timeout out to get
the standard batch_timeout_result() / batch_fix_timeout_result() shape, and pass it
only when the tool has its own timeout message. Leave on_error out to let a launch
failure propagate. Those two result builders are exported on their own, so a tool whose
middle section is bespoke — a missing-config skip, a per-module loop, a dependency-error
hint — can still share the timeout and result-construction shapes without adopting the
whole runner.
Ecosystem preconditions
Two families of tools cannot run from the directory lintro discovered their files in.
lintro.tools.core carries the shared preconditions so each definition states the
requirement rather than reimplementing it.
Cargo workspaces. cargo clippy, cargo fmt and cargo deny must be launched from
a directory that owns a Cargo.toml, but lintro hands the plugin whatever its file
patterns matched — *.rs for clippy and rustfmt, Cargo.toml and deny.toml for
cargo-deny. find_cargo_root() walks every path upward to the nearest manifest and
reconciles the results: one package wins outright, several fall back to their common
ancestor and only if that ancestor owns a Cargo.toml of its own.
from lintro.tools.core.cargo import find_cargo_root
cargo_root = find_cargo_root(ctx.files, tool_label="rustfmt")
if cargo_root is None:
return ToolResult(name=self.definition.name, success=True, output="...", issues_count=0)
tool_label is optional and affects logging only: pass it to explain an unresolvable
multi-package layout to the user, leave it out to fail silently and let the caller emit
its own skip message.
Node dependencies. astro-check and svelte-check ship inside the project they
lint, so neither exists until node_modules is populated. ensure_node_modules() makes
the three-way decision — skip on a read-only directory, install when the user passed
--auto-install, otherwise skip with the instruction to pass it — and returns the skip
ToolResult for the caller to return, or None when the tool may proceed.
from lintro.tools.core.node_modules import ensure_node_modules
skip_result = ensure_node_modules(
plugin=self,
cwd=cwd_path,
auto_install=bool(merged_options.get("auto_install", False)),
tool_label="astro-check",
)
if skip_result is not None:
return skip_result
tool_label is the human-facing tool name; it names the tool in the log lines and in
the Skipping <tool>: ... messages, so it is the spelling users see rather than the
registered snake_case name. The one outcome it does not reach is the plain
missing-node_modules skip: that path logs nothing and returns the unprefixed
node_modules not found. Use --auto-install to install dependencies.
Execution Isolation (important for correctness)
Registered plugin instances are process-wide singletons with mutable option state.
Lintro’s parallel executor never mutates that singleton directly — it takes a private
per-invocation copy via copy_for_execution() so concurrent runs cannot clobber each
other’s options. Subclassing BaseToolPlugin gives you this for free.
If your plugin adds its own mutable option state (for example a config dataclass
that set_options() mutates), you must isolate it too by overriding
_isolate_execution_state() (deep-copy it onto the execution copy) and
_reset_execution_state() (restore defaults). Otherwise concurrent invocations will
race on that shared state. Read-mostly caches may stay shared.
Creating a Parser
Create a parser module to convert tool output into structured issues:
# lintro/parsers/my_tool/my_tool_parser.py
import re
from lintro.parsers.base_issue import BaseIssue
class MyToolIssue(BaseIssue):
"""Issue class for my-tool output."""
pass # Inherits all fields from BaseIssue
def parse_my_tool_output(output: str) -> list[MyToolIssue]:
"""Parse my-tool output into issues.
Assumes output format: filename:line:column: level: message [CODE]
Args:
output: Raw tool output.
Returns:
List of parsed issues.
"""
issues: list[MyToolIssue] = []
if not output.strip():
return issues
# Pattern for: file:line:col: level: message [CODE]
pattern = re.compile(
r"^(.+?):(\d+):(\d+):\s*(error|warning|info):\s*(.+?)\s*\[(\w+)\]$"
)
for line in output.splitlines():
line = line.strip()
if not line:
continue
match = pattern.match(line)
if match:
file, line_num, col, level, message, code = match.groups()
issues.append(
MyToolIssue(
file=file,
line=int(line_num),
column=int(col),
message=message,
code=code,
level=level,
)
)
return issues
Packaging Checklist
A minimal third-party plugin distribution contains:
my_package/plugin.py— aBaseToolPluginsubclass (see above).my_package/parser.py— an output parser (see below).pyproject.toml— with a[project.entry-points."lintro.tools"]entry pointing at your plugin class, andlintroas a dependency.
Testing Your Plugin
- Install your plugin package (
pip install ./uv pip install .). - Run
lintro list-toolsand confirm your tool appears with your package name in the Origin column. - Run
lintro check --tools my-tool path/to/filesto test.
Example Plugins
See the built-in plugins under lintro/tools/ for complete examples:
lintro/tools/ruff/definition.py- Python linter with fix supportlintro/tools/bandit/definition.py- Security scanner (no fix)lintro/tools/prettier/definition.py- JavaScript/TypeScript formatterlintro/tools/hadolint/definition.py- Dockerfile linter