Adding a New Tool to Lintro
Lintro is a unified CLI for code linting/formatting with a plugin architecture:
tools are defined in lintro/tools/definitions/<tool>.py (via @register_tool),
parsed by lintro/parsers/<tool>/, tested in tests/unit/, with sample violation
files in test_samples/tools/.
Related Skills
stand-py: Python coding standards (type hints, docstrings, trailing commas)test: pytest best practices (no classes, use fixtures, parametrize)commit: semantic commit format when committing changes
How to Implement: Copy a Reference, Don't Write From Scratch
Do NOT write plugin/parser/test code from a template. Pick the closest existing
implementation, read all of its files (definition, issue class, parser, parser
__init__.py, sample violation file, parser tests, plugin tests), and mirror
that structure exactly for the new tool:
- Simple tool (no fix):
lintro/tools/definitions/actionlint.py,hadolint.py - Tool with fix support:
lintro/tools/definitions/ruff.py,black.py - Security scanner:
lintro/tools/definitions/bandit.py,semgrep.py - Shell tools:
lintro/tools/definitions/shellcheck.py,shfmt.py
The parser lives in lintro/parsers/<reference-tool>/ and its tests in
tests/unit/parsers/ and tests/unit/tools/<reference-tool>/ — copy the pattern
from the same reference tool so imports, mocking, and naming stay consistent.
Quick Reference
New files (paths)
lintro/parsers/<tool>/__init__.py
lintro/parsers/<tool>/<tool>_issue.py
lintro/parsers/<tool>/<tool>_parser.py
lintro/tools/definitions/<tool>.py
test_samples/tools/<category>/<tool>/<tool>_violations.<ext>
tests/unit/parsers/test_<tool>_parser.py
tests/unit/tools/<tool>/__init__.py
tests/unit/tools/<tool>/test_<tool>_plugin.py
Updated files (paths)
lintro/enums/tool_name.py # Add to ToolName enum (alphabetical)
lintro/tools/core/version_parsing.py # Add to TOOLS_WITH_SIMPLE_VERSION_PATTERN
lintro/tools/core/version_checking.py # Add install hints in get_install_hints()
lintro/_tool_versions.py # Add version (binary/cargo/rustup tools only)
lintro/tools/manifest.src.json # Add tool entry (NO version key; generator renders manifest.json)
lintro/cli_utils/commands/doctor.py # Add to TOOL_COMMANDS for health check
package.json # Pin the npm package (the version source for npm tools)
renovate.json # Add a custom manager for _tool_versions.py (binary tools only)
pyproject.toml # Add parser package + [tool.lintro.versions] entry
Dockerfile # Add to verification steps (root AND non-root blocks)
Dockerfile.tools # Add to verification step (tool --version)
scripts/utils/install-tools.sh # Add installation command (external tools)
scripts/ci/homebrew/templates/lintro.rb.template # Add depends_on + update caveats (if Homebrew-installable)
For every updated file, find an existing tool's entry in that file and add the new tool the same way (alphabetical order where the file is ordered).
Version Consistency (CRITICAL for external tools)
For external tools (not bundled Python packages), versions must be consistent across:
lintro/_tool_versions.py— source of truth for binary/cargo/rustup tools (npm tools readpackage.json, bundled Python tools readpyproject.toml)lintro/tools/manifest.src.json— the hand-authored entry carries install metadata only, never aversionkey; the build-time generator renderslintro/tools/manifest.json(gitignored, not committed) with the version injected from the source above- Plugin
min_version— in the tool definition, should match or be <= the version source renovate.json— a custom regex manager updating_tool_versions.py(binary tools only; the rendered manifest follows via the generator)
There is no committed generated copy to drift (py-lintro #2176): the derived
artifacts are generated at package build time. Run just generate in the
py-lintro checkout after editing a version source to refresh the gitignored
working-tree copies.
Homebrew Formula (for Homebrew-installable tools)
- Add
depends_on "<tool>"toscripts/ci/homebrew/templates/lintro.rb.templateand list the tool in the caveats under the appropriate category. - Bundled Python tools (ruff, black, mypy, bandit, yamllint) are excluded from the
Homebrew venv via
generate_resources.py --exclude; they install as separate Homebrew formulae and are discovered via PATH (shutil.which), NOTpython -m.PythonBundledBuilderincommand_builders.pyhandles this automatically.
ToolType Options
ToolType.LINTER # Code quality checker
ToolType.FORMATTER # Code formatter
ToolType.TYPE_CHECKER # Type checking (mypy)
ToolType.DOCUMENTATION # Doc checker (darglint)
ToolType.SECURITY # Security scanner (bandit, semgrep, gitleaks)
ToolType.INFRASTRUCTURE # IaC linter (hadolint, actionlint)
ToolType.TEST_RUNNER # Test framework (pytest)
Can be combined: ToolType.LINTER | ToolType.FORMATTER
Common Gotchas
- Version command variations: some tools use
versioninstead of--version(e.g.,gitleaks version). Check the tool's CLI. - ToolResult invariant for fix operations:
initial_issues_count = fixed_issues_count + remaining_issues_count. - Test mocking: mock the version check with
patch("lintro.plugins.execution_preparation.verify_tool_version", return_value=None)and subprocess calls withpatch.object(plugin, "_run_subprocess", ...)— copy the patterns from a reference tool's plugin tests. - File discovery:
_prepare_execution()handles filtering byfile_patterns; usectx.rel_filesfor the filtered list. - Subprocess safety: always use list args, never
shell=True; add# nosec B404on the subprocess import. - Return early: if
ctx.should_skipis True, returnctx.early_result. - Parser function naming: must be
parse_<tool>_output(output: str | None) -> list[<Tool>Issue]. - Issue class: must inherit from
BaseIssue; useDISPLAY_FIELD_MAPfor custom field name mappings.
Deprecated Patterns to Avoid
- Do NOT create tool-specific formatters (use the unified formatter)
- Do NOT modify
lintro/tools/tool_enum.py(deleted — registry is automatic) - Do NOT modify
lintro/tools/core/tool_base.py(deleted — use BaseToolPlugin)
Verification Checklist
-
uv run lintro toolsshows the new tool -
uv run lintro check --tools <tool> .runs without error — single-tool runs are normally banned by thelintskill; this is the explicit sanctioned exception for verifying a tool under development. Still finish with a fulluv run lintro chk. -
uv run lintro doctorshows the tool with correct version (no "No cmd defined") - Tool detects violations in the sample file
- Parser unit tests pass:
pytest tests/unit/parsers/test_<tool>_parser.py -v - Plugin unit tests pass:
pytest tests/unit/tools/<tool>/ -v - Coverage >80% on new code
- No linting errors:
uv run lintro fmt && uv run lintro chk -
just generateruns cleanly (derived artifacts are gitignored, not committed) - Tool added to
Dockerfile(root and non-root blocks) andDockerfile.tools - Tool added to
install-tools.sh(external tools only) - Tool added to
lintro/tools/manifest.src.json(noversionkey) - Renovate manager added for
_tool_versions.py(binary tools only) - Homebrew template updated with
depends_on+ caveats (if Homebrew-installable) - Docker image builds:
docker build -t py-lintro:test .
Use the lintro-verify skill for the full post-implementation review.