Search
K

Custom Linters

The beautiful thing about trunk is that you can leverage our caching and hold-the-line solution with your own custom linters; you just need to tell us how to interpret the results from your linter.
Trunk currently supports the following types of additional/proprietary linters:
Linter Type
Autofix support
Description
sarif
Produces diagnostics as Static Analysis Results Interchange Format JSON.
lsp_json
Produces diagnostics as Language Server Protocol JSON.
pass_fail
Writes a single file-level diagnostic to stdout.
regex
Produces diagnostics using a custom regex format.
arcanist
Produces diagnostics as Arcanist JSON.
rewrite
Writes the formatted version of a file to stdout.
If your linter produces a different output type, you can also write a parser to transform the linter's output into something trunk can understand.
To set up a custom linter, add it to trunk.yaml under lint > definitions and enable it:
lint:
definitions:
- name: foo
files: [ALL]
commands:
- output: sarif
success_codes: [0, 1]
run: ${workspace}/bin/foo --file ${target}
read_output_from: stdout
enabled:
# ...
- foo@SYSTEM
The @SYSTEM is a special identifier that indicates that we will forward the PATH environment variable to the custom linter when we invoke it.
Every custom linter must specify a name, the types of files it will run on, at least one command, and success_codes or error_codes.
Info: Entries in enabled must specify both a linter name and a version. If you commit your linter into your repository, you should simply use @SYSTEM, which will run the linter with your shell's PATH. If you have a versioned release pipeline for your linter, though, you'll want to define your custom linter using a download and specify the download version to use.

Execution Model

Running trunk check tells trunk to do the following:
  • compute the set of modified files (by comparing the current working tree and upstream-ref, usually your main or master branch)
  • compute the set of lint actions to run based on the modified files
    • each enabled linter is invoked once per applicable modified file (details); for example, if pylint and flake8 are enabled, they will both be run on every modified python file but not on any modified markdown files
    • every lint action also will have a corresponding upstream lint action, i.e. the linter will also be run on the upstream version of the file, so that we can determine which issues already exist in your repository
  • execute uncached lint actions
  • determine which lint issues are new, existing, or fixed

Linter Types

SARIF

output: sarif linters produce diagnostics in the Static Analysis Results Interchange Format:
{
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [
{
"results": [
{
"level": "warning",
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "/dev/shm/sandbox/detekt_test_repo/example.kt"
},
"region": {
"startColumn": 12,
"startLine": 18
}
}
}
],
"message": {
"text": "A class should always override hashCode when overriding equals and the other way around."
},
"ruleId": "detekt.potential-bugs.EqualsWithHashCodeExist"
}
],
"tool": {
"driver": {
"downloadUri": "https://github.com/detekt/detekt/releases/download/v1.19.0/detekt",
"fullName": "detekt",
"guid": "022ca8c2-f6a2-4c95-b107-bb72c43263f3",
"informationUri": "https://detekt.github.io/detekt",
"language": "en",
"name": "detekt",
"organization": "detekt",
"semanticVersion": "1.19.0",
"version": "1.19.0"
}
}
}
]
}

LSP JSON

output: lsp_json linters output issues as Language Server Protocol JSON.
[
{
"message": "Not formatted correctly. Missing owner",
"code": "missing-owner",
"severity": "Error",
"range": {
"start": {
"line": 12,
"character": 8
},
"end": {
"line": 12,
"character": 12
}
}
},
{
"message": "TODO is assigned to someone not listed in this project",
"code": "unknown-user",
"severity": "Warning",
"range": {
"start": {
"line": 37,
"character": 0
},
"end": {
"line": 37,
"character": 14
}
}
}
]

Pass/Fail Linters

output: pass_fail linters find either:
  • no issues in a file, indicated by exiting with exit_code=0, or
  • a single file-level issue in a file, whose message is the linter's stdout, indicated by exiting with exit_code=1.
Note: Exiting with exit_code=1 but writing nothing to stdout is considered to be a linter tool failure.
Note: pass_fail linters are required to have success_codes: [0, 1]

Regex

output: regex linters produce output that can be parsed with custom regular expressions and named capture groups. The regular expression is specified in the parse_regex field.
regex supports capturing strings from a linter output for the following named capture groups:
  • path: file path (required)
  • line: line number
  • col: column number
  • severity: one of allow, deny, disabled, error, info, warning
  • code: linter diagnostic code
  • message: description
For example, the output
.trunk/trunk.yaml:7:81: [error] line too long (82 > 80 characters) (line-length)
can be parsed with the regular expression
((?P<path>.*):(?P<line>\d+):(?P<col>\d+): \[(?P<severity>.*)\] (?P<message>.*) \((?P<code>.*)\))
and would result in a trunk diagnostic that looks like this:
7:81 high line too long (82 > 80 characters) regex-linter/line-length
In the event that multiple capture groups of the same name are specified, the nonempty capture will be preferred. If there are multiple non-empty captures, a linter error will be thrown. Adjust your regular expression accordingly to match the specifics of your output.
Note: For additional information on building custom regular expressions, see re2. More complicated regex may require additional escape characters in yaml configuration.

Arcanist

You can also output JSON using the Arcanist format.
[
{
"Char": 1,
"Code": "missing_copyright",
"Description": "Message about things\nMaybe contain multiple lines and web\nlinks\nhttps://website.com/notice-about-stuff\n",
"Line": 1,
"Name": "Incorrect (or missing) copyright notice",
"OriginalText": "",
"Path": "somefile.py"
}
]

Formatters

output: rewrite linters write the formatted version of a file to stdout; this becomes an autofix which trunk can prompt you to apply (which is what trunk check does by default) or automatically apply for you (if you trunk check --fix or trunk fmt).
For example, if you wanted a linter to normalize your line endings, you could do this:
lint:
definitions:
- name: no-carriage-returns
files: [ALL]
commands:
- output: rewrite
formatter: true
command: sed s/\r// ${target}
success_codes: [0]
Setting formatter: true will cause trunk fmt to run this linter.

Configuration Options

When defining a custom linter, trunk not only needs to know the output of the linter command but also details such as how to invoke it, how to specify the file to check, and more; the next few sections explain what options you use to configure these and how:
For even more details, you can refer to the JSON schema for trunk.yaml.

Applicable filetypes

To determine which linters to run on which files (i.e. compute the set of lint actions), trunk requires that every linter define the set of filetypes it applies to in files.
We have a number of pre-defined filetypes (e.g. c++-header, gemspec, rust; see our configuration schema for an up-to-date list), but you can also define your own filetypes. Here's how we define the python filetype:
lint:
files:
- name: python
extensions:
- py
- py2
- py3
shebangs:
- python
- python3
This tells trunk that files matching either of the following criteria should be considered python files:
  • the extension is any of .py, .py2, or .py3 (e.g. lib.py)
  • the shebang is any of python or python3 (e.g. #!/usr/bin/env python3)

Command

Once trunk has figured out which linters it will run on which files, trunk expands the template provided in the run field to determine the arguments it will invoke the linter with. Here's what that looks like for detekt, one of our Kotlin linters:
lint:
definitions:
- name: detekt
# ...
commands:
- output: sarif
run:
detekt-cli --build-upon-default-config --config .detekt.yaml --input ${target} --report
sarif:${tmpfile}
This command template contains all the information trunk needs to execute detekt in a way where trunk will be able to understand detekt's output.
Note that some of the fields in this command template contain ${} tokens: these tokens are why command is a template and are replaced at execution time with the value of that variable within the context of the lint action being executed.
Variable
Description
${workspace}
Path to the root of the repository
${target}
Path to the file to check, relative to ${workspace}
${linter}
Path to the directory the linter was downloaded to
${runtime}
Path to the directory the runtime (e.g. node) was downloaded to
${upstream-ref}
Upstream git commit that is being used to calculate new/existing/fixed issues

Input

The target field specifies what paths this linter will run on given an input file. It may be a literal such a . which will run the linter on the whole repository. It also supports various substitutions:
Variable
Description
${file}
The input file.
${parent}
The folder containing the file.
${parent_with(<name>)}
Walks up toward the repository root looking for the first folder containing <name>. If <name> is not found, do not run any linter.
${root_or_parent_with(<name>)}
Walks up toward the repository root looking for the first folder containing <name>. If <name> is not found, evaluate to the repository root.
If target is not specified it will default to ${file}.
This target may be referenced in the run field as ${target}.
lint:
definitions:
- name: noop
files: [ALL]
commands:
- output: rewrite
formatter: true
run: cat ${target}
or via stdin, by specifying stdin: true:
lint:
definitions:
- name: noop
files: [ALL]
commands:
- output: rewrite
formatter: true
run: cat -
stdin: true
Info: Linters that take their input via stdin may still want to know the file's path so that they can, say, generate >diagnostics with the file's path. In these cases you can still use ${target} in run.

Output

The output format that trunk expects from a linter is determined by its type.
stdout, stderr or tmp_file
trunk generally expects a linter to output its findings to stdout, but does support other output mechanisms:
read_output_from
Description
stdout
Standard output.
stderr
Standard error.
tmp_file
If ${tmpfile} was specified in command, the path of the created ${tmpfile}.
Exit codes
Linters often use different exit codes to categorize the outcome. For instance, markdownlint uses 0 to indicate that no issues were found, 1 to indicate that the tool ran successfully but issues were found, and 2, 3, and 4 for tool execution failures.
trunk supports specifying either success_codes or error_codes for a linter:
  • if success_codes are specified, trunk expects a successful linter invocation (which may or may not find issues) to return one of the specified success_codes;
  • if error_codes are specified, trunk expects a successful linter invocation to return any exit code which is not one of the specified error_codes.
markdownlint, for example, has success_codes: [0, 1] in its configuration.
Working directory
run_from determines what directory a linter command is run from.
run_from
Description
<path> (. by default)
Explicit path to run from
${parent}
Parent of the target file; e.g. would be foo/bar for foo/bar/hello.txt
${root_or_parent_with(<file>)}
Nearest parent directory containing the specified file
${root_or_parent_with_dir(<dir>)}
Nearest parent directory containing the specified directory
${root_or_parent_with_regex(<regex>)}
Nearest parent directory containing a file or directory matching specified regex
${target_directory}
Run the linter from the same directory as the target file, and change the target to be .
${compile_command}
Run from the directory where compile_commands.json is located
Environment variables
trunk by default runs linters without environment variables from the parent shell; however, most linters need at least some such variables to be set, so trunk allows specifying them using environment; for example, the environment for ktlint looks like this:
lint:
definitions:
name: ktlint
# ...
environment:
- name: PATH
list: ["${linter}"]
- name: LANG
value: en_US.UTF-8
Most environment entries are maps with name and value keys; these become name=value environment variables. For PATH, we allow specifying list, in which case we concatenate the entries with :.
We use the same template syntax for environment as we do for command.

Tools

You can use the tools section to specify trunk-configured binaries that the linter uses to run. The tools key should specify a list of strings referring to tool names. We have two kinds of tool dependencies - they are described in turn below. See the Tools Configuration page for more details on how to set up your tools.
This is the preferred way of defining and versioning a linter, as it also allows repo users to conveniently run the linter binary outside of the trunk check context.

Eponymous Tool Dependencies

Here is an example of where the tool matches the linter name:
tools:
definitions:
- name: pylint
runtime: python
package: pylint
shims: [pylint]
known_good_version: 2.11.1
lint:
definitions:
- name: pylint
files: [python]
commands:
- name: lint
# Custom parser type defined in the trunk cli to handle pylint's JSON output.
output: pylint
run: pylint --exit-zero --output ${tmpfile} --output-format json ${target}
success_codes: [0]
read_output_from: tmp_file
batch: true
cache_results: true
tools: [pylint]
suggest_if: config_present
direct_configs:
- pylintrc
- .pylintrc
affects_cache:
- pyproject.toml
- setup.cfg
issue_url_format: http://pylint-messages.wikidot.com/messages:{}
known_good_version: 2.11.1
version_command:
parse_regex: pylint ${semver}
run: pylint --version
In this case, the tool name (pylint) matches that of the linter, making it an eponymous tool. Eponymous tools need to be defined separately from the linter but implicitly enabled with the linter's version. You may explicitly enable the eponymous tool if you wish, but note that its version needs to be synced to that of the linter.

Additional Tool Dependencies

You can also have a scenario where a linter depends on a tool that is not identically named - an additional tool dependency. We give an example below:
tools:
definitions:
- name: terragrunt
known_good_version: 0.45.8
download: terragrunt
shims:
- name: terragrunt
target: terragrunt
lint:
definitions:
- name: terragrunt
tools: [terragrunt, terraform]
known_good_version: 0.45.8
files: [hcl]
suggest_if: never
environment:
- name: PATH
list: ["${linter}"]
commands:
- name: format
output: rewrite
run: terragrunt hclfmt ${target}
success_codes: [0]
sandbox_type: copy_targets
in_place: true
formatter: true
batch: true
version_command:
parse_regex: terragrunt v${semver}
run: terragrunt -version
In this scenario, terraform is an additional tool dependency - terragrunt requires it to be in $PATH. If the tool is an additional dependency, it must be enabled explicitly and versioned independently of the linter - that is, it must be listed in the tools.enabled section.

Downloads

(NOTE: This method of specifying linters is still supported, but using tools like specified above is recommended going forward)
If your custom linter has a separate release process (i.e. is not committed in your repo), then you can tell trunk how to download it like so:
lint:
downloads:
- name: lorem-linter
# the default version to download; overridden by the version in `enabled`
version: 4.0.1
executable: true
downloads:
- os: linux
cpu: x86_64
url: https://github.com/my-org/my-repo/releases/download/${version}/lorem-darwin-x86-64
- os: macos
cpu: x86_64
url: https://github.com/my-org/my-repo/releases/download/${version}/lorem-linux-x86-64
- name: ipsum-linter
# the default version to download; overridden by the version in `enabled`
version: 0.1.1
downloads:
- os: linux
cpu: x86_64
url: https://github.com/my-org/my-repo/releases/download/${version}/ipsum-darwin-x86-64.tar.gz
strip_components: 2
- os: macos
cpu: x86_64
url: https://github.com/my-org/my-repo/releases/download/${version}/ipsum-linux-x86-64.tar.gz
strip_components: 2
definitions:
- name: lorem-linter
files: [javascript, typescript]
download: lorem-linter
- name: ipsum-linter
files: [rust]
download: ipsum-linter
enabled:
This tells trunk that, for lorem-linter:
  • you want to run version 4.0.2 on javascript and typescript files,
  • it is available for macOS and Linux at the specified URLs (expanded by replacing ${version} with 4.0.2), and
  • the download consists of a single executable binary, since executable: true is set;
for ipsum-linter:
  • you want to run version 0.1.6 on rust files,
  • it is available for macOS and Linux at the specified URLs (expanded by replacing ${version} with 0.1.6), and
  • the download is a compressed archive, the linter binary is strip_components: 2 directories deep inside the uncompressed archive, and trunk should automatically extract and unpack the linter from the archive.

Download via package manager

If your linter can be downloaded via gem install, go get, npm install, or pip install, you can specify a runtime and the package key:
lint:
definitions:
- name: fizz-buzz
files: [javascript]
# npm install fizz-buzz
runtime: node
package: fizz-buzz
This will now create a hermetic directory in ~/.cache/trunk/linters/fizz-buzz and npm install fizz-buzz there. You can refer to different versions of your package in trunk.yaml as normal, via [email protected].
Note: Such downloads will use the hermetic version of the specified runtime that trunk installs, not the one >you've installed on your machine.

run_when

Lint sessions are always associated with exactly one of the following session types:
  • ci: triggered by a CI run, i.e. trunk check --ci or when running inside the GitHub action,
  • cli: triggered by a human or script running trunk check,
  • lsp: triggered by VSCode, or
  • monitor: triggered by background linting.
You can use run_when to specify which session types you want to run a linter in; for example, to always disable a linter during CI:
lint:
definitions:
- name: fizz-buzz
run_when: [cli, lsp, monitor]