A linter for Terramate configuration. It finds things that are valid, and therefore not reported by Terramate, but are almost always a mistake or dead code.
$ tm-lint .
config.tm.hcl:16:3: [unused-global] global.environments is never used
stacks/a/stack.tm.hcl:3:33: [invalid-stack-ref] stack.after entry "/stacks/nope" does not exist (Terramate only warns at run time and ignores it)
stacks/d/stack.tm.hcl:21:21: [undefined-global] global.regoin is not defined anywhere, the tm_try/tm_can fallback is always used, did you mean global.region?
stacks/d/stack.tm.hcl:6:5: [unused-let] let.unused is never used in generate_hcl "d.tf"
Exit codes: 0 = no findings, 1 = findings, 2 = error (e.g. a parse error).
go install github.com/kaiehrhardt/tm-lint@latestOr as a container image (linux/amd64 and linux/arm64), with the project mounted at /work:
docker run --rm -v "$PWD:/work" ghcr.io/kaiehrhardt/tm-lint:latestImages are tagged with the full version (1.2.3), 1.2, 1 and latest. tm-lint --version prints the version.
Dependencies: Terramate's HCL fork (github.com/terramate-io/hcl/v2, the same version Terramate itself uses), go-cty, and cobra / viper for the CLI.
tm-lint [path ...] [flags] # path defaults to the current directory| Flag | Environment variable | Description |
|---|---|---|
--root path |
TM_LINT_ROOT |
Terramate project root (default: detected, see below) |
--enable rule,... |
TM_LINT_ENABLE |
run only these rules (default: all) |
--disable rule,... |
TM_LINT_DISABLE |
skip these rules |
--ignore-globals pattern,... |
TM_LINT_IGNORE_GLOBALS |
globals ignored by unused-global/shadowed-global/undefined-global, e.g. global.ci.* |
--ignore-file path |
TM_LINT_IGNORE_FILE |
ignore file (default: .tmlintignore in the project root, if present) |
--list-rules |
TM_LINT_LIST_RULES |
print the available rules and exit |
--format text|json|sarif|gitlab |
TM_LINT_FORMAT |
output format (default: text) |
[path ...] |
TM_LINT_PATHS |
paths whose findings are reported (default: current directory) |
- Lists: list flags accept comma-separated values or can be repeated (
--enable a,bor--enable a --enable b). In environment variables, lists are comma- or space-separated. - Precedence: a flag wins over its environment variable, and positional paths win over
TM_LINT_PATHS. - Output: file paths are relative to the current working directory.
- Shell completion:
tm-lint completion bash|zsh|fish|powershellprints a completion script. Becausecompletionandhelpare subcommands, pass a folder with one of these names as./completion.
# CI: the same configuration via environment variables
export TM_LINT_DISABLE=unused-let
export TM_LINT_IGNORE_GLOBALS="global.ci.* global.tags"
tm-lint stacks/prod-
text(default):path:line:column: [rule] message, for editors and terminals. -
json: an array of findings,[]when there are none, each withrule,file,line,column,endLine,endColumnandmessage. -
sarif: a SARIF 2.1.0 log with one run, for tools that consume it, e.g. GitHub code scanning:tm-lint --format sarif . > tm-lint.sarif
-
gitlab: a GitLab Code Quality report (every finding reported as severitymajor; tm-lint has no severity levels):# .gitlab-ci.yml tm-lint: image: name: ghcr.io/kaiehrhardt/tm-lint:1 entrypoint: [""] script: - tm-lint --format gitlab . > gl-code-quality-report.json artifacts: reports: codequality: gl-code-quality-report.json
Exit codes are unaffected by --format: 1 on findings, 2 on errors, even for json/sarif/gitlab, so CI steps that only look at the exit code keep working. In the GitLab example above, script fails the job on findings; add || true to the tm-lint line if you want the report uploaded without failing the pipeline.
tm-lint always loads the whole project, so imports, globals from parent directories and stack references resolve exactly as when linting everything. The paths you pass only limit which findings are reported:
tm-lint # everything below the current directory
tm-lint stacks/prod stacks/stg # only these folders
tm-lint stacks/prod/stack.tm.hcl # only this file
cd stacks/prod && tm-lint # same as `tm-lint stacks/prod` from the rootA finding is reported if its file is inside one of the paths, or if the file is imported into one of them. For example, an unused global in /imports/common.tm.hcl shows up when linting a stack that imports it.
The project root is detected the same way Terramate does it: starting at the first path, tm-lint walks upwards to the nearest directory whose Terramate files contain terramate { required_version = ... }. Without such a directory, it uses the nearest git repository root, and failing that, the path itself. Use --root (or TM_LINT_ROOT) to set it explicitly. The .tmlintignore is always read from the project root.
There are two ways to suppress findings, and both can be combined.
A comment on the same line or the line above suppresses findings, either for all rules or only for the rules listed:
globals {
# tm-lint:ignore unused-global
ci_token = "..." # only read by `terramate get-config-value` in CI
}A .tmlintignore file in the project root is read automatically. Use --ignore-file (or TM_LINT_IGNORE_FILE) to point to a different file; an explicitly given file must exist.
One entry per line, # starts a comment:
<target> suppress all rules for target
<rule>[,<rule>...] <target> suppress only the listed rules
A target is either
- a path glob relative to the project root:
*matches within a directory,**spans directories, and a directory matches everything below it, or - a global path like
global.ci_tokenorglobal.ci.*(or justglobalfor every global), which applies to the findings ofunused-globalandundefined-global.
# Legacy stacks that are being decommissioned.
stacks/legacy
# Read by CI via `terramate get-config-value`.
unused-global global.ci.*
# Generated components are checked upstream.
unused-let,undefined-global components/**/*.tm.hcl
Unknown rule names or invalid lines are an error (exit code 2), so typos in the ignore file do not go unnoticed. See testdata/example/.tmlintignore for a complete example.
A global that is defined but never used anywhere it would be visible.
- Definitions:
- attributes in
globalsblocks, including labels:globals "a" "b" { c = 1 }definesglobal.a.b.c mapblocks- empty labeled blocks
- attributes in
- Uses: every
global.*reference in any Terramate expression. - Paths:
global.a(the whole object) counts as a use ofglobal.a.b.global["x"]is treated likeglobal.x.- Dynamic access such as
global.a[var]counts for everything belowglobal.a.
- Hierarchy: the definition and the use must be on the same branch of the directory tree.
- Globals are inherited downwards.
- Expressions in parent directories are evaluated in the context of every stack below them.
- A global used only by a sibling stack therefore counts as unused.
- Imports: the content of an imported file also applies in every directory importing it, transitively.
- Self-references: an override like
region = "${global.region}-c"counts as a use of the parent value, not as a use of itself.
A global that is read somewhere, but every read resolves to a more specific override defined closer to it, so this particular definition's value is never actually used. Typically a root default that every stack below it overrides:
# globals.tm.hcl
globals {
region = "eu-west-1" # shadowed-global: every stack below overrides it
}# stacks/a/stack.tm.hcl
globals {
region = "us-east-1" # this is the value generate_hcl below actually reads
}
generate_hcl "main.tf" {
content {
region = global.region
}
}- Only the override must use the exact same global path; overriding just one field of an object (
net.cidr) does not shadow a sibling field (net.other). - Only the common direction is checked: a definition read from its own directory or below. A global read from one of its own definition's ancestors (an expression in a parent directory, inherited back down into a descendant stack) is conservatively treated as used, without checking whether that read is itself shadowed.
- As soon as at least one reachable read is not shadowed (e.g. one stack does not override), the definition counts as used.
A global that is referenced but not defined anywhere it would be visible. The same hierarchy and import rules apply as for unused-global. If a similarly named global exists, the finding includes a "did you mean" suggestion.
References inside tm_try(...)/tm_can(...) are usually the "optional value with a default" idiom, e.g. tm_try(global.app.replicas, 1). They are only reported
- when nothing at all is defined under the root name (the fallback is then always used), or
- when the missing name looks like a typo of a defined sibling, e.g.
tm_try(global.net.cidrr, …)next toglobal.net.cidr.
Entries in stack.after, before, wants and wanted_by that match no stack. For paths that do not exist, Terramate only prints a warning to stderr at run time. Paths without stacks and tag filters without matches are ignored completely silently.
The rule reports:
- paths that do not exist or are not a directory,
- directories with no stacks below them,
- a stack referencing itself,
tag:filters with tags no stack has (typos),tag:filters that match no stack.
The filter syntax is the same as Terramate's: , = OR, : = AND, ~ = NOT.
A let in a lets block (e.g. in generate_hcl/generate_file) that is never used in the surrounding block. Use by another let counts, use in commented-out code does not.
- No full validation: the tool only parses files, it does not load the complete Terramate configuration. It complements
terramate fmt --checkandterramate generatefollowed bygit diff --exit-code, it does not replace them. - Other file types: references outside
.tm/.tm.hclfiles are not detected, e.g.terramate get-config-valuein shell scripts. Use the suppression options for those. - Shadowing:
shadowed-globalonly catches an override with the exact same global path, and only checks the "defined above, read at or below" direction; see its section above for both. - Hierarchy in
undefined-global: a global used in a parentgenerate_hclbut defined in only some of the stacks below counts as defined. - Non-literal stack attributes:
stackattributes that are not literal lists are skipped.
The layout follows the official Go recommendation for a command with supporting packages: the command lives in the module root, so go install github.com/kaiehrhardt/tm-lint@latest works, and everything else is in internal/.
.
├── main.go, cli.go command: cobra/viper CLI, flag wiring
├── format.go output formatting: text, json, sarif, gitlab
├── *_test.go CLI and end-to-end tests against testdata/
├── testdata/ example project and its expected output
├── Dockerfile release image, built by .github/workflows/release.yml
└── internal/
├── project/ loads a Terramate project: files, imports, stacks,
│ evaluation contexts, root detection, scopes
├── lint/ rules, findings, ignore file, inline suppression
└── hclutil/ small helpers for the HCL syntax tree
go test ./... # all tests
go test -update . # rewrite testdata/example.expected after intentional changes- Pull requests (
.github/workflows/ci.yml): gofmt,go mod tidy -diff,go vet,go test -race, and a multi-arch image build without pushing. - Releases (
.github/workflows/release.yml): every push tomainruns the same checks, then semantic-release derives the next version from the commit messages since the last tag, creates thevX.Y.Ztag and GitHub release, and pushes the image toghcr.io/kaiehrhardt/tm-lint.
Commit messages therefore follow Conventional Commits: fix: makes a patch release, feat: a minor release, a BREAKING CHANGE: footer (or feat!:) a major release; docs:, chore:, test: etc. release nothing. With squash merges, the PR title becomes that commit message.
A rule is a function func(c *checker) []Finding in internal/lint, registered in Rules in internal/lint/lint.go. The checker gives access to:
- the project (
c.p): parsed files (Files,Bodies,Sources), stacks with their tags (Stacks), and the evaluation contexts of a file, taking imports into account (Contexts), - the options (
c.opts), - the global index (
c.globals()), built once per run.
Add a unit test next to the rule, and a case to testdata/example, then run go test -update ..