From 2c20ae5f04e1e6ff096f04fc63c80630567c2cb1 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Fri, 16 May 2025 10:02:33 -0700 Subject: [PATCH 1/8] log -> coder/slog change Signed-off-by: Callum Styan --- cmd/readmevalidation/coderresources.go | 8 +-- cmd/readmevalidation/contributors.go | 10 ++-- cmd/readmevalidation/main.go | 16 +++--- go.mod | 22 +++++++- go.sum | 74 ++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 16 deletions(-) diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 98a953c..7996c72 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -2,9 +2,9 @@ package main import ( "bufio" + "context" "errors" "fmt" - "log" "net/url" "os" "path" @@ -338,17 +338,17 @@ func validateAllCoderResourceFilesOfType(resourceType string) error { return err } - log.Printf("Processing %d README files\n", len(allReadmeFiles)) + logger.Info(context.Background(), "Processing README files", "num_files", len(allReadmeFiles)) resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles) if err != nil { return err } - log.Printf("Processed %d README files as valid Coder resources with type %q", len(resources), resourceType) + logger.Info(context.Background(), "Processed README files as valid Coder resources", "num_files", len(resources), "type", resourceType) err = validateCoderResourceRelativeUrls(resources) if err != nil { return err } - log.Printf("All relative URLs for %s READMEs are valid\n", resourceType) + logger.Info(context.Background(), "All relative URLs for READMEs are valid", "type", resourceType) return nil } diff --git a/cmd/readmevalidation/contributors.go b/cmd/readmevalidation/contributors.go index daee82c..8a875a7 100644 --- a/cmd/readmevalidation/contributors.go +++ b/cmd/readmevalidation/contributors.go @@ -1,9 +1,9 @@ package main import ( + "context" "errors" "fmt" - "log" "net/url" "os" "path" @@ -318,19 +318,19 @@ func validateAllContributorFiles() error { return err } - log.Printf("Processing %d README files\n", len(allReadmeFiles)) + logger.Info(context.Background(), "Processing README files", "num_files", len(allReadmeFiles)) contributors, err := parseContributorFiles(allReadmeFiles) if err != nil { return err } - log.Printf("Processed %d README files as valid contributor profiles", len(contributors)) + logger.Info(context.Background(), "Processed README files as valid contributor profiles", "num_contributors", len(contributors)) err = validateContributorRelativeUrls(contributors) if err != nil { return err } - log.Println("All relative URLs for READMEs are valid") + logger.Info(context.Background(), "All relative URLs for READMEs are valid") - log.Printf("Processed all READMEs in the %q directory\n", rootRegistryPath) + logger.Info(context.Background(), "Processed all READMEs in directory", "dir", rootRegistryPath) return nil } diff --git a/cmd/readmevalidation/main.go b/cmd/readmevalidation/main.go index 6f33f74..5398867 100644 --- a/cmd/readmevalidation/main.go +++ b/cmd/readmevalidation/main.go @@ -6,20 +6,24 @@ package main import ( - "fmt" - "log" + "context" "os" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" ) +var logger = slog.Make(sloghuman.Sink(os.Stdout)) + func main() { - log.Println("Starting README validation") + logger.Info(context.Background(), "Starting README validation") // If there are fundamental problems with how the repo is structured, we // can't make any guarantees that any further validations will be relevant // or accurate repoErr := validateRepoStructure() if repoErr != nil { - log.Println(repoErr) + logger.Error(context.Background(), repoErr.Error()) os.Exit(1) } @@ -34,11 +38,11 @@ func main() { } if len(errs) == 0 { - log.Printf("Processed all READMEs in the %q directory\n", rootRegistryPath) + logger.Info(context.Background(), "Processed all READMEs in directory", "dir", rootRegistryPath) os.Exit(0) } for _, err := range errs { - fmt.Println(err) + logger.Error(context.Background(), err.Error()) } os.Exit(1) } diff --git a/go.mod b/go.mod index e407422..53e3a1c 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,24 @@ module coder.com/coder-registry go 1.23.2 -require gopkg.in/yaml.v3 v3.0.1 +require ( + cdr.dev/slog v1.6.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.7.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + go.opentelemetry.io/otel v1.16.0 // indirect + go.opentelemetry.io/otel/trace v1.16.0 // indirect + golang.org/x/crypto v0.11.0 // indirect + golang.org/x/sys v0.10.0 // indirect + golang.org/x/term v0.10.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect +) diff --git a/go.sum b/go.sum index a62c313..a25cfa1 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,77 @@ +cdr.dev/slog v1.6.1 h1:IQjWZD0x6//sfv5n+qEhbu3wBkmtBQY5DILXNvMaIv4= +cdr.dev/slog v1.6.1/go.mod h1:eHEYQLaZvxnIAXC+XdTSNLb/kgA/X2RVSF72v5wsxEI= +cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY= +cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/logging v1.7.0 h1:CJYxlNNNNAMkHp9em/YEXcfJg+rPDg7YfwoRpMU+t5I= +cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= +cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI= +cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= +go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= +go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo= +go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4= +go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiMWgE= +go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4= +go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= +go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e h1:xIXmWJ303kJCuogpj0bHq+dcjcZHU+XFyc1I0Yl9cRg= +google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:0ggbjUrZYpy1q+ANUS30SEoGZ53cdfwtbuG7Ptgy108= +google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 h1:XVeBY8d/FaK4848myy41HBqnDwvxeV3zMZhwN1TvAMU= +google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:mPBs5jNgx2GuQGvFwUvVKqtn6HsUw9nP64BedgvqEsQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 h1:2FZP5XuJY9zQyGM5N0rtovnoXjiMUEIUMvw0m9wlpLc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o= +google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw= +google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From b1d22f2f4ff210a40e765709d6870cc277dfbf75 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Fri, 16 May 2025 10:19:53 -0700 Subject: [PATCH 2/8] minor style cleanup of readmefiles.go Signed-off-by: Callum Styan --- cmd/readmevalidation/readmefiles.go | 117 +++++++++++++--------------- 1 file changed, 53 insertions(+), 64 deletions(-) diff --git a/cmd/readmevalidation/readmefiles.go b/cmd/readmevalidation/readmefiles.go index 2967652..466bbbf 100644 --- a/cmd/readmevalidation/readmefiles.go +++ b/cmd/readmevalidation/readmefiles.go @@ -8,48 +8,73 @@ import ( "strings" ) -const rootRegistryPath = "./registry" +const ( + rootRegistryPath = "./registry" + fence = "---" + + // validationPhaseFileStructureValidation indicates when the entire Registry + // directory is being verified for having all files be placed in the file + // system as expected. + validationPhaseFileStructureValidation validationPhase = "File structure validation" -var supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"} + // validationPhaseFileLoad indicates when README files are being read from + // the file system. + validationPhaseFileLoad = "Filesystem reading" + + // validationPhaseReadmeParsing indicates when a README's frontmatter is + // being parsed as YAML. This phase does not include YAML validation. + validationPhaseReadmeParsing = "README parsing" -// readme represents a single README file within the repo (usually within the -// top-level "/registry" directory). + // validationPhaseAssetCrossReference indicates when a README's frontmatter + // is having all its relative URLs be validated for whether they point to + // valid resources. + validationPhaseAssetCrossReference = "Cross-referencing relative asset URLs" +) + +var ( + supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"} + // TODO: an example of what this regex matches would be useful, I think it's just + readmeHeaderRe = regexp.MustCompile("^(#{1,})(\\s*)") +) + +// validationPhase represents a specific phase during README validation. It is expected that each phase is discrete, and +// errors during one will prevent a future phase from starting. +type validationPhase string + +// readme represents a single README file within the repo (usually within the top-level "/registry" directory). type readme struct { filePath string rawText string } -// separateFrontmatter attempts to separate a README file's frontmatter content -// from the main README body, returning both values in that order. It does not -// validate whether the structure of the frontmatter is valid (i.e., that it's +// separateFrontmatter attempts to separate a README file's frontmatter content from the main README body, returning +// both values in that order. It does not validate whether the structure of the frontmatter is valid (i.e., that it's // structured as YAML). func separateFrontmatter(readmeText string) (string, string, error) { if readmeText == "" { return "", "", errors.New("README is empty") } - const fence = "---" fm := "" body := "" + nextLine := "" fenceCount := 0 lineScanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(readmeText))) for lineScanner.Scan() { - nextLine := lineScanner.Text() + nextLine = lineScanner.Text() if fenceCount < 2 && nextLine == fence { fenceCount++ continue } - // Break early if the very first line wasn't a fence, because then we - // know for certain that the README has problems + // Break early if the very first line wasn't a fence, because then we know for certain that the README has problems. if fenceCount == 0 { break } - // It should be safe to trim each line of the frontmatter on a per-line - // basis, because there shouldn't be any extra meaning attached to the - // indentation. The same does NOT apply to the README; best we can do is - // gather all the lines, and then trim around it + // It should be safe to trim each line of the frontmatter on a per-line basis, because there shouldn't be any + // extra meaning attached to the indentation. The same does NOT apply to the README; best we can do is gather + // all the lines and then trim around it. if inReadmeBody := fenceCount >= 2; inReadmeBody { body += nextLine + "\n" } else { @@ -66,10 +91,8 @@ func separateFrontmatter(readmeText string) (string, string, error) { return fm, strings.TrimSpace(body), nil } -var readmeHeaderRe = regexp.MustCompile("^(#{1,})(\\s*)") - -// Todo: This seems to work okay for now, but the really proper way of doing -// this is by parsing this as an AST, and then checking the resulting nodes +// TODO: This seems to work okay for now, but the really proper way of doing this is by parsing this as an AST, and then +// checking the resulting nodes. func validateReadmeBody(body string) []error { trimmed := strings.TrimSpace(body) @@ -77,9 +100,8 @@ func validateReadmeBody(body string) []error { return []error{errors.New("README body is empty")} } - // If the very first line of the README, there's a risk that the rest of the - // validation logic will break, since we don't have many guarantees about - // how the README is actually structured + // If the very first line of the README, there's a risk that the rest of the validation logic will break, since we + // don't have many guarantees about how the README is actually structured. if !strings.HasPrefix(trimmed, "# ") { return []error{errors.New("README body must start with ATX-style h1 header (i.e., \"# \")")} } @@ -88,14 +110,14 @@ func validateReadmeBody(body string) []error { latestHeaderLevel := 0 foundFirstH1 := false isInCodeBlock := false + nextLine := "" lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) for lineScanner.Scan() { - nextLine := lineScanner.Text() + nextLine = lineScanner.Text() - // Have to check this because a lot of programming languages support # - // comments (including Terraform), and without any context, there's no - // way to tell the difference between a markdown header and code comment + // Have to check this because a lot of programming languages support # comments (including Terraform), and + // without any context, there's no way to tell the difference between a markdown header and code comment. if strings.HasPrefix(nextLine, "```") { isInCodeBlock = !isInCodeBlock continue @@ -109,8 +131,7 @@ func validateReadmeBody(body string) []error { continue } - spaceAfterHeader := headerGroups[2] - if spaceAfterHeader == "" { + if spaceAfterHeader := headerGroups[2]; spaceAfterHeader == "" { errs = append(errs, errors.New("header does not have space between header characters and main header text")) } @@ -121,8 +142,7 @@ func validateReadmeBody(body string) []error { continue } - // If we have obviously invalid headers, it's not really safe to keep - // proceeding with the rest of the content + // If we have obviously invalid headers, it's not really safe to keep proceeding with the rest of the content. if nextHeaderLevel == 1 { errs = append(errs, errors.New("READMEs cannot contain more than h1 header")) break @@ -132,47 +152,16 @@ func validateReadmeBody(body string) []error { break } - // This is something we need to enforce for accessibility, not just for - // the Registry website, but also when users are viewing the README - // files in the GitHub web view + // This is something we need to enforce for accessibility, not just for the Registry website, but also when + // users are viewing the README files in the GitHub web view if nextHeaderLevel > latestHeaderLevel && nextHeaderLevel != (latestHeaderLevel+1) { errs = append(errs, fmt.Errorf("headers are not allowed to increase more than 1 level at a time")) continue } - // As long as the above condition passes, there's no problems with - // going up a header level or going down 1+ header levels + // As long as the above condition passes, there's no problems with going up a header level or going down 1+ header levels. latestHeaderLevel = nextHeaderLevel } return errs } - -// validationPhase represents a specific phase during README validation. It is -// expected that each phase is discrete, and errors during one will prevent a -// future phase from starting. -type validationPhase string - -const ( - // validationPhaseFileStructureValidation indicates when the entire Registry - // directory is being verified for having all files be placed in the file - // system as expected. - validationPhaseFileStructureValidation validationPhase = "File structure validation" - - // validationPhaseFileLoad indicates when README files are being read from - // the file system - validationPhaseFileLoad = "Filesystem reading" - - // validationPhaseReadmeParsing indicates when a README's frontmatter is - // being parsed as YAML. This phase does not include YAML validation. - validationPhaseReadmeParsing = "README parsing" - - // validationPhaseReadmeValidation indicates when a README's frontmatter is - // being validated as proper YAML with expected keys. - validationPhaseReadmeValidation = "README validation" - - // validationPhaseAssetCrossReference indicates when a README's frontmatter - // is having all its relative URLs be validated for whether they point to - // valid resources. - validationPhaseAssetCrossReference = "Cross-referencing relative asset URLs" -) From 6b4093c5894c6c2db390a50cf9244d502c3042cd Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Fri, 16 May 2025 10:23:02 -0700 Subject: [PATCH 3/8] minor style changes in main.go Signed-off-by: Callum Styan --- cmd/readmevalidation/main.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/readmevalidation/main.go b/cmd/readmevalidation/main.go index 5398867..6bf670c 100644 --- a/cmd/readmevalidation/main.go +++ b/cmd/readmevalidation/main.go @@ -18,9 +18,8 @@ var logger = slog.Make(sloghuman.Sink(os.Stdout)) func main() { logger.Info(context.Background(), "Starting README validation") - // If there are fundamental problems with how the repo is structured, we - // can't make any guarantees that any further validations will be relevant - // or accurate + // If there are fundamental problems with how the repo is structured, we can't make any guarantees that any further + // validations will be relevant or accurate. repoErr := validateRepoStructure() if repoErr != nil { logger.Error(context.Background(), repoErr.Error()) From 097b8a2328f92f1647649041508070ae7160adc4 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Fri, 16 May 2025 10:24:38 -0700 Subject: [PATCH 4/8] minor style changes in errors.go Signed-off-by: Callum Styan --- cmd/readmevalidation/errors.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/readmevalidation/errors.go b/cmd/readmevalidation/errors.go index d9dbb17..9a6d109 100644 --- a/cmd/readmevalidation/errors.go +++ b/cmd/readmevalidation/errors.go @@ -2,10 +2,8 @@ package main import "fmt" -// validationPhaseError represents an error that occurred during a specific -// phase of README validation. It should be used to collect ALL validation -// errors that happened during a specific phase, rather than the first one -// encountered. +// validationPhaseError represents an error that occurred during a specific phase of README validation. It should be +// used to collect ALL validation errors that happened during a specific phase, rather than the first one encountered. type validationPhaseError struct { phase validationPhase errors []error From ed629c196ace18ced9e632262a375ce925b9ccad Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Fri, 16 May 2025 11:14:22 -0700 Subject: [PATCH 5/8] minor contributors.go style changes Signed-off-by: Callum Styan --- cmd/readmevalidation/contributors.go | 43 +++++++++++----------------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/cmd/readmevalidation/contributors.go b/cmd/readmevalidation/contributors.go index 8a875a7..740c239 100644 --- a/cmd/readmevalidation/contributors.go +++ b/cmd/readmevalidation/contributors.go @@ -35,7 +35,7 @@ type contributorProfileReadme struct { func validateContributorDisplayName(displayName string) error { if displayName == "" { - return fmt.Errorf("missing display_name") + return errors.New("missing display_name") } return nil @@ -53,6 +53,9 @@ func validateContributorLinkedinURL(linkedinURL *string) error { return nil } +// validateContributorSupportEmail does best effort validation of a contributors email address. We can't 100% validate +// that this is correct without actually sending an email, especially because some contributors are individual developers +// and we don't want to do that on every single run of the CI pipeline. The best we can do is verify the general structure. func validateContributorSupportEmail(email *string) []error { if email == nil { return nil @@ -60,10 +63,6 @@ func validateContributorSupportEmail(email *string) []error { errs := []error{} - // Can't 100% validate that this is correct without actually sending - // an email, and especially with some contributors being individual - // developers, we don't want to do that on every single run of the CI - // pipeline. Best we can do is verify the general structure username, server, ok := strings.Cut(*email, "@") if !ok { errs = append(errs, fmt.Errorf("email address %q is missing @ symbol", *email)) @@ -113,21 +112,18 @@ func validateContributorStatus(status string) error { return nil } -// Can't validate the image actually leads to a valid resource in a pure -// function, but can at least catch obvious problems +// Can't validate the image actually leads to a valid resource in a pure function, but can at least catch obvious problems. func validateContributorAvatarURL(avatarURL *string) []error { if avatarURL == nil { return nil } - errs := []error{} if *avatarURL == "" { - errs = append(errs, errors.New("avatar URL must be omitted or non-empty string")) - return errs + return []error{errors.New("avatar URL must be omitted or non-empty string")} } - // Have to use .Parse instead of .ParseRequestURI because this is the - // one field that's allowed to be a relative URL + errs := []error{} + // Have to use .Parse instead of .ParseRequestURI because this is the one field that's allowed to be a relative URL. if _, err := url.Parse(*avatarURL); err != nil { errs = append(errs, fmt.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL)) } @@ -220,8 +216,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil yamlValidationErrors := []error{} for _, p := range profilesByNamespace { - errors := validateContributorReadme(p) - if len(errors) > 0 { + if errors := validateContributorReadme(p); len(errors) > 0 { yamlValidationErrors = append(yamlValidationErrors, errors...) continue } @@ -245,11 +240,12 @@ func aggregateContributorReadmeFiles() ([]readme, error) { allReadmeFiles := []readme{} errs := []error{} for _, e := range dirEntries { - dirPath := path.Join(rootRegistryPath, e.Name()) if !e.IsDir() { continue } + dirPath := path.Join(rootRegistryPath, e.Name()) + readmePath := path.Join(dirPath, "README.md") rmBytes, err := os.ReadFile(readmePath) if err != nil { @@ -273,20 +269,17 @@ func aggregateContributorReadmeFiles() ([]readme, error) { } func validateContributorRelativeUrls(contributors map[string]contributorProfileReadme) error { - // This function only validates relative avatar URLs for now, but it can be - // beefed up to validate more in the future + // This function only validates relative avatar URLs for now, but it can be beefed up to validate more in the future. errs := []error{} for _, con := range contributors { - // If the avatar URL is missing, we'll just assume that the Registry - // site build step will take care of filling in the data properly + // If the avatar URL is missing, we'll just assume that the Registry site build step will take care of filling + // in the data properly. if con.frontmatter.AvatarURL == nil { continue } - isRelativeURL := strings.HasPrefix(*con.frontmatter.AvatarURL, ".") || - strings.HasPrefix(*con.frontmatter.AvatarURL, "/") - if !isRelativeURL { + if !strings.HasPrefix(*con.frontmatter.AvatarURL, ".") || !strings.HasPrefix(*con.frontmatter.AvatarURL, "/") { continue } @@ -297,8 +290,7 @@ func validateContributorRelativeUrls(contributors map[string]contributorProfileR absolutePath := strings.TrimSuffix(con.filePath, "README.md") + *con.frontmatter.AvatarURL - _, err := os.ReadFile(absolutePath) - if err != nil { + if _, err := os.ReadFile(absolutePath); err != nil { errs = append(errs, fmt.Errorf("%q: relative avatar path %q does not point to image in file system", con.filePath, *con.frontmatter.AvatarURL)) } } @@ -325,8 +317,7 @@ func validateAllContributorFiles() error { } logger.Info(context.Background(), "Processed README files as valid contributor profiles", "num_contributors", len(contributors)) - err = validateContributorRelativeUrls(contributors) - if err != nil { + if err = validateContributorRelativeUrls(contributors); err != nil { return err } logger.Info(context.Background(), "All relative URLs for READMEs are valid") From 85743cdd448638e1487f70041d227dca61af20a8 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Fri, 16 May 2025 11:35:06 -0700 Subject: [PATCH 6/8] minor style changes in corderresources.go Signed-off-by: Callum Styan --- cmd/readmevalidation/coderresources.go | 82 ++++++++++++-------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 7996c72..9095bfc 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -15,7 +15,14 @@ import ( "gopkg.in/yaml.v3" ) -var supportedResourceTypes = []string{"modules", "templates"} +var ( + supportedResourceTypes = []string{"modules", "templates"} + + // TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but + // realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's + // structured. Just validating whether it *can* be parsed as Terraform would be a big improvement. + terraformVersionRe = regexp.MustCompile("^\\s*\\bversion\\s+=") +) type coderResourceFrontmatter struct { Description string `yaml:"description"` @@ -25,9 +32,8 @@ type coderResourceFrontmatter struct { Tags []string `yaml:"tags"` } -// coderResourceReadme represents a README describing a Terraform resource used -// to help create Coder workspaces. As of 2025-04-15, this encapsulates both -// Coder Modules and Coder Templates +// coderResourceReadme represents a README describing a Terraform resource used to help create Coder workspaces. +// As of 2025-04-15, this encapsulates both Coder Modules and Coder Templates. type coderResourceReadme struct { resourceType string filePath string @@ -50,35 +56,33 @@ func validateCoderResourceDescription(description string) error { } func validateCoderResourceIconURL(iconURL string) []error { - problems := []error{} - if iconURL == "" { - problems = append(problems, errors.New("icon URL cannot be empty")) - return problems + return []error{errors.New("icon URL cannot be empty")} } + errs := []error{} + isAbsoluteURL := !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") if isAbsoluteURL { if _, err := url.ParseRequestURI(iconURL); err != nil { - problems = append(problems, errors.New("absolute icon URL is not correctly formatted")) + errs = append(errs, errors.New("absolute icon URL is not correctly formatted")) } if strings.Contains(iconURL, "?") { - problems = append(problems, errors.New("icon URLs cannot contain query parameters")) + errs = append(errs, errors.New("icon URLs cannot contain query parameters")) } - return problems + return errs } - // Would normally be skittish about having relative paths like this, but it - // should be safe because we have guarantees about the structure of the - // repo, and where this logic will run + // Would normally be skittish about having relative paths like this, but it should be safe because we have guarantees + // about the structure of the repo, and where this logic will run. isPermittedRelativeURL := strings.HasPrefix(iconURL, "./") || strings.HasPrefix(iconURL, "/") || strings.HasPrefix(iconURL, "../../../../.icons") if !isPermittedRelativeURL { - problems = append(problems, fmt.Errorf("relative icon URL %q must either be scoped to that module's directory, or the top-level /.icons directory (this can usually be done by starting the path with \"../../../.icons\")", iconURL)) + errs = append(errs, fmt.Errorf("relative icon URL %q must either be scoped to that module's directory, or the top-level /.icons directory (this can usually be done by starting the path with \"../../../.icons\")", iconURL)) } - return problems + return errs } func validateCoderResourceTags(tags []string) error { @@ -89,9 +93,8 @@ func validateCoderResourceTags(tags []string) error { return nil } - // All of these tags are used for the module/template filter controls in the - // Registry site. Need to make sure they can all be placed in the browser - // URL without issue + // All of these tags are used for the module/template filter controls in the Registry site. Need to make sure they + // can all be placed in the browser URL without issue. invalidTags := []string{} for _, t := range tags { if t != url.QueryEscape(t) { @@ -105,16 +108,11 @@ func validateCoderResourceTags(tags []string) error { return nil } -// Todo: This is a holdover from the validation logic used by the Coder Modules -// repo. It gives us some assurance, but realistically, we probably want to -// parse any Terraform code snippets, and make some deeper guarantees about how -// it's structured. Just validating whether it *can* be parsed as Terraform -// would be a big improvement. -var terraformVersionRe = regexp.MustCompile("^\\s*\\bversion\\s+=") - func validateCoderResourceReadmeBody(body string) []error { - trimmed := strings.TrimSpace(body) var errs []error + + trimmed := strings.TrimSpace(body) + // TODO: this may cause unexpected behaviour since the errors slice may have a 0 length. Add a test. errs = append(errs, validateReadmeBody(trimmed)...) foundParagraph := false @@ -124,15 +122,15 @@ func validateCoderResourceReadmeBody(body string) []error { lineNum := 0 isInsideCodeBlock := false isInsideTerraform := false + nextLine := "" lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) for lineScanner.Scan() { lineNum++ - nextLine := lineScanner.Text() + nextLine = lineScanner.Text() - // Code assumes that invalid headers would've already been handled by - // the base validation function, so we don't need to check deeper if the - // first line isn't an h1 + // Code assumes that invalid headers would've already been handled by the base validation function, so we don't + // need to check deeper if the first line isn't an h1. if lineNum == 1 { if !strings.HasPrefix(nextLine, "# ") { break @@ -159,15 +157,13 @@ func validateCoderResourceReadmeBody(body string) []error { continue } - // Code assumes that we can treat this case as the end of the "h1 - // section" and don't need to process any further lines + // Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines. if lineNum > 1 && strings.HasPrefix(nextLine, "#") { break } - // Code assumes that if we've reached this point, the only other options - // are: (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset - // references made via [] syntax + // Code assumes that if we've reached this point, the only other options are: + // (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax. trimmedLine := strings.TrimSpace(nextLine) isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<") foundParagraph = foundParagraph || isParagraph @@ -257,9 +253,9 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin yamlValidationErrors := []error{} for _, readme := range resources { - errors := validateCoderResourceReadme(readme) - if len(errors) > 0 { - yamlValidationErrors = append(yamlValidationErrors, errors...) + errs := validateCoderResourceReadme(readme) + if len(errs) > 0 { + yamlValidationErrors = append(yamlValidationErrors, errs...) } } if len(yamlValidationErrors) != 0 { @@ -272,7 +268,7 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin return resources, nil } -// Todo: Need to beef up this function by grabbing each image/video URL from +// TODO: Need to beef up this function by grabbing each image/video URL from // the body's AST func validateCoderResourceRelativeUrls(resources map[string]coderResourceReadme) error { return nil @@ -286,13 +282,14 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) { var allReadmeFiles []readme var errs []error + var resourceDirs []os.DirEntry for _, rf := range registryFiles { if !rf.IsDir() { continue } resourceRootPath := path.Join(rootRegistryPath, rf.Name(), resourceType) - resourceDirs, err := os.ReadDir(resourceRootPath) + resourceDirs, err = os.ReadDir(resourceRootPath) if err != nil { if !errors.Is(err, os.ErrNotExist) { errs = append(errs, err) @@ -345,8 +342,7 @@ func validateAllCoderResourceFilesOfType(resourceType string) error { } logger.Info(context.Background(), "Processed README files as valid Coder resources", "num_files", len(resources), "type", resourceType) - err = validateCoderResourceRelativeUrls(resources) - if err != nil { + if err = validateCoderResourceRelativeUrls(resources); err != nil { return err } logger.Info(context.Background(), "All relative URLs for READMEs are valid", "type", resourceType) From 6e8fc7780601443a2bfe87a3144e562c56c8abfb Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Fri, 16 May 2025 11:56:29 -0700 Subject: [PATCH 7/8] minor style changes in repostructure.go Signed-off-by: Callum Styan --- cmd/readmevalidation/repostructure.go | 73 ++++++++++++--------------- 1 file changed, 33 insertions(+), 40 deletions(-) diff --git a/cmd/readmevalidation/repostructure.go b/cmd/readmevalidation/repostructure.go index 11bd920..25e6cfd 100644 --- a/cmd/readmevalidation/repostructure.go +++ b/cmd/readmevalidation/repostructure.go @@ -12,40 +12,37 @@ import ( var supportedUserNameSpaceDirectories = append(supportedResourceTypes[:], ".icons", ".images") func validateCoderResourceSubdirectory(dirPath string) []error { - errs := []error{} + var ( + err error + subDir os.FileInfo + ) - subDir, err := os.Stat(dirPath) - if err != nil { - // It's valid for a specific resource directory not to exist. It's just - // that if it does exist, it must follow specific rules + if subDir, err = os.Stat(dirPath); err != nil { + // It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow specific rules. if !errors.Is(err, os.ErrNotExist) { - errs = append(errs, addFilePathToError(dirPath, err)) + return []error{addFilePathToError(dirPath, err)} } - return errs } if !subDir.IsDir() { - errs = append(errs, fmt.Errorf("%q: path is not a directory", dirPath)) - return errs + return []error{fmt.Errorf("%q: path is not a directory", dirPath)} } files, err := os.ReadDir(dirPath) if err != nil { - errs = append(errs, addFilePathToError(dirPath, err)) - return errs + return []error{addFilePathToError(dirPath, err)} } + + errs := []error{} for _, f := range files { - // The .coder subdirectories are sometimes generated as part of Bun - // tests. These subdirectories will never be committed to the repo, but - // in the off chance that they don't get cleaned up properly, we want to - // skip over them + // The .coder subdirectories are sometimes generated as part of Bun tests. These subdirectories will never be + // committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over them. if !f.IsDir() || f.Name() == ".coder" { continue } resourceReadmePath := path.Join(dirPath, f.Name(), "README.md") - _, err := os.Stat(resourceReadmePath) - if err != nil { + if _, err = os.Stat(resourceReadmePath); err != nil { if errors.Is(err, os.ErrNotExist) { errs = append(errs, fmt.Errorf("%q: 'README.md' does not exist", resourceReadmePath)) } else { @@ -54,27 +51,28 @@ func validateCoderResourceSubdirectory(dirPath string) []error { } mainTerraformPath := path.Join(dirPath, f.Name(), "main.tf") - _, err = os.Stat(mainTerraformPath) - if err != nil { + if _, err = os.Stat(mainTerraformPath); err != nil { if errors.Is(err, os.ErrNotExist) { errs = append(errs, fmt.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath)) } else { errs = append(errs, addFilePathToError(mainTerraformPath, err)) } } - } - return errs } func validateRegistryDirectory() []error { - userDirs, err := os.ReadDir(rootRegistryPath) - if err != nil { + var ( + userDirs []os.DirEntry + err error + ) + if userDirs, err = os.ReadDir(rootRegistryPath); err != nil { return []error{err} } allErrs := []error{} + var iterFiles []os.DirEntry for _, d := range userDirs { dirPath := path.Join(rootRegistryPath, d.Name()) if !d.IsDir() { @@ -83,20 +81,17 @@ func validateRegistryDirectory() []error { } contributorReadmePath := path.Join(dirPath, "README.md") - _, err := os.Stat(contributorReadmePath) - if err != nil { + if _, err = os.Stat(contributorReadmePath); err != nil { allErrs = append(allErrs, err) } - files, err := os.ReadDir(dirPath) - if err != nil { + if iterFiles, err = os.ReadDir(dirPath); err != nil { allErrs = append(allErrs, err) continue } - for _, f := range files { - // Todo: Decide if there's anything more formal that we want to - // ensure about non-directories scoped to user namespaces + for _, f := range iterFiles { + // TODO: Decide if there's anything more formal that we want to ensure about non-directories scoped to user namespaces. if !f.IsDir() { continue } @@ -110,8 +105,7 @@ func validateRegistryDirectory() []error { } if slices.Contains(supportedResourceTypes, segment) { - errs := validateCoderResourceSubdirectory(filePath) - if len(errs) != 0 { + if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 { allErrs = append(allErrs, errs...) } } @@ -122,20 +116,19 @@ func validateRegistryDirectory() []error { } func validateRepoStructure() error { - var problems []error - if errs := validateRegistryDirectory(); len(errs) != 0 { - problems = append(problems, errs...) + var errs []error + if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 { + errs = append(errs, errs...) } - _, err := os.Stat("./.icons") - if err != nil { - problems = append(problems, errors.New("missing top-level .icons directory (used for storing reusable Coder resource icons)")) + if _, err := os.Stat("./.icons"); err != nil { + errs = append(errs, errors.New("missing top-level .icons directory (used for storing reusable Coder resource icons)")) } - if len(problems) != 0 { + if len(errs) != 0 { return validationPhaseError{ phase: validationPhaseFileStructureValidation, - errors: problems, + errors: errs, } } return nil From 4fc0a541aa8a4c40373adfb01c0056a6d7d8d842 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Fri, 16 May 2025 15:56:23 -0700 Subject: [PATCH 8/8] address review feedback from Michael Signed-off-by: Callum Styan --- cmd/readmevalidation/readmefiles.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/readmevalidation/readmefiles.go b/cmd/readmevalidation/readmefiles.go index 466bbbf..55cc473 100644 --- a/cmd/readmevalidation/readmefiles.go +++ b/cmd/readmevalidation/readmefiles.go @@ -10,7 +10,6 @@ import ( const ( rootRegistryPath = "./registry" - fence = "---" // validationPhaseFileStructureValidation indicates when the entire Registry // directory is being verified for having all files be placed in the file @@ -33,7 +32,7 @@ const ( var ( supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"} - // TODO: an example of what this regex matches would be useful, I think it's just + // Matches markdown headers, must be at the beginning of a line, such as "# " or "### ". readmeHeaderRe = regexp.MustCompile("^(#{1,})(\\s*)") ) @@ -55,6 +54,8 @@ func separateFrontmatter(readmeText string) (string, string, error) { return "", "", errors.New("README is empty") } + const fence = "---" + fm := "" body := "" nextLine := "" @@ -100,8 +101,8 @@ func validateReadmeBody(body string) []error { return []error{errors.New("README body is empty")} } - // If the very first line of the README, there's a risk that the rest of the validation logic will break, since we - // don't have many guarantees about how the README is actually structured. + // If the very first line of the README doesn't start with an ATX-style H1 header, there's a risk that the rest of the + // validation logic will break, since we don't have many guarantees about how the README is actually structured. if !strings.HasPrefix(trimmed, "# ") { return []error{errors.New("README body must start with ATX-style h1 header (i.e., \"# \")")} }