From 7594c516f9e83227675fe6086ed454807e459fed Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Thu, 29 Oct 2026 20:58:23 +0000 Subject: [PATCH] fix(lint): keep templ fixtures as templ generates them structpages-lint maps a .templ attribute to its generated code through templ's source map only when the _templ.go is byte-for-byte what the templ generator produces. The repo's pre-commit hook ran goimports and gofmt -s over every .go file but gsx's *.x.go, so it regrouped the imports of the committed *_templ.go fixtures. Each then read as stale, and the .templ half of the region and keyed checks went untested: TestRegion_Integration and TestKeyed_Integration were missing all their .templ findings. CI never ran the tools/lint tests, so nothing caught it. The linter itself was right: `templ generate` output is exactly what it reproduces. The hook now skips *_templ.go as it skips *.x.go, and the five fixtures are regenerated with templ v0.3.1020 and keep their deliberate differences (another version header in pages_templ.go, none in noversion_templ.go, an edited comment that keeps stale_templ.go stale). A templscan test checks every fixture is accepted, except stale.templ, and says how to regenerate one that isn't. A new CI job runs the tools/lint tests. docs/lint.md now names a formatter run over a _templ.go as one of the differences that make its attributes go unchecked. --- .githooks/pre-commit | 11 ++++--- .github/workflows/ci.yml | 15 ++++++++++ docs/lint.md | 8 +++-- tools/lint/templscan/exprattr_test.go | 30 +++++++++++++++++++ tools/lint/testdata/keyed/tapp/pages_templ.go | 9 +++--- .../testdata/region/tapp/noversion_templ.go | 9 +++--- .../lint/testdata/region/tapp/pages_templ.go | 9 +++--- .../testdata/region/tapp/requests_templ.go | 9 +++--- .../lint/testdata/region/tapp/stale_templ.go | 9 +++--- 9 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 tools/lint/templscan/exprattr_test.go diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1c84690..ed9e622 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,17 +1,20 @@ #!/bin/sh # Pre-commit hook to run goimports, gofmt, go vet, staticcheck, and golangci-lint -# Run goimports on all Go files +# Run goimports on all Go files but generated ones: gsx (*.x.go) and templ +# (*_templ.go) output must stay as the generator wrote it, since +# structpages-lint maps a _templ.go to its .templ only when the file is +# exactly what templ generates. echo "Running goimports..." -find . -name "*.go" -not -name "*.x.go" -not -path "./vendor/*" -not -path "./.git/*" | xargs goimports -w +find . -name "*.go" -not -name "*.x.go" -not -name "*_templ.go" -not -path "./vendor/*" -not -path "./.git/*" | xargs goimports -w if [ $? -ne 0 ]; then echo "goimports failed" exit 1 fi -# Run gofmt on all Go files +# Run gofmt on all Go files but generated ones echo "Running gofmt..." -find . -name "*.go" -not -name "*.x.go" -not -path "./vendor/*" -not -path "./.git/*" | xargs gofmt -w -s +find . -name "*.go" -not -name "*.x.go" -not -name "*_templ.go" -not -path "./vendor/*" -not -path "./.git/*" | xargs gofmt -w -s if [ $? -ne 0 ]; then echo "gofmt failed" exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7db5127..adbf65e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,3 +132,18 @@ jobs: working-directory: ./examples/${{ matrix.example }} run: go test ./... + test-lint-tool: + name: Test tools/lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Test + working-directory: ./tools/lint + run: go test ./... diff --git a/docs/lint.md b/docs/lint.md index 169780c..cd5b06f 100644 --- a/docs/lint.md +++ b/docs/lint.md @@ -183,9 +183,11 @@ The attribute is matched exactly: only if the result is byte-for-byte the `_templ.go` on disk. The one exception is the `// templ: version:` comment line, which may differ or be missing (`templ generate -include-version=false`): it is a comment, so it changes no code. If anything else differs (another - templ version that generates different code, `-include-timestamp`, a hand edit), the file's positions can't be - trusted, and its attributes are skipped by `region` without a finding. To have them checked, regenerate with - the templ version the linter is built with (the `github.com/a-h/templ` version in the linter's `go.mod`). + templ version that generates different code, `-include-timestamp`, a hand edit, or a formatter run over the + generated file: `goimports` regroups its imports), the file's positions can't be trusted, and its attributes are + skipped by `region` and `keyed` without a finding. To have them checked, regenerate with the templ version the + linter is built with (the `github.com/a-h/templ` version in the linter's `go.mod`), and leave `*_templ.go` out of + any `goimports` or `gofmt -s` run, as you would gsx's `*.x.go`. Limits: diff --git a/tools/lint/templscan/exprattr_test.go b/tools/lint/templscan/exprattr_test.go new file mode 100644 index 0000000..889f20d --- /dev/null +++ b/tools/lint/templscan/exprattr_test.go @@ -0,0 +1,30 @@ +package templscan + +import ( + "errors" + "path/filepath" + "testing" +) + +// The linter's templ fixtures are joined to their .templ only when the +// _templ.go is exactly what templ generates, so a formatter run over one +// (goimports regroups its imports) silently drops its findings. +// stale.templ's _templ.go differs on purpose. +func TestExprAttrs_fixturesAreTemplOutput(t *testing.T) { + files, err := filepath.Glob("../testdata/*/tapp/*.templ") + if err != nil || len(files) == 0 { + t.Fatalf("no .templ fixtures: %v", err) + } + for _, f := range files { + _, err := ExprAttrs(f) + switch { + case filepath.Base(f) == "stale.templ": + if !errors.Is(err, ErrStale) { + t.Errorf("%s: err = %v, want ErrStale", f, err) + } + case err != nil: + t.Errorf("%s: %v (commit a _templ.go exactly as `templ generate` writes it: "+ + "no goimports, no gofmt -s)", f, err) + } + } +} diff --git a/tools/lint/testdata/keyed/tapp/pages_templ.go b/tools/lint/testdata/keyed/tapp/pages_templ.go index 6f9ce19..230ff44 100644 --- a/tools/lint/testdata/keyed/tapp/pages_templ.go +++ b/tools/lint/testdata/keyed/tapp/pages_templ.go @@ -5,11 +5,10 @@ package tapp //lint:file-ignore SA4006 This context is only used if a nested component is present. -import ( - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" - "github.com/jackielii/structpages" -) +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "github.com/jackielii/structpages" func (p list) Page(rows []row) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { diff --git a/tools/lint/testdata/region/tapp/noversion_templ.go b/tools/lint/testdata/region/tapp/noversion_templ.go index cae6a8d..6df12f6 100644 --- a/tools/lint/testdata/region/tapp/noversion_templ.go +++ b/tools/lint/testdata/region/tapp/noversion_templ.go @@ -4,11 +4,10 @@ package tapp //lint:file-ignore SA4006 This context is only used if a nested component is present. -import ( - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" - "github.com/jackielii/structpages" -) +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "github.com/jackielii/structpages" func (p ace) NoVersion() templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { diff --git a/tools/lint/testdata/region/tapp/pages_templ.go b/tools/lint/testdata/region/tapp/pages_templ.go index 2c70862..2dd0e68 100644 --- a/tools/lint/testdata/region/tapp/pages_templ.go +++ b/tools/lint/testdata/region/tapp/pages_templ.go @@ -5,11 +5,10 @@ package tapp //lint:file-ignore SA4006 This context is only used if a nested component is present. -import ( - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" - "github.com/jackielii/structpages" -) +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "github.com/jackielii/structpages" func (p ace) Page() templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { diff --git a/tools/lint/testdata/region/tapp/requests_templ.go b/tools/lint/testdata/region/tapp/requests_templ.go index 73b4abd..5b4e6e5 100644 --- a/tools/lint/testdata/region/tapp/requests_templ.go +++ b/tools/lint/testdata/region/tapp/requests_templ.go @@ -5,11 +5,10 @@ package tapp //lint:file-ignore SA4006 This context is only used if a nested component is present. -import ( - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" - "github.com/jackielii/structpages" -) +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "github.com/jackielii/structpages" func (p ace) Requests() templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { diff --git a/tools/lint/testdata/region/tapp/stale_templ.go b/tools/lint/testdata/region/tapp/stale_templ.go index caeda34..6783321 100644 --- a/tools/lint/testdata/region/tapp/stale_templ.go +++ b/tools/lint/testdata/region/tapp/stale_templ.go @@ -5,11 +5,10 @@ package tapp //lint:file-ignore SA4006 This context is only used if a nested component is present -import ( - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" - "github.com/jackielii/structpages" -) +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "github.com/jackielii/structpages" func (p stale) Page() templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {