Contributing
August 2, 2026 · View on GitHub
Development
decolint is written in Go (the required version is in
go.mod) and uses the still experimental
encoding/json/v2 standard library package, so every go command
needs GOEXPERIMENT=jsonv2. The Makefile sets it for you:
make build # build ./bin/decolint
make test # go test ./...
make lint # golangci-lint
make run ARGS="--format=json path/to/dir"
The documentation site in docs/ is built with
Hugo. It is a tool dependency in
go.mod, so there is nothing to install and the version is
pinned with the rest of them:
make docs # build into docs/public
make docs-serve # serve with live reload at http://localhost:1313/
Adding a rule
Rules are plain Go code. Declare a
linter.Rule value in a new file under
rules/ and add it to the builtinRuleList slice in
rules.go.
A rule declares the kinds of configuration files it applies to
(linter.Devcontainer, linter.Feature, linter.Template), the
category it belongs to (linter.CategoryCorrectness,
linter.CategorySecurity, linter.CategoryReproducibility, or
linter.CategoryStyle; every rule must declare exactly one), the
target platform(s) it applies to (linter.PlatformVSCode,
linter.PlatformCodespaces, ...; a nil or empty value means the rule
applies to every platform), and the JSON Pointer paths it wants to
inspect. The engine traverses the syntax tree once per matching file
and calls Check for every value matching one of the paths; a *
segment matches any object member name or array index, and the empty
string matches the document root.
A devcontainer.json's runArgs is the exception: it is traversed as
the docker run argv it becomes (see The docker run flag
table below), so a rule addresses its
entries by flag rather than by index. /runArgs/--volume matches once
per occurrence of that flag, whichever spelling the argv uses;
node.Arg carries the flag's value, and node.Value is the entry that
value is written in, which is not necessarily the one naming the flag.
Nothing else is addressed under /runArgs there — /runArgs/*
included, and a runArgs that is not an array is reached only as a
whole, at /runArgs — so a path under it always arrives with
node.Arg set.
Only a devcontainer.json has a runArgs at all. A Feature or a
Template that carries one is walked as the ordinary data it is, so
/runArgs/--volume matches a member merely spelled like the flag
there, with node.Arg nil — exactly as it is on the property the
rule's other path names. A rule reporting both a property and a flag
has to ignore those matches, with underRunArgs (see
rules/util.go).
A rule that reports a flag's absence cannot be driven by any of that:
a flag that is not there is never matched. It inspects the document
root instead, asking runArgsHasFlagValue (also in
rules/util.go) for the flag's values.
A rule's default severity is not set individually; it comes entirely
from its category (see categoryDefaultSeverities in
rules.go) — only CategoryCorrectness runs by
default, at error. Pick the category that matches the problem the
rule reports, not the severity you'd like it to have.
Besides the short Description, a rule carries the reasoning and an
example directly on the linter.Rule value —
nothing about a rule lives in a separate file:
package rules
import "github.com/bare-devcontainer/decolint/linter"
var MyRule = &linter.Rule{
ID: "my-rule",
Description: "...",
LongDescription: `What goes wrong in the configuration this reports, and what to do
instead.`,
References: []string{"https://containers.dev/implementors/json_reference/"},
Category: linter.CategoryCorrectness,
FileTypes: []linter.FileType{linter.Devcontainer},
Platforms: nil, // applies to every platform
Paths: []string{"/mounts/*"},
Example: linter.Example{
Bad: linter.Snippet{
Files: []linter.ExampleFile{
{Path: "devcontainer.json", Content: `{ ... }
`},
},
},
Good: linter.Snippet{
Files: []linter.ExampleFile{
{Path: "devcontainer.json", Content: `{ ... }
`},
},
},
},
Check: checkMyRule,
}
func checkMyRule(ctx *linter.Context, node *linter.Node) []linter.Finding {
// node.Value is the HuJSON value at node.Pointer. Set each
// finding's Offset to the offending value's StartOffset so the
// engine can resolve it to a line and column.
return nil
}
LongDescription is Markdown; write it for the user who just hit the
finding, since it is what the rule's own page on the documentation
site is built from (see below). The SARIF output does not repeat it —
each alert links to the rule's page instead, so a reader who wants the
reasoning is one click away rather than seeing it duplicated inline.
Example is machine-checked, not just illustrative:
rules/doc_test.go lints Bad with the rule as
the only one active and requires a finding, then lints Good and
requires none. Snippet.Files is one directory: the file named after
the rule's first FileTypes entry (devcontainer.json,
devcontainer-feature.json, or devcontainer-template.json) is the
one linted, and any other files are context a rule reads from the
directory (e.g. a Template's other files, for a
${templateOption:...} reference). Set Snippet.DirName when the
rule reads the directory's own name (id-dir-mismatch), and a file's
Mode when the rule reads permission bits (install.sh's executable
bit) — Bad and Good can then differ in mode alone, with identical
content. Example.Note is optional Markdown shown after Good, for
context the two snippets alone don't convey.
The existing rules in rules/ are good references,
including for the table-driven tests each rule ships with.
The docker run flag table
A devcontainer.json's runArgs is spliced into a docker run command
line, so where a rule finds a value depends on which flags take one:
["--label", "--cap-drop=ALL"] drops no capability, because --label
consumes the entry after it. dockerargs reads a
runArgs array the way pflag — the parser docker/cli uses — reads an
argv. Both ways a rule reaches a flag (see Adding a
rule above) are built on that reading, so no rule
matches entries itself.
That needs to know every flag docker run registers and whether it
takes a value. A table written by hand would go stale the first time
Docker adds a flag, and stale silently: decolint would keep parsing,
just no longer the way Docker does. So
cmd/dockerflagsgen builds the command
docker/cli builds and writes the flags back out as
dockerargs/runflags.go:
make dockerflags # regenerate the table
make dockerflags-test # the generator's tests, incl. the differential test against pflag
The generator is a module of its own, with docker/cli pinned in its own
go.mod. decolint itself depends on
neither docker/cli nor pflag, and go build, go test and make lint,
which all work on the module rooted here, never reach it. Renovate bumps
the pin like any other dependency; CI's dockerflags job regenerates
the table and fails on a diff, so a docker/cli release that changes a
flag arrives as a diff to review rather than as findings that quietly
stop matching what Docker does.
Two of the generator module's tests are the ones that keep the table honest, and both are meant to fail loudly:
TestParseruns random argvs through bothdockerargs.Parseand a realpflag.FlagSetbuilt from the table, and compares the values each assigns.TestRunFlags, indockerargsitself, spells the whole table out a second time, so regenerating it against a newer docker/cli fails a test instead of changing which entry a rule reads a value from.
dockerargs deliberately parts from Docker in two places, both
documented at Parse: it reads on past a -- terminator and past the
image name, where Docker stops. Anything else is a bug in dockerargs,
not a rule's problem.
Where documentation lives
The README and the site divide by what the reader is trying to decide:
the README covers whether to use decolint at all, and the site covers
how. So the walkthroughs and the reference are the site's, and adding
to them is an edit to docs/content/ — Getting
started, Reference, and the rule index's _index.md are hand-written
there.
The rest is generated from rules/*.go and README.md by
cmd/docgen, run as part of make docs (see
Development above) and standalone as make docs-content:
- A page per rule, under
docs/content/rules/on the published site. A new rule or a changedLongDescription/Example/Referencesneeds no follow-up edit anywhere else. - The site's landing page, from the part of
README.mdbetween<!-- decolint:page=_index -->and<!-- decolint:end-page -->, so the pitch the two share has one source. Everything outside those markers is README-only. A link inside them must resolve to a heading inside them too; point anywhere else at its published address (https://bare-devcontainer.github.io/decolint/...), which is what the generator's dead-anchor check leaves you. - The README's category summary, between the
<!-- decolint:categories -->markers.
Nothing generated is hand-edited. CI's docs job runs make docs-content and fails if that changes README.md, which is what
catches a generator or a rule declaration that drifted from the other.
The layout of everything the generator writes lives in
cmd/docgen/templates/, so changing how a
page looks is an edit to a template rather than to Go code.
When implementing or reviewing rules, consult the Dev Container specification at containers.dev to confirm the behavior matches the spec:
Pull requests
PR titles must follow the Conventional
Commits format with one of the
types feat, fix, ci, chore, test, or docs, e.g.
feat(cli): add new feature. Keep external dependencies to a minimum.