hcl-linter 0.0.1-alpha is out
terraform fmt only touches whitespace. In a codebase with dozens of contributors and hundreds of HCL files, block order drifts, naming conventions split, and required fields go missing quietly. I wanted something that could catch and fix that automatically - so I built hcl-linter.
The first run fixed over 1000 terragrunt.hcl files.
getting started
Two commands to go from nothing to a first pass:
hcl-linter init # scaffold .hcl-linter/ from your existing files
hcl-linter fix ./ --dry-run # preview what would change
init walks the project, groups files by basename, and writes a .hcl-linter/default.hcl plus one extends = "default" override per unique filename. No config needed for a first formatting pass either - --format applies opinionated defaults without any config:
hcl-linter fix ./ --format --dry-run
block order
Wrong block order is the most common drift. block_order catches and auto-fixes it:
# violation - terraform before include
terraform {
source = "git::https://github.com/example/vpc.git"
}
include "root" {
path = find_in_parent_folders()
}
dependency "vpc" {
config_path = "../vpc"
}
After hcl-linter fix:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://github.com/example/vpc.git"
}
dependency "vpc" {
config_path = "../vpc"
}
Config:
rules {
block_order {
enabled = true
order = ["include", "locals", "terraform", "dependency", "inputs"]
}
}
naming
Hyphens in block labels break dependency.my-vpc.outputs.id references in locals. name_validation flags and auto-fixes them - hyphens replaced with underscores, references in locals updated too:
# violation
dependency "my-vpc" {
config_path = "../vpc"
}
dependency "db-primary" {
config_path = "../database"
}
rules {
name_validation {
enabled = true
pattern = "^[a-z][a-z0-9_]*$"
blocks = ["dependency", "include"]
}
}
required fields
required_fields catches missing attributes when a block is present and auto-adds them:
# violation - expose missing on include blocks
include "root" {
path = find_in_parent_folders()
}
include "vpc" {
path = "../vpc"
}
After fix:
include "root" {
path = find_in_parent_folders()
expose = true
}
include "vpc" {
path = "../vpc"
expose = true
}
rules {
required_fields {
include {
expose = true
}
}
}
count and for_each
count_for_each warns on patterns that silently disable resources - count = 0 or for_each = {} left behind after feature flags, and count + for_each used on the same block (Terraform rejects this at plan time):
resource "aws_instance" "web" {
count = 0 # WARNING: resource will not be created
ami = "ami-12345"
}
resource "aws_db_instance" "db" {
count = 1
for_each = var.db_configs # ERROR: cannot use both count and for_each
engine = "postgres"
}
Not fixable - count = 0 is sometimes intentional. The rule flags it so the intent is explicit.
rules {
count_for_each {
enabled = true
warn_on_count_zero = true
warn_on_empty_for_each = true
warn_on_conflict = true
}
}
get_env without default
hcl_functions warns on get_env() calls with no fallback - those fail silently in CI if the variable is unset. It also validates that find_in_parent_folders() targets exist on disk:
locals {
env = get_env("MY_SECRET") # WARNING: no default
root = find_in_parent_folders("nonexistent.hcl") # ERROR: file not found
port = get_env("PORT", "8080") # ok
}
rules {
hcl_functions {
enabled = true
find_in_parent_folders_exists = true
get_env_has_default = true
}
}
deprecated terraform hooks
Terragrunt renamed before_hook / after_hook to before_hooks / after_hooks a while back. terraform_block catches the old names still in use:
terraform {
source = "git::https://github.com/example/vpc.git"
before_hook "run_fmt" { # WARNING: deprecated, use before_hooks
commands = ["terragrunt"]
execute = ["terraform", "fmt"]
}
}
rules {
terraform_block {
enabled = true
no_deprecated_fields = true
}
}
dependency outputs
dependency_outputs statically validates that dependency.x.outputs.y references exist in the target module - no plan needed:
dependency "vpc" {
config_path = "./mock-vpc"
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id # ok
bad_output = dependency.vpc.outputs.nonexistent # WARNING: not declared
}
Walks the dependency chain, parses output blocks from .tf files. Supports .mock-outputs.json for modules where upstream state is not available locally.
rules {
dependency_outputs {
enabled = true
}
}
CI
check fails the build on any rule violation:
hcl-linter check ./infra
fix --dry-run catches everything check does, plus byte-level drift from fixable rules - if someone committed without running fix:
hcl-linter fix ./infra --dry-run
Both are composable: run check for semantic issues and fix --dry-run for formatting drift as separate steps.
install
go install github.com/bard-works/hcl-linter@latest
Or grab a binary from releases:
curl -L https://github.com/bard-works/hcl-linter/releases/latest/download/hcl-linter_linux_amd64.tar.gz | tar xz
Source and full docs: bard-works/hcl-linter
Originally published at https://bard.sh/posts/hcl-linter-release/
Top comments (0)