Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
76aff39
feat: document each rule's rationale and references
claude Jul 25, 2026
19b1374
refactor: move CLI-only packages under cmd/decolint
claude Jul 25, 2026
e12428c
feat: publish the rule reference as a documentation site
claude Jul 25, 2026
3f3954c
ci: build the documentation site with Hugo instead of Jekyll
claude Jul 25, 2026
e38066a
refactor: move discovery, format and substitute back to the module root
claude Jul 26, 2026
ab3a43c
feat(cli): drop -explain
claude Jul 26, 2026
5052c81
fix: make site-serve usable from the workspace root
claude Jul 26, 2026
77b2a76
Merge remote-tracking branch 'origin/main' into claude/rule-long-desc…
claude Jul 26, 2026
2805ec5
docs: bring the site's prose in line with the rewritten README
claude Jul 26, 2026
d404f40
build: make hugo a go tool dependency
claude Jul 26, 2026
01a7206
fix(lint): use strings.SplitSeq instead of strings.Split
claude Jul 26, 2026
dc2bccd
feat: generate the documentation site and README rules table from source
claude Jul 26, 2026
ce3f5fb
fix(lint): satisfy golangci-lint in cmd/docgen
claude Jul 26, 2026
d69de81
style(site): polish footer, widen content, add TOC to every long page
claude Jul 26, 2026
e7cca19
fix(site): keep the sidebar visible while scrolling
claude Jul 26, 2026
e9d76cd
add images
nozaq Jul 26, 2026
c23a62e
feat(site): use the project logo for favicon and navbar
claude Jul 26, 2026
fa930cd
update images
nozaq Jul 26, 2026
693cc79
site: pin the GitHub link to the header's top-right
claude Jul 26, 2026
50025d8
Merge remote-tracking branch into claude/rule-long-description-ration…
claude Jul 26, 2026
92420a7
site: fix invisible punctuation in dark-mode code blocks
claude Jul 26, 2026
2ffde1b
site: fix header logo and "All rules" links on the deployed subpath
claude Jul 26, 2026
20d2b19
fix(cli): stop -rules from rejecting the config file's lint format
claude Jul 26, 2026
f5636b1
fix(docgen): refresh the README's rules table before splitting it
claude Jul 26, 2026
cd4d37e
fix(docgen): return an error instead of panicking on a titleless README
claude Jul 26, 2026
aa59f5c
fix(cli): report only permission bits in -rules -format=json Mode
claude Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ jobs:
go-version-file: go.mod
- run: make lint

docs:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
# cmd/docgen regenerates the site's content from rules/*.go and README.md, and rewrites
# README's rules table from the same rule catalog; a diff here means one of those drifted
# from the other and a commit forgot to run "make site-content".
- run: make site-content
- run: git diff --exit-code README.md

dogfooding:
runs-on: ubuntu-latest
permissions:
Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Pages

# Builds the documentation site in docs/ and publishes it to GitHub Pages. A pull request builds it
# too, without deploying, so a page that breaks the build is caught before it is merged.
on:
push:
branches: [main]
paths:
- docs/**
- rules/**
- linter/rule.go
- cmd/docgen/**
- README.md
- Makefile
- .github/workflows/pages.yml
pull_request:
paths:
- docs/**
- rules/**
- linter/rule.go
- cmd/docgen/**
- README.md
- Makefile
- .github/workflows/pages.yml
workflow_dispatch:

permissions: {}

# One deployment at a time, and never cancel one in flight: a half-published site is worse than a
# late one.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

# Hugo is a tool dependency in go.mod, so the Go toolchain is all this needs: "make site"
# builds it at the pinned version and caches it like any other dependency.
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod

- run: make site

- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: docs/public

deploy:
needs: build
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ profile.cov

# Build output
/bin/
# A stray "go build ./cmd/docgen/..." from the repo root lands here; the Makefile always uses
# "go run" for it instead, so this is never an intentional artifact.
/docgen
/docs/public/
/docs/resources/
# Rule pages and the README-derived pages; see cmd/docgen and "make site-content".
/docs/.generated/
# Hugo writes its build lock in the directory it runs from, which is not always docs/.
.hugo_build.lock

# Go workspace file
go.work
Expand Down
87 changes: 79 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ make lint # golangci-lint
make run ARGS="-format=json path/to/dir"
```

The documentation site in [`docs/`](docs/) is built with
[Hugo](https://gohugo.io/). It is a tool dependency in
[`go.mod`](go.mod), so there is nothing to install and the version is
pinned with the rest of them:

```console
make site # build into docs/public
make site-serve # serve with live reload at http://localhost:1313/
```

## Adding a rule

Rules are plain Go code. Declare a
Expand All @@ -40,6 +50,10 @@ from its category (see `categoryDefaultSeverities` in
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`](linter/rule.go) value —
nothing about a rule lives in a separate file:

```go
package rules

Expand All @@ -48,11 +62,28 @@ import "github.com/bare-devcontainer/decolint/linter"
var MyRule = &linter.Rule{
ID: "my-rule",
Description: "...",
Category: linter.CategoryCorrectness,
FileTypes: []linter.FileType{linter.Devcontainer},
Platforms: nil, // applies to every platform
Paths: []string{"/mounts/*"},
Check: checkMyRule,
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 {
Expand All @@ -63,10 +94,50 @@ func checkMyRule(ctx *linter.Context, node *linter.Node) []linter.Finding {
}
```

`LongDescription` is Markdown; write it for the user who just hit the
finding, since `decolint -rules -format=json` and the SARIF output
both carry it, and it is what the documentation site is built from
(see below).

`Example` is machine-checked, not just illustrative:
[`rules/doc_test.go`](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/`](rules/) are good references,
including for the table-driven tests each rule ships with. When a new
rule lands, also add a row for it to the table in
[README.md](README.md#rules).
including for the table-driven tests each rule ships with.

## The documentation site and the README rules table

Both are generated from `rules/*.go` and `README.md` by
[`cmd/docgen`](cmd/docgen/), run as part of `make site` (see
[Development](#development) above) and standalone as `make
site-content`. Nothing under `docs/content/rules/` other than
`_index.md`, and nothing in `README.md` between the
`<!-- decolint:rules-table -->` markers, is hand-edited — a new rule
or a changed `LongDescription`/`Example`/`References` needs no
follow-up edit anywhere else. CI's `docs` job runs `make site-content`
and fails if that changes `README.md`, which is what catches a
generator or a rule declaration that drifted from the other.

`README.md` itself is also this generator's input for the rest of the
site: the landing page, Getting started, and Reference are the
corresponding sections of `README.md`, split at their headings. A
link within README.md to a heading that ends up on a different page
(e.g. Getting started linking to `#config-file`, which lives on
Reference) is rewritten to point there; write new cross-references the
same way you already do (`[Config file](#config-file)`) and the
generator will resolve them.

When implementing or reviewing rules, consult the Dev Container
specification at [containers.dev](https://containers.dev/) to confirm
Expand Down
40 changes: 39 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ export GOEXPERIMENT := jsonv2
# renovate: datasource=github-releases depName=golangci/golangci-lint
GOLANGCI_LINT_VERSION := v2.12.2

# Hugo is a tool dependency (see the tool directive in go.mod), so the version the site is built
# with is pinned there and needs nothing installed. HUGO overrides it with another binary.
HUGO ?= go tool hugo

# Port for site-serve.
SITE_PORT ?= 1313

VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
REVISION := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
GOBUILDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(VERSION) -X main.revision=$(REVISION)"
Expand Down Expand Up @@ -37,9 +44,40 @@ coverage: ## Run tests and open an HTML coverage report
lint: ## Run all lint rules
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run

.PHONY: site
site: site-content ## Build the documentation site into docs/public
$(HUGO) --source docs --minify

.PHONY: site-serve
site-serve: site-content ## Serve the documentation site with live reload
# hugo server keeps the path of the configured baseURL, which would serve the site under
# /decolint/ and leave http://localhost:$(SITE_PORT)/ a 404. Override it for local preview.
$(HUGO) server --source docs --port $(SITE_PORT) --baseURL http://localhost:$(SITE_PORT)/

.PHONY: site-content
site-content: ## Regenerate the rule pages, README-derived pages, and README rules table
go run ./cmd/docgen

.PHONY: site-syntax
site-syntax: ## Regenerate the syntax highlighting stylesheet
@{ \
light=$$($(HUGO) gen chromastyles --style=github | tail -n +2); \
echo '/* Chroma syntax highlighting token colors.'; \
echo ' Generated by `make site-syntax`; edit that target rather than this file. */'; \
echo; \
echo "$$light"; \
echo '@media (prefers-color-scheme: dark) {'; \
echo ' /* github-dark omits token classes it colors the same as the default text color,'; \
echo ' so fall back to inheriting that color instead of the light style'"'"'s, which'; \
echo ' stays in effect below for every class the dark style does define. */'; \
echo "$$light" | grep -oE '\.chroma \.[A-Za-z0-9]+' | sort -u | sed 's/.*/ & { color: inherit }/'; \
$(HUGO) gen chromastyles --style=github-dark | tail -n +2 | sed 's/^./ &/'; \
echo '}'; \
} > docs/assets/css/syntax.css

.PHONY: clean
clean: ## Remove build artifacts
rm -rf bin coverage.out coverage.html
rm -rf bin coverage.out coverage.html docs/public docs/resources docs/.generated docs/.hugo_build.lock .hugo_build.lock

.PHONY: install
install: ## Install the decolint binary to GOPATH/bin
Expand Down
Loading
Loading