From 5995c3e078d658349d6a31b29c0fe594fb2c870b Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:24:25 +0200 Subject: [PATCH 1/2] Add the editor configuration and a formatting leg with its own name Closes #23. The tree holds HTML, YAML, JSON, Go and prose, and nothing decided whitespace in any of them. Review time goes on it and diffs carry changes nobody made. .editorconfig is the request, and it records what the tree already is rather than a preference imposed on it: LF, a final newline, no trailing whitespace, four spaces except where an ecosystem uses two and except Go, which follows gofmt rather than arguing with it. internal/format is the refusal, and it is Go for the reason decisions/means.md gives: a rule in a workflow step has no suite and cannot be run against a planted input, and every property here is one function reading bytes and returning a verdict. It judges three things gofmt does not reach, over the files git tracks rather than whatever the working directory holds, so a scratch file nobody committed cannot red the leg. The Formatting workflow is the leg, with its own name, so a red says formatting rather than something in the build and so it can be required on its own when #48 arrives. The line-ending trap is the part worth getting right rather than discovering later. internal/format normalises CRLF before judging, and a test compares the verdicts on both spellings of the same content, so it cannot become an operating-system check by accident. gofmt does not normalise and lists a Go file whose working copy has CRLF, which is why .gitattributes widens from the JSON files to the whole tree with eol=lf: a fresh clone is LF in the working copy whatever core.autocrlf says locally, so gofmt agrees with itself on every platform. Measured rather than assumed, on a working copy that had CRLF and a fresh checkout that did not. CONTRIBUTING.md names the format command and says which half handles line endings which way, including what to do when gofmt lists a file nobody touched. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- .editorconfig | 44 ++++++++++ .gitattributes | 15 +++- .github/workflows/format.yml | 61 ++++++++++++++ CONTRIBUTING.md | 36 ++++++++- internal/format/format.go | 144 +++++++++++++++++++++++++++++++++ internal/format/format_test.go | 117 +++++++++++++++++++++++++++ 6 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/format.yml create mode 100644 internal/format/format.go create mode 100644 internal/format/format_test.go diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5c6fe22 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,44 @@ +# What an editor should do before anybody runs anything. This file is a request +# and not the rule: internal/format is what refuses a tracked file breaking one +# of the three properties below, and gofmt is what decides Go. +# +# The values are the tree as it already is rather than a preference imposed on +# it. Anything here that no check reads is a courtesy to an editor and nothing +# more. + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +# gofmt indents with tabs and is the authority for Go formatting, so this +# follows it rather than arguing with it. +[*.go] +indent_style = tab +indent_size = 4 + +# The workflow files and the site are two-space by convention in their own +# ecosystems, and the tree already follows both. +[*.{yml,yaml}] +indent_size = 2 + +[*.{html,css,js}] +indent_size = 2 + +# The manifest's own indentation is four spaces and it is fixed by +# decisions/manifest-schema.md rather than by taste, so this agrees with the +# encoder rather than with a habit. +[*.json] +indent_size = 4 + +# Markdown is prose, so an indent size is meaningless in it. A trailing space is +# not: two of them are a hard line break, which is a real construct somebody may +# want, and the check refuses one anyway. Use a backslash or a blank line, and +# there is one place to argue with that rather than two. +[*.md] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes index d062f75..efddd7d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,12 @@ +# What git stores, which is what every check reads and what every reviewer sees. +# +# Line endings are pinned here rather than left to core.autocrlf, so a file's +# stored bytes do not depend on the checkout setting of whoever committed it. # The generated manifest and the golden fixture it is compared against are -# judged byte for byte, so their line endings cannot depend on the checkout -# setting of whoever ran the test. Pinned here rather than left to core.autocrlf. -# #23 is where this widens to the rest of the tree. -*.json text eol=lf +# judged byte for byte and would be the first casualty, but the reason is not +# theirs alone: a rule that only holds for one file type holds by luck. +# +# A working copy may still have CRLF, and that is fine. internal/format +# normalises before judging, so the formatting leg gives the same verdict on +# either checkout. +* text=auto eol=lf diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..5c5ccb2 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,61 @@ +# Formatting. Its own leg with its own name, so a red here says "formatting" and +# not "something in the build", and so it can be required on its own when #48 +# arrives. +# +# Two halves, because two things decide formatting here. gofmt decides Go and +# ships with the toolchain. internal/format decides the properties an editor +# configuration states for everything else, and it normalises line endings +# before judging, so this leg gives the same verdict on a CRLF checkout as on an +# LF one. .editorconfig is the request; this is the refusal. +# +# The single entry point that runs build, test and format as named legs is #18. +# When it lands this leg calls it instead of calling the two commands directly. +name: Formatting + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +# Read-only: nothing here writes to the repository. +permissions: + contents: read + +# Cancel a superseded run on the same ref; the check is cheap and idempotent. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + name: Formatting + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # No step pushes, so do not persist the GITHUB_TOKEN in .git/config. + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + # go.mod is the authority for the version floor, so it is read rather + # than restated here where it would drift. + go-version-file: go.mod + + - name: Refuse unformatted Go + # gofmt -l prints the files it would change and exits 0 either way, so + # the exit status is derived from the output rather than trusted. + run: | + set -euo pipefail + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "::error::gofmt would rewrite these files. Run 'gofmt -w .'." + echo "$unformatted" + exit 1 + fi + echo "gofmt would rewrite nothing." + + - name: Refuse whitespace .editorconfig forbids + run: go test ./internal/format -count=1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88ecec5..e7ede87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,34 @@ ones that arrive with it. The site under `docs/` still opens in a browser with no build step. +### Formatting + + gofmt -w . + +That is the whole format command, and on a tree that is already formatted it +changes nothing. gofmt decides Go. Everything else is decided by three +properties `.editorconfig` states and `internal/format` refuses: a final +newline, no trailing whitespace, and no tab indent outside Go. + + go test ./internal/format -count=1 + +is what refuses them, and the Formatting leg of the gate runs both. There is no +formatter for the HTML, the YAML or the prose, because one would be a runtime +this tree does not carry, and `.editorconfig` is what an editor reads instead. + +Line endings do not decide the verdict, and the two halves get there +differently. `internal/format` normalises before judging, so it answers the same +on either checkout, and a test holds that property rather than a sentence +claiming it. gofmt does not normalise: it lists a Go file whose working copy has +CRLF, on any platform. What keeps that from being a Windows-only red is +`.gitattributes`, which pins every tracked file to LF in the working copy as +well as in the object store, so a fresh clone is LF whatever `core.autocrlf` +says locally. + +If gofmt lists a file you have not touched, that is the symptom: the working +copy predates `.gitattributes`. Deleting the file and checking it out again +fixes it, and nothing about the file's content was wrong. + ### The dependency set is empty, and empty is not unlocked There is no `go.sum` in the tree, because nothing is required yet. A build does @@ -87,9 +115,11 @@ the comment at the top of its own file: ls .github/workflows -They are supply-chain and hygiene checks. None of them builds or tests anything, -because there is nothing yet to build or test. That is worth knowing before you -read a green check as the tree being verified. +Most of them are supply-chain and hygiene checks that build and test nothing. +The Formatting leg is the exception: it installs the toolchain and runs part of +the suite. Nothing else in the gate compiles this repository yet, which is worth +knowing before you read a green check as the tree being verified. #18 is where +one entry point runs build, test and format as named legs. ## Sign your commits diff --git a/internal/format/format.go b/internal/format/format.go new file mode 100644 index 0000000..071f7b2 --- /dev/null +++ b/internal/format/format.go @@ -0,0 +1,144 @@ +// Package format holds the whitespace rules .editorconfig states and the check +// that refuses a tracked file breaking one. +// +// The rules are here rather than in a workflow step because an editor +// configuration is a request and this is the refusal, and because a refusal +// that cannot be run against a planted input cannot be shown to bite. Nothing +// outside the standard library is needed to decide any of them. +// +// gofmt is not re-implemented here. It ships with the toolchain, it already +// decides Go formatting, and decisions/means.md names it. What this adds is the +// part gofmt does not reach: the HTML, the JSON, the workflow YAML and the +// prose, which are most of the tree. +package format + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "strings" +) + +// Finding is one broken rule at one place. Line is 1-based, or 0 when the +// finding is about the file as a whole. +type Finding struct { + Path string + Line int + Rule string + Detail string +} + +func (f Finding) String() string { + if f.Line == 0 { + return fmt.Sprintf("%s: %s: %s", f.Path, f.Rule, f.Detail) + } + return fmt.Sprintf("%s:%d: %s: %s", f.Path, f.Line, f.Rule, f.Detail) +} + +// Rule names, which are what a failure prints. They are constants so a message +// and a test cannot drift apart. +const ( + RuleFinalNewline = "final-newline" + RuleTrailingWhitespace = "trailing-whitespace" + RuleTabIndent = "tab-indent" +) + +// CheckFile judges one file's bytes under the rules for its path. +// +// CRLF is normalised to LF before anything is judged, so the verdict does not +// depend on the checkout that produced the bytes. That is deliberate and it is +// the reason this check can run on Windows and on Linux and agree: what is +// stored is fixed by .gitattributes, and a contributor whose working copy has +// CRLF is not told their tree is broken. A lone carriage return is not +// normalised and not judged; nothing in this tree has one. +// +// A file containing a NUL byte is treated as binary and judged not at all, +// because these rules are about text and a false finding on a binary file is +// how a check gets switched off. +func CheckFile(name string, content []byte) []Finding { + if bytes.IndexByte(content, 0) >= 0 { + return nil + } + content = bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n")) + + var findings []Finding + if len(content) > 0 && content[len(content)-1] != '\n' { + findings = append(findings, Finding{ + Path: name, + Rule: RuleFinalNewline, + Detail: "the file does not end with a newline", + }) + } + + tabsAllowed := tabIndented(name) + lines := strings.Split(strings.TrimSuffix(string(content), "\n"), "\n") + for i, line := range lines { + if line == "" { + continue + } + if strings.HasSuffix(line, " ") || strings.HasSuffix(line, "\t") { + findings = append(findings, Finding{ + Path: name, + Line: i + 1, + Rule: RuleTrailingWhitespace, + Detail: "the line ends in a space or a tab", + }) + } + if !tabsAllowed && strings.HasPrefix(line, "\t") { + findings = append(findings, Finding{ + Path: name, + Line: i + 1, + Rule: RuleTabIndent, + Detail: "the line is indented with a tab, and .editorconfig indents this file type with spaces", + }) + } + } + return findings +} + +// tabIndented says whether .editorconfig indents this path with tabs. Go is the +// only such type in the tree, because gofmt indents with tabs and rewriting it +// would be a fight with the toolchain's own formatter rather than a style. +func tabIndented(name string) bool { + return filepath.Ext(name) == ".go" +} + +// TrackedFiles returns the paths git tracks under root, so an untracked scratch +// file in somebody's working copy never reds the check and every file that will +// reach a reviewer does. +func TrackedFiles(root string) ([]string, error) { + cmd := exec.Command("git", "-C", root, "ls-files", "-z") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("listing tracked files in %s: %w", root, err) + } + var paths []string + for _, p := range strings.Split(string(out), "\x00") { + if p != "" { + paths = append(paths, p) + } + } + return paths, nil +} + +// CheckTree judges every tracked file under root. Paths in the findings are the +// slash-separated ones git reports, so a failure reads the same on either +// platform. +func CheckTree(root string) ([]Finding, error) { + paths, err := TrackedFiles(root) + if err != nil { + return nil, err + } + var findings []Finding + for _, p := range paths { + content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(p))) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", p, err) + } + findings = append(findings, CheckFile(path.Clean(p), content)...) + } + return findings, nil +} diff --git a/internal/format/format_test.go b/internal/format/format_test.go new file mode 100644 index 0000000..0221b81 --- /dev/null +++ b/internal/format/format_test.go @@ -0,0 +1,117 @@ +package format + +import ( + "strings" + "testing" +) + +// crlf rewrites LF as CRLF so a test can hand CheckFile the bytes a Windows +// checkout produces. Written as a helper rather than as a literal so no +// carriage return is stored in this file, which .gitattributes fixes as LF. +func crlf(s string) string { + return strings.ReplaceAll(s, "\n", "\r\n") +} + +func rules(findings []Finding) []string { + var out []string + for _, f := range findings { + out = append(out, f.Rule) + } + return out +} + +func TestCheckFileRefusesAMissingFinalNewline(t *testing.T) { + findings := CheckFile("docs/index.html", []byte("

one line

")) + if got := rules(findings); len(got) != 1 || got[0] != RuleFinalNewline { + t.Fatalf("rules refused: %v, want exactly [%s]", got, RuleFinalNewline) + } + if findings[0].Line != 0 { + t.Errorf("line %d, want 0: the rule is about the file, not a line", findings[0].Line) + } +} + +func TestCheckFileRefusesTrailingWhitespace(t *testing.T) { + // A space at the end of the second line, which is what a wrapped paragraph + // leaves behind and what no diff shows. + findings := CheckFile("README.md", []byte("first\nsecond \nthird\n")) + if got := rules(findings); len(got) != 1 || got[0] != RuleTrailingWhitespace { + t.Fatalf("rules refused: %v, want exactly [%s]", got, RuleTrailingWhitespace) + } + if findings[0].Line != 2 { + t.Errorf("line %d, want 2", findings[0].Line) + } +} + +func TestCheckFileRefusesATabIndentOutsideGo(t *testing.T) { + findings := CheckFile(".github/workflows/format.yml", []byte("jobs:\n\tformat:\n")) + if got := rules(findings); len(got) != 1 || got[0] != RuleTabIndent { + t.Fatalf("rules refused: %v, want exactly [%s]", got, RuleTabIndent) + } +} + +func TestCheckFileAllowsATabIndentInGo(t *testing.T) { + if findings := CheckFile("manifest/manifest.go", []byte("func f() {\n\treturn\n}\n")); len(findings) != 0 { + t.Fatalf("refused %v, and gofmt indents Go with tabs", findings) + } +} + +// TestCheckFileGivesTheSameVerdictOnCRLF is the one that decides whether this +// check can be in the gate at all. A formatting rule that reds only on one +// operating system pushes contributors away for a reason they cannot see, so +// the same content judged from a CRLF checkout has to produce the same answer. +func TestCheckFileGivesTheSameVerdictOnCRLF(t *testing.T) { + cases := []struct { + name string + content string + }{ + {"clean.md", "first\nsecond\n"}, + {"trailing.md", "first\nsecond \nthird\n"}, + {"tabbed.yml", "jobs:\n\tformat:\n"}, + } + for _, c := range cases { + lf := rules(CheckFile(c.name, []byte(c.content))) + crlfRules := rules(CheckFile(c.name, []byte(crlf(c.content)))) + if strings.Join(lf, ",") != strings.Join(crlfRules, ",") { + t.Errorf("%s: LF refused %v and CRLF refused %v", c.name, lf, crlfRules) + } + } +} + +// TestCheckFileIgnoresBinary keeps a false finding on a binary file from being +// the reason somebody switches the leg off. +func TestCheckFileIgnoresBinary(t *testing.T) { + if findings := CheckFile("docs/logo.png", []byte("\x89PNG\x00\x1a ")); len(findings) != 0 { + t.Fatalf("refused %v on a file containing a NUL byte", findings) + } +} + +func TestCheckFileAllowsAnEmptyFile(t *testing.T) { + if findings := CheckFile("docs/CNAME", nil); len(findings) != 0 { + t.Fatalf("refused %v on an empty file", findings) + } +} + +// TestTrackedTreeIsFormatted is the leg itself. It reads what git tracks rather +// than what the working directory happens to hold, so a scratch file nobody +// committed cannot red it. +func TestTrackedTreeIsFormatted(t *testing.T) { + const root = "../.." + paths, err := TrackedFiles(root) + if err != nil { + t.Fatalf("listing tracked files: %v", err) + } + if len(paths) == 0 { + t.Fatal("no tracked files found, so this test would pass on an empty tree") + } + + findings, err := CheckTree(root) + if err != nil { + t.Fatalf("checking the tree: %v", err) + } + for _, f := range findings { + t.Errorf("%s", f) + } + if len(findings) > 0 { + t.Logf("%d tracked file(s) checked", len(paths)) + } +} From a9b41a574d56ec1e11a37563a808cfe7dd19152b Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:08:10 +0200 Subject: [PATCH 2/2] Run the editorconfig rules as a gate leg, not as a second workflow This branch was written before #18 landed and said so: it added .github/workflows/format.yml with its own gofmt step and its own `go test ./internal/format` step, and its comment promised to call the single entry point once one existed. One exists on main now, and the promise was still unkept, so merging as it stood would have put a second gofmt run on every pull request under a different name and left the whitespace rules in a job the gate's own suite cannot see. What was wrong is the shape rather than the rules: internal/format, .editorconfig and the .gitattributes widening are unchanged. The whitespace half is now the `editorconfig` leg of `go run . gate`, with a job in gate.yml like every other leg, and format.yml is gone. This prevents two failures. A leg living outside the entry point drifts from what a contributor runs before pushing, which is the thing #18 exists against. And a check outside gate.yml is invisible to TestWorkflowDeclaresOneJobPerLeg, so deleting its job would have been green. Found by reading the branch against main before merging. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- .github/workflows/format.yml | 61 ------------------------------------ .github/workflows/gate.yml | 16 +++++++++- CONTRIBUTING.md | 31 +++++++++--------- internal/gate/gate.go | 17 ++++++++-- 4 files changed, 46 insertions(+), 79 deletions(-) delete mode 100644 .github/workflows/format.yml diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml deleted file mode 100644 index 5c5ccb2..0000000 --- a/.github/workflows/format.yml +++ /dev/null @@ -1,61 +0,0 @@ -# Formatting. Its own leg with its own name, so a red here says "formatting" and -# not "something in the build", and so it can be required on its own when #48 -# arrives. -# -# Two halves, because two things decide formatting here. gofmt decides Go and -# ships with the toolchain. internal/format decides the properties an editor -# configuration states for everything else, and it normalises line endings -# before judging, so this leg gives the same verdict on a CRLF checkout as on an -# LF one. .editorconfig is the request; this is the refusal. -# -# The single entry point that runs build, test and format as named legs is #18. -# When it lands this leg calls it instead of calling the two commands directly. -name: Formatting - -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - -# Read-only: nothing here writes to the repository. -permissions: - contents: read - -# Cancel a superseded run on the same ref; the check is cheap and idempotent. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - format: - name: Formatting - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # No step pushes, so do not persist the GITHUB_TOKEN in .git/config. - persist-credentials: false - - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - # go.mod is the authority for the version floor, so it is read rather - # than restated here where it would drift. - go-version-file: go.mod - - - name: Refuse unformatted Go - # gofmt -l prints the files it would change and exits 0 either way, so - # the exit status is derived from the output rather than trusted. - run: | - set -euo pipefail - unformatted=$(gofmt -l .) - if [ -n "$unformatted" ]; then - echo "::error::gofmt would rewrite these files. Run 'gofmt -w .'." - echo "$unformatted" - exit 1 - fi - echo "gofmt would rewrite nothing." - - - name: Refuse whitespace .editorconfig forbids - run: go test ./internal/format -count=1 diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 15902e8..fb2e732 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -1,4 +1,5 @@ -# The merge gate: build, test, format. +# The merge gate. The jobs below are the whole of it, and `go run .` names the +# same legs from a shell, so neither list is written out in prose that drifts. # # One job per leg, and each job runs the same entry point a contributor runs # before pushing. A job is not a place where a decision is taken here; every @@ -95,6 +96,19 @@ jobs: go-version-file: go.mod - run: go run . gate format + editorconfig: + name: "Gate: editorconfig" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - run: go run . gate editorconfig + tests-reach-nothing: name: "Gate: tests-reach-nothing" runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f13ef8b..4c91d33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,9 +26,11 @@ newline, no trailing whitespace, and no tab indent outside Go. go test ./internal/format -count=1 -is what refuses them, and the Formatting leg of the gate runs both. There is no -formatter for the HTML, the YAML or the prose, because one would be a runtime -this tree does not carry, and `.editorconfig` is what an editor reads instead. +is what refuses them. The gate runs the two halves as two legs, `format` and +`editorconfig`, so a red says which of them it was without anybody opening a +log. There is no formatter for the HTML, the YAML or the prose, because one +would be a runtime this tree does not carry, and `.editorconfig` is what an +editor reads instead. Line endings do not decide the verdict, and the two halves get there differently. `internal/format` normalises before judging, so it answers the same @@ -95,9 +97,9 @@ Its legs run in order, they stop at the first failure, and the run ends by sayin how many of them it examined, so a run that covered two of three cannot be read as one that covered three and found nothing. -The legs are build, test and format, which is what `decisions/means.md` settles -them as. Which legs exist is printed rather than restated here, because a list in -this file drifts against the one the command runs: +What each leg is made of comes from `decisions/means.md`. Which legs exist is +printed rather than restated here, because a list in this file drifts against the +one the command runs: go run . @@ -111,10 +113,10 @@ because `build`, `deploy` and `report-build-status` are already taken on `main` by the Pages deployment, which has no file in this tree; `internal/gate` holds the prefix, and the suite refuses a leg with no job and a job with no leg. -On a clone where the working copy has CRLF line endings, the format leg lists Go -files you have not touched. The content is not wrong: gofmt does not normalise -before judging, and only `*.json` is pinned to LF today. #23 is where that pin -widens to the rest of the tree. +On a clone made before `.gitattributes` pinned the whole tree to LF, the format +leg lists Go files you have not touched. The content is not wrong: gofmt does not +normalise before judging, and the working copy still holds the carriage returns +the old checkout wrote. The Formatting section above says what fixes it. ## Where things live @@ -142,11 +144,10 @@ the comment at the top of its own file: ls .github/workflows -Most of them are supply-chain and hygiene checks that build and test nothing. -The Formatting leg is the exception: it installs the toolchain and runs part of -the suite. Nothing else in the gate compiles this repository yet, which is worth -knowing before you read a green check as the tree being verified. #18 is where -one entry point runs build, test and format as named legs. +`gate.yml` is the one that compiles and tests this repository, one job per leg +of the entry point above. The rest are supply-chain and hygiene checks that build +and test nothing, which is worth knowing before you read one of their greens as +the tree being verified. ## Sign your commits diff --git a/internal/gate/gate.go b/internal/gate/gate.go index 0198926..3b477e4 100644 --- a/internal/gate/gate.go +++ b/internal/gate/gate.go @@ -9,8 +9,9 @@ // the jobs the workflow declares, and the suite refuses a leg with no job and a // job with no leg. // -// What each leg is made of comes from decisions/means.md: Go, go test, and -// gofmt -l over the tree. +// What each leg is made of comes from decisions/means.md, which is why every +// Argv below is the toolchain and nothing installed beside it. Legs is the +// authority for the list; a run prints it, and no comment here repeats it. package gate import ( @@ -80,6 +81,18 @@ func Legs() []Leg { Refuses: "a Go file gofmt would rewrite", OutputIsTheVerdict: true, }, + { + // editorconfig, the half of formatting gofmt does not reach: the + // HTML, the JSON, the workflow YAML and the prose, which are most + // of the tree. Its own leg rather than a widening of format + // because the two are decided by different things and fail for + // different reasons - one is the toolchain's formatter, the other + // is three properties .editorconfig states - and because a leg + // runs one command. + Name: "editorconfig", + Argv: []string{"go", "test", "./internal/format", "-run", "TestTrackedTreeIsFormatted", "-count=1"}, + Refuses: "a tracked file with no final newline, with trailing whitespace, or indented with a tab outside Go", + }, { // gate-tests-reach-nothing, which decisions/headless-and-unelevated.md // names as a leg of this gate. It is its own leg rather than a test