diff --git a/.custom-gcl.yml b/.custom-gcl.yml index 772345c1cbd..a0a0945e11a 100644 --- a/.custom-gcl.yml +++ b/.custom-gcl.yml @@ -2,7 +2,7 @@ version: v2.6.1 plugins: - module: "github.com/google/go-github/v79/tools/fmtpercentv" path: ./tools/fmtpercentv - - module: "github.com/google/go-github/v79/tools/jsonfieldname" - path: ./tools/jsonfieldname - module: "github.com/google/go-github/v79/tools/sliceofpointers" path: ./tools/sliceofpointers + - module: "github.com/google/go-github/v79/tools/structfield" + path: ./tools/structfield diff --git a/.golangci.yml b/.golangci.yml index 139a4a71188..df503f41577 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -16,7 +16,6 @@ linters: - goheader - gosec - intrange - - jsonfieldname - misspell - modernize - musttag @@ -27,6 +26,7 @@ linters: - revive - sliceofpointers - staticcheck + - structfield - tparallel - unconvert - unparam @@ -152,12 +152,16 @@ linters: type: module description: Reports usage of %d or %s in format strings. original-url: github.com/google/go-github/v79/tools/fmtpercentv - jsonfieldname: + sliceofpointers: + type: module + description: Reports usage of []*string and slices of structs without pointers. + original-url: github.com/google/go-github/v79/tools/sliceofpointers + structfield: type: module - description: Reports mismatches between Go field and JSON tag names. - original-url: github.com/google/go-github/v79/tools/jsonfieldname + description: Reports mismatches between Go field and JSON, URL tag names and types. + original-url: github.com/google/go-github/v79/tools/structfield settings: - allowed-exceptions: + allowed-tag-names: - ActionsCacheUsageList.RepoCacheUsage # TODO: RepoCacheUsages ? - AuditEntry.ExternalIdentityNameID - AuditEntry.Timestamp @@ -186,6 +190,8 @@ linters: - ListCheckSuiteResults.Total - ListCustomDeploymentRuleIntegrationsResponse.AvailableIntegrations - ListDeploymentProtectionRuleResponse.ProtectionRules + - ListIDPGroupsOptions.Query + - ListProjectsOptions.Query - OrganizationCustomRepoRoles.CustomRepoRoles # TODO: CustomRoles - OrganizationCustomRoles.CustomRepoRoles # TODO: Roles - PreReceiveHook.ConfigURL @@ -219,10 +225,201 @@ linters: - WeeklyStats.Commits - WeeklyStats.Deletions - WeeklyStats.Week - sliceofpointers: - type: module - description: Reports usage of []*string and slices of structs without pointers. - original-url: github.com/google/go-github/v79/tools/sliceofpointers + allowed-tag-types: + - ActivityListStarredOptions.Direction # TODO: Activities + - ActivityListStarredOptions.Sort # TODO: Activities + - AddProjectItemOptions.ID # TODO: Projects + - AddProjectItemOptions.Type # TODO: Projects + - AlertInstancesListOptions.Ref # TODO: CodeScanning + - AlertListOptions.Direction # TODO: CodeScanning + - AlertListOptions.Ref # TODO: CodeScanning + - AlertListOptions.Severity # TODO: CodeScanning + - AlertListOptions.Sort # TODO: CodeScanning + - AlertListOptions.State # TODO: CodeScanning + - AlertListOptions.ToolGUID # TODO: CodeScanning + - AlertListOptions.ToolName # TODO: CodeScanning + - APIMetaArtifactAttestations.TrustDomain # TODO: Meta + - CommitsListOptions.Author # TODO: Repositories + - CommitsListOptions.Path # TODO: Repositories + - CommitsListOptions.SHA # TODO: Repositories + - CommitsListOptions.Since # TODO: Repositories + - CommitsListOptions.Until # TODO: Repositories + - CreateTag.Message # TODO: Git + - CreateTag.Object # TODO: Git + - CreateTag.Tag # TODO: Git + - CreateTag.Type # TODO: Git + - CredentialAuthorizationsListOptions.Login # TODO: Organizations + - DependabotEncryptedSecret.SelectedRepositoryIDs # TODO: Dependabot + - DependabotEncryptedSecret.Visibility # TODO: Dependabot + - DeploymentRequest.RequiredContexts # TODO: Deployments + - DeploymentsListOptions.Environment # TODO: Repositories + - DeploymentsListOptions.Ref # TODO: Repositories + - DeploymentsListOptions.SHA # TODO: Repositories + - DeploymentsListOptions.Task # TODO: Repositories + - DiscussionCommentListOptions.Direction # TODO: Teams + - DiscussionListOptions.Direction # TODO: Teams + - DismissalRestrictionsRequest.Apps # TODO: Repositories + - DismissalRestrictionsRequest.Teams # TODO: Repositories + - DismissalRestrictionsRequest.Users # TODO: Repositories + - EncryptedSecret.SelectedRepositoryIDs # TODO: Actions + - EncryptedSecret.Visibility # TODO: Actions + - ErrorBlock.Reason # TODO: Common + - ErrorResponse.DocumentationURL # TODO: Common + - GetCodeownersErrorsOptions.Ref # TODO: Repositories + - GistListOptions.Since # TODO: Gists + - HostedRunnerRequest.EnableStaticIP # TODO: Actions + - HostedRunnerRequest.Image # TODO: Actions + - HostedRunnerRequest.ImageVersion # TODO: Actions + - HostedRunnerRequest.MaximumRunners # TODO: Actions + - HostedRunnerRequest.Name # TODO: Actions + - HostedRunnerRequest.RunnerGroupID # TODO: Actions + - HostedRunnerRequest.Size # TODO: Actions + - IssueEvent.Action # TODO: Issues + - IssueListByRepoOptions.Assignee # TODO: Issues + - IssueListByRepoOptions.Assignee # TODO: Issues + - IssueListByRepoOptions.Creator # TODO: Issues + - IssueListByRepoOptions.Creator # TODO: Issues + - IssueListByRepoOptions.Direction # TODO: Issues + - IssueListByRepoOptions.Direction # TODO: Issues + - IssueListByRepoOptions.Mentioned # TODO: Issues + - IssueListByRepoOptions.Mentioned # TODO: Issues + - IssueListByRepoOptions.Milestone # TODO: Issues + - IssueListByRepoOptions.Since # TODO: Issues + - IssueListByRepoOptions.Since # TODO: Issues + - IssueListByRepoOptions.Sort # TODO: Issues + - IssueListByRepoOptions.Sort # TODO: Issues + - IssueListByRepoOptions.State # TODO: Issues + - IssueListOptions.Direction # TODO: Issues + - IssueListOptions.Filter # TODO: Issues + - IssueListOptions.Since # TODO: Issues + - IssueListOptions.Sort # TODO: Issues + - IssueListOptions.State # TODO: Issues + - IssueRequest.Assignees # TODO: Issues + - IssueRequest.Labels # TODO: Issues + - License.Conditions # TODO: Licenses + - License.Limitations # TODO: Licenses + - License.Permissions # TODO: Licenses + - ListCodespacesOptions.RepositoryID # TODO: Codespaces + - ListCollaboratorsOptions.Affiliation # TODO: Repositories + - ListCollaboratorsOptions.Permission # TODO: Repositories + - ListContributorsOptions.Anon # TODO: Repositories + - ListCursorOptions.After # TODO: Common + - ListCursorOptions.Before # TODO: Common + - ListCursorOptions.Cursor # TODO: Common + - ListCursorOptions.First # TODO: Common + - ListCursorOptions.Last # TODO: Common + - ListCursorOptions.Page # TODO: Common + - ListCursorOptions.PerPage # TODO: Common + - ListCustomPropertyValuesOptions.RepositoryQuery # TODO: Organizations + - ListEnterpriseRunnerGroupOptions.VisibleToOrganization # TODO: Enterprise + - ListFineGrainedPATOptions.Direction # TODO: Organizations + - ListFineGrainedPATOptions.LastUsedAfter # TODO: Organizations + - ListFineGrainedPATOptions.LastUsedBefore # TODO: Organizations + - ListFineGrainedPATOptions.Permission # TODO: Organizations + - ListFineGrainedPATOptions.Repository # TODO: Organizations + - ListFineGrainedPATOptions.Sort # TODO: Organizations + - ListIDPGroupsOptions.Query # TODO: Teams + - ListMembersOptions.Filter # TODO: Organizations + - ListMembersOptions.Role # TODO: Organizations + - ListOptions.Page # TODO: Common + - ListOptions.PerPage # TODO: Common + - ListOrgMembershipsOptions.State # TODO: Organizations + - ListOrgRunnerGroupOptions.VisibleToRepository # TODO: Actions + - ListOutsideCollaboratorsOptions.Filter # TODO: Organizations + - ListProvisionedSCIMGroupsEnterpriseOptions.Count # TODO: Enterprise + - ListProvisionedSCIMGroupsEnterpriseOptions.ExcludedAttributes # TODO: Enterprise + - ListProvisionedSCIMGroupsEnterpriseOptions.Filter # TODO: Enterprise + - ListProvisionedSCIMGroupsEnterpriseOptions.StartIndex # TODO: Enterprise + - ListReactionOptions.Content # TODO: Reactions + - ListRepositoryActivityOptions.ActivityType # TODO: Repositories + - ListRepositoryActivityOptions.Actor # TODO: Repositories + - ListRepositoryActivityOptions.After # TODO: Repositories + - ListRepositoryActivityOptions.Before # TODO: Repositories + - ListRepositoryActivityOptions.Direction # TODO: Repositories + - ListRepositoryActivityOptions.PerPage # TODO: Repositories + - ListRepositoryActivityOptions.Ref # TODO: Repositories + - ListRepositoryActivityOptions.TimePeriod # TODO: Repositories + - ListRepositorySecurityAdvisoriesOptions.Direction # TODO: SecurityAdvisories + - ListRepositorySecurityAdvisoriesOptions.Direction # TODO: SecurityAdvisories + - ListRepositorySecurityAdvisoriesOptions.Sort # TODO: SecurityAdvisories + - ListRepositorySecurityAdvisoriesOptions.Sort # TODO: SecurityAdvisories + - ListRepositorySecurityAdvisoriesOptions.State # TODO: SecurityAdvisories + - ListRepositorySecurityAdvisoriesOptions.State # TODO: SecurityAdvisories + - ListWorkflowJobsOptions.Filter # TODO: Actions + - ListWorkflowRunsOptions.Actor # TODO: Actions + - ListWorkflowRunsOptions.Branch # TODO: Actions + - ListWorkflowRunsOptions.CheckSuiteID # TODO: Actions + - ListWorkflowRunsOptions.Created # TODO: Actions + - ListWorkflowRunsOptions.Event # TODO: Actions + - ListWorkflowRunsOptions.ExcludePullRequests # TODO: Actions + - ListWorkflowRunsOptions.HeadSHA # TODO: Actions + - ListWorkflowRunsOptions.Status # TODO: Actions + - LockIssueOptions.LockReason # TODO: Issues + - MarketplacePlan.Bullets # TODO: Marketplaces + - MilestoneListOptions.Direction # TODO: Issues + - MilestoneListOptions.Sort # TODO: Issues + - MilestoneListOptions.State # TODO: Issues + - NotificationListOptions.All # TODO: Activities + - NotificationListOptions.Before # TODO: Activities + - NotificationListOptions.Participating # TODO: Activities + - NotificationListOptions.Since # TODO: Activities + - OrganizationsListOptions.Since # TODO: Organizations + - ProjectV2ItemFieldValue.DataType # TODO: Projects + - ProjectV2ItemFieldValue.Name # TODO: Projects + - PullRequestListCommentsOptions.Direction # TODO: PullRequests + - PullRequestListCommentsOptions.Since # TODO: PullRequests + - PullRequestListCommentsOptions.Sort # TODO: PullRequests + - PullRequestListOptions.Base # TODO: PullRequests + - PullRequestListOptions.Direction # TODO: PullRequests + - PullRequestListOptions.Head # TODO: PullRequests + - PullRequestListOptions.Sort # TODO: PullRequests + - PullRequestListOptions.State # TODO: PullRequests + - Rate.Resource # TODO: Common + - RepositoryAddCollaboratorOptions.Permission # TODO: Repositories + - RepositoryContentGetOptions.Ref # TODO: Repositories + - RepositoryCreateForkOptions.DefaultBranchOnly # TODO: Repositories + - RepositoryCreateForkOptions.Name # TODO: Repositories + - RepositoryCreateForkOptions.Organization # TODO: Repositories + - RepositoryListAllOptions.Since # TODO: Repositories + - RepositoryListByAuthenticatedUserOptions.Affiliation # TODO: Repositories + - RepositoryListByAuthenticatedUserOptions.Direction # TODO: Repositories + - RepositoryListByAuthenticatedUserOptions.Sort # TODO: Repositories + - RepositoryListByAuthenticatedUserOptions.Type # TODO: Repositories + - RepositoryListByAuthenticatedUserOptions.Visibility # TODO: Repositories + - RepositoryListByOrgOptions.Direction # TODO: Repositories + - RepositoryListByOrgOptions.Sort # TODO: Repositories + - RepositoryListByOrgOptions.Type # TODO: Repositories + - RepositoryListByUserOptions.Direction # TODO: Repositories + - RepositoryListByUserOptions.Sort # TODO: Repositories + - RepositoryListByUserOptions.Type # TODO: Repositories + - RepositoryListForksOptions.Sort # TODO: Repositories + - RepositoryListOptions.Affiliation # TODO: Repositories + - RepositoryListOptions.Direction # TODO: Repositories + - RepositoryListOptions.Sort # TODO: Repositories + - RepositoryListOptions.Type # TODO: Repositories + - RepositoryListOptions.Visibility # TODO: Repositories + - RequiredStatusChecks.Checks # TODO: Repositories + - RequiredStatusChecks.Contexts # TODO: Repositories + - SearchOptions.Order # TODO: Search + - SearchOptions.Sort # TODO: Search + - Secret.SelectedRepositoriesURL # TODO: Actions + - Secret.Visibility # TODO: Actions + - SecretScanningAlertListOptions.Direction # TODO: SecretScanning + - SecretScanningAlertListOptions.IsMultiRepo # TODO: SecretScanning + - SecretScanningAlertListOptions.IsPubliclyLeaked # TODO: SecretScanning + - SecretScanningAlertListOptions.Resolution # TODO: SecretScanning + - SecretScanningAlertListOptions.SecretType # TODO: SecretScanning + - SecretScanningAlertListOptions.Sort # TODO: SecretScanning + - SecretScanningAlertListOptions.State # TODO: SecretScanning + - SecretScanningAlertListOptions.Validity # TODO: SecretScanning + - TeamAddTeamMembershipOptions.Role # TODO: Teams + - TeamAddTeamRepoOptions.Permission # TODO: Teams + - TeamListTeamMembersOptions.Role # TODO: Teams + - TrafficBreakdownOptions.Per # TODO: Repositories + - UpdateRuleParameters.UpdateAllowsFetchAndMerge # TODO: Rules + - UploadOptions.Label # TODO: Repositories + - UploadOptions.Name # TODO: Repositories + - UserListOptions.Since # TODO: Users exclusions: rules: - linters: @@ -258,6 +455,9 @@ linters: # Because fmt.Sprint(reset.Unix())) is more readable than strconv.FormatInt(reset.Unix(), 10). - linters: [perfsprint] text: fmt.Sprint.* can be replaced with faster strconv.FormatInt +issues: + max-issues-per-linter: 0 + max-same-issues: 0 formatters: enable: - gci diff --git a/tools/jsonfieldname/jsonfieldname.go b/tools/jsonfieldname/jsonfieldname.go deleted file mode 100644 index 1de10bc2f8c..00000000000 --- a/tools/jsonfieldname/jsonfieldname.go +++ /dev/null @@ -1,234 +0,0 @@ -// Copyright 2025 The go-github AUTHORS. All rights reserved. -// -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Package jsonfieldname is a custom linter to be used by -// golangci-lint to find instances where the Go field name -// of a struct does not match the JSON tag name. -// It honors idiomatic Go initialisms and handles the -// special case of `Github` vs `GitHub` as agreed upon -// by the original author of the repo. -package jsonfieldname - -import ( - "go/ast" - "go/token" - "reflect" - "regexp" - "strings" - - "github.com/golangci/plugin-module-register/register" - "golang.org/x/tools/go/analysis" -) - -func init() { - register.Plugin("jsonfieldname", New) -} - -// JSONFieldNamePlugin is a custom linter plugin for golangci-lint. -type JSONFieldNamePlugin struct { - allowedExceptions map[string]bool -} - -// Settings is the configuration for the jsonfieldname linter. -type Settings struct { - AllowedExceptions []string `json:"allowed-exceptions" yaml:"allowed-exceptions"` -} - -// New returns an analysis.Analyzer to use with golangci-lint. -// It parses the "allowed-exceptions" section to determine which warnings to skip. -func New(cfg any) (register.LinterPlugin, error) { - allowedExceptions := map[string]bool{} - - if cfg != nil { - if settingsMap, ok := cfg.(map[string]any); ok { - if exceptionsRaw, ok := settingsMap["allowed-exceptions"]; ok { - if exceptionsList, ok := exceptionsRaw.([]any); ok { - for _, item := range exceptionsList { - if exception, ok := item.(string); ok { - allowedExceptions[exception] = true - } - } - } - } - } - } - - return &JSONFieldNamePlugin{allowedExceptions: allowedExceptions}, nil -} - -// BuildAnalyzers builds the analyzers for the JSONFieldNamePlugin. -func (f *JSONFieldNamePlugin) BuildAnalyzers() ([]*analysis.Analyzer, error) { - return []*analysis.Analyzer{ - { - Name: "jsonfieldname", - Doc: "Reports mismatches between Go field and JSON tag names. Note that the JSON tag name is the source-of-truth and the Go field name needs to match it.", - Run: func(pass *analysis.Pass) (any, error) { - return run(pass, f.allowedExceptions) - }, - }, - }, nil -} - -// GetLoadMode returns the load mode for the JSONFieldNamePlugin. -func (f *JSONFieldNamePlugin) GetLoadMode() string { - return register.LoadModeSyntax -} - -func run(pass *analysis.Pass, allowedExceptions map[string]bool) (any, error) { - for _, file := range pass.Files { - ast.Inspect(file, func(n ast.Node) bool { - if n == nil { - return false - } - - switch t := n.(type) { - case *ast.TypeSpec: - structType, ok := t.Type.(*ast.StructType) - if !ok { - return true - } - structName := t.Name.Name - - // Only check exported structs. - if !ast.IsExported(structName) { - return true - } - - for _, field := range structType.Fields.List { - if field.Tag == nil || len(field.Names) == 0 { - continue - } - - goField := field.Names[0] - tagValue := strings.Trim(field.Tag.Value, "`") - structTag := reflect.StructTag(tagValue) - jsonTagName, ok := structTag.Lookup("json") - if !ok || jsonTagName == "-" { - continue - } - jsonTagName = strings.TrimSuffix(jsonTagName, ",omitempty") - - checkGoFieldName(structName, goField.Name, jsonTagName, goField.Pos(), pass, allowedExceptions) - } - } - - return true - }) - } - return nil, nil -} - -func checkGoFieldName(structName, goFieldName, jsonTagName string, tokenPos token.Pos, pass *analysis.Pass, allowedExceptions map[string]bool) { - fullName := structName + "." + goFieldName - if allowedExceptions[fullName] { - return - } - - want, alternate := jsonTagToPascal(jsonTagName) - if goFieldName != want && goFieldName != alternate { - const msg = "change Go field name %q to %q for JSON tag %q in struct %q" - pass.Reportf(tokenPos, msg, goFieldName, want, jsonTagName, structName) - } -} - -func splitJSONTag(jsonTagName string) []string { - jsonTagName = strings.TrimPrefix(jsonTagName, "$") - - if strings.Contains(jsonTagName, "_") { - return strings.Split(jsonTagName, "_") - } - - if strings.Contains(jsonTagName, "-") { - return strings.Split(jsonTagName, "-") - } - - if strings.ToLower(jsonTagName) == jsonTagName { // single word - return []string{jsonTagName} - } - - s := camelCaseRE.ReplaceAllString(jsonTagName, "$1 $2") - parts := strings.Fields(s) - for i, part := range parts { - parts[i] = strings.ToLower(part) - } - - return parts -} - -var camelCaseRE = regexp.MustCompile(`([a-z0-9])([A-Z])`) - -func jsonTagToPascal(jsonTagName string) (want, alternate string) { - parts := splitJSONTag(jsonTagName) - alt := make([]string, len(parts)) - for i, part := range parts { - alt[i] = part - if part == "" { - continue - } - upper := strings.ToUpper(part) - if initialisms[upper] { - parts[i] = upper - alt[i] = upper - } else if specialCase, ok := specialCases[upper]; ok { - parts[i] = specialCase - alt[i] = specialCase - } else if possibleAlternate, ok := possibleAlternates[upper]; ok { - parts[i] = possibleAlternate - alt[i] = strings.ToUpper(part[:1]) + part[1:] - } else { - parts[i] = strings.ToUpper(part[:1]) + part[1:] - alt[i] = parts[i] - } - } - return strings.Join(parts, ""), strings.Join(alt, "") -} - -// Common Go initialisms that should be all caps. -var initialisms = map[string]bool{ - "API": true, "ASCII": true, - "CAA": true, "CAS": true, "CNAME": true, "CPU": true, - "CSS": true, "CWE": true, "CVE": true, "CVSS": true, - "DN": true, "DNS": true, - "EOF": true, "EPSS": true, - "GB": true, "GHSA": true, "GPG": true, "GUID": true, - "HTML": true, "HTTP": true, "HTTPS": true, - "ID": true, "IDE": true, "IDP": true, "IP": true, "JIT": true, - "JSON": true, - "LDAP": true, "LFS": true, "LHS": true, - "MD5": true, "MS": true, "MX": true, - "NPM": true, "NTP": true, "NVD": true, - "OID": true, "OS": true, - "PEM": true, "PR": true, "QPS": true, - "RAM": true, "RHS": true, "RPC": true, - "SAML": true, "SBOM": true, "SCIM": true, - "SHA": true, "SHA1": true, "SHA256": true, - "SKU": true, "SLA": true, "SMTP": true, "SNMP": true, - "SPDX": true, "SPDXID": true, "SQL": true, "SSH": true, - "SSL": true, "SSO": true, "SVN": true, - "TCP": true, "TFVC": true, "TLS": true, "TTL": true, - "UDP": true, "UI": true, "UID": true, "UUID": true, - "URI": true, "URL": true, "UTF8": true, - "VCF": true, "VCS": true, "VM": true, - "XML": true, "XMPP": true, "XSRF": true, "XSS": true, -} - -var specialCases = map[string]string{ - "CPUS": "CPUs", - "CWES": "CWEs", - "GRAPHQL": "GraphQL", - "HREF": "HRef", - "IDS": "IDs", - "IPS": "IPs", - "OAUTH": "OAuth", - "OPENAPI": "OpenAPI", - "URLS": "URLs", -} - -var possibleAlternates = map[string]string{ - "ORGANIZATION": "Org", - "ORGANIZATIONS": "Orgs", - "REPOSITORY": "Repo", - "REPOSITORIES": "Repos", -} diff --git a/tools/jsonfieldname/testdata/src/has-warnings/main.go b/tools/jsonfieldname/testdata/src/has-warnings/main.go deleted file mode 100644 index c1a74c6059a..00000000000 --- a/tools/jsonfieldname/testdata/src/has-warnings/main.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2025 The go-github AUTHORS. All rights reserved. -// -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package main - -type Example struct { - GitHubThing string `json:"github_thing"` // want `change Go field name "GitHubThing" to "GithubThing" for JSON tag "github_thing" in struct "Example"` - Id string `json:"id,omitempty"` // want `change Go field name "Id" to "ID" for JSON tag "id" in struct "Example"` - strings string `json:"strings,omitempty"` // want `change Go field name "strings" to "Strings" for JSON tag "strings" in struct "Example"` - camelcaseexample *int `json:"camelCaseExample,omitempty"` // want `change Go field name "camelcaseexample" to "CamelCaseExample" for JSON tag "camelCaseExample" in struct "Example"` - DollarRef string `json:"$ref"` // want `change Go field name "DollarRef" to "Ref" for JSON tag "\$ref" in struct "Example"` -} diff --git a/tools/jsonfieldname/testdata/src/no-warnings/main.go b/tools/jsonfieldname/testdata/src/no-warnings/main.go deleted file mode 100644 index c522c7783ea..00000000000 --- a/tools/jsonfieldname/testdata/src/no-warnings/main.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2025 The go-github AUTHORS. All rights reserved. -// -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package main - -type Example struct { - GithubThing string `json:"github_thing"` // Should not be flagged - ID string `json:"id,omitempty"` // Should not be flagged - Strings string `json:"strings,omitempty"` // Should not be flagged - Ref string `json:"$ref,omitempty"` // Should not be flagged -} diff --git a/tools/jsonfieldname/go.mod b/tools/structfield/go.mod similarity index 87% rename from tools/jsonfieldname/go.mod rename to tools/structfield/go.mod index 3ae53f27ac4..22571c72626 100644 --- a/tools/jsonfieldname/go.mod +++ b/tools/structfield/go.mod @@ -1,4 +1,4 @@ -module tools/jsonfieldname +module tools/structfield go 1.24.0 diff --git a/tools/jsonfieldname/go.sum b/tools/structfield/go.sum similarity index 100% rename from tools/jsonfieldname/go.sum rename to tools/structfield/go.sum diff --git a/tools/structfield/structfield.go b/tools/structfield/structfield.go new file mode 100644 index 00000000000..19ac0db4de8 --- /dev/null +++ b/tools/structfield/structfield.go @@ -0,0 +1,362 @@ +// Copyright 2025 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package structfield is a custom linter to be used by +// golangci-lint to find instances where the Go field name +// of a struct does not match the JSON or URL tag name. +// It honors idiomatic Go initialisms and handles the +// special case of `Github` vs `GitHub` as agreed upon +// by the original author of the repo. +// It also checks that fields with "omitempty" tags are reference types. +package structfield + +import ( + "fmt" + "go/ast" + "go/token" + "go/types" + "reflect" + "regexp" + "strings" + + "github.com/golangci/plugin-module-register/register" + "golang.org/x/tools/go/analysis" +) + +func init() { + register.Plugin("structfield", New) +} + +// StructFieldPlugin is a custom linter plugin for golangci-lint. +type StructFieldPlugin struct { + allowedTagNames map[string]bool + allowedTagTypes map[string]bool +} + +// Settings is the configuration for the structfield linter. +type Settings struct { + AllowedTagNames []string `json:"allowed-tag-names" yaml:"allowed-tag-names"` + AllowedTagTypes []string `json:"allowed-tag-types" yaml:"allowed-tag-types"` +} + +// New returns an analysis.Analyzer to use with golangci-lint. +func New(cfg any) (register.LinterPlugin, error) { + allowedTagNames := map[string]bool{} + allowedTagTypes := map[string]bool{} + + if cfg != nil { + if settingsMap, ok := cfg.(map[string]any); ok { + if exceptionsRaw, ok := settingsMap["allowed-tag-names"]; ok { + if exceptionsList, ok := exceptionsRaw.([]any); ok { + for _, item := range exceptionsList { + if exception, ok := item.(string); ok { + allowedTagNames[exception] = true + } + } + } + } + + if exceptionsRaw, ok := settingsMap["allowed-tag-types"]; ok { + if exceptionsList, ok := exceptionsRaw.([]any); ok { + for _, item := range exceptionsList { + if exception, ok := item.(string); ok { + allowedTagTypes[exception] = true + } + } + } + } + } + } + + return &StructFieldPlugin{ + allowedTagNames: allowedTagNames, + allowedTagTypes: allowedTagTypes, + }, nil +} + +// BuildAnalyzers builds the analyzers for the StructFieldPlugin. +func (f *StructFieldPlugin) BuildAnalyzers() ([]*analysis.Analyzer, error) { + return []*analysis.Analyzer{ + { + Name: "structfield", + Doc: `Reports mismatches between Go field and JSON or URL tag names and types. +Note that the JSON or URL tag name is the source-of-truth and the Go field name needs to match it. +If the tag contains "omitempty", then the Go field must be a reference type.`, + Run: func(pass *analysis.Pass) (any, error) { + return run(pass, f.allowedTagNames, f.allowedTagTypes) + }, + }, + }, nil +} + +// GetLoadMode returns the load mode for the StructFieldPlugin. +func (f *StructFieldPlugin) GetLoadMode() string { + return register.LoadModeSyntax +} + +func run(pass *analysis.Pass, allowedTagNames, allowedTagTypes map[string]bool) (any, error) { + for _, file := range pass.Files { + ast.Inspect(file, func(n ast.Node) bool { + if n == nil { + return false + } + + t, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + structType, ok := t.Type.(*ast.StructType) + if !ok { + return true + } + + // Check only exported + if !ast.IsExported(t.Name.Name) { + return true + } + + for _, field := range structType.Fields.List { + if field.Tag == nil || len(field.Names) == 0 { + continue + } + + processStructField(t.Name.Name, field, pass, allowedTagNames, allowedTagTypes) + } + + return true + }) + } + return nil, nil +} + +func processStructField(structName string, field *ast.Field, pass *analysis.Pass, allowedTagNames, allowedTagTypes map[string]bool) { + goField := field.Names[0] + tagValue := strings.Trim(field.Tag.Value, "`") + structTag := reflect.StructTag(tagValue) + + processTag(structName, goField, field, structTag, "json", pass, allowedTagNames, allowedTagTypes) + processTag(structName, goField, field, structTag, "url", pass, allowedTagNames, allowedTagTypes) +} + +func processTag(structName string, goField *ast.Ident, field *ast.Field, structTag reflect.StructTag, tagType string, pass *analysis.Pass, allowedTagNames, allowedTagTypes map[string]bool) { + tagName, ok := structTag.Lookup(tagType) + if !ok || tagName == "-" { + return + } + + if strings.Contains(tagName, ",omitempty") { + checkGoFieldType(structName, goField.Name, field, field.Type.Pos(), pass, allowedTagTypes) + tagName = strings.ReplaceAll(tagName, ",omitempty", "") + } + + if tagType == "url" { + tagName = strings.ReplaceAll(tagName, ",comma", "") + } + + checkGoFieldName(structName, goField.Name, tagName, goField.Pos(), pass, allowedTagNames) +} + +func checkGoFieldName(structName, goFieldName, tagName string, tokenPos token.Pos, pass *analysis.Pass, allowedNames map[string]bool) { + fullName := structName + "." + goFieldName + if allowedNames[fullName] { + return + } + + want, alternate := tagNameToPascal(tagName) + if goFieldName != want && goFieldName != alternate { + const msg = "change Go field name %q to %q for tag %q in struct %q" + pass.Reportf(tokenPos, msg, goFieldName, want, tagName, structName) + } +} + +func checkGoFieldType(structName, goFieldName string, field *ast.Field, tokenPos token.Pos, pass *analysis.Pass, allowedTypes map[string]bool) { + if allowedTypes[structName+"."+goFieldName] { + return + } + + skipOmitempty := checkAndReportInvalidTypes(structName, goFieldName, field.Type, tokenPos, pass) + + if !skipOmitempty { + const msg = `change the %q field type to %q in the struct %q because its tag uses "omitempty"` + pass.Reportf(tokenPos, msg, goFieldName, "*"+exprToString(field.Type), structName) + } +} + +func checkAndReportInvalidTypes(structName, goFieldName string, fieldType ast.Expr, tokenPos token.Pos, pass *analysis.Pass) bool { + switch ft := fieldType.(type) { + case *ast.StarExpr: + // Check for *[]T where T is builtin - should be []T + if arrType, ok := ft.X.(*ast.ArrayType); ok { + if ident, ok := arrType.Elt.(*ast.Ident); ok && isBuiltinType(ident.Name) { + const msg = "change the %q field type to %q in the struct %q" + pass.Reportf(tokenPos, msg, goFieldName, "[]"+ident.Name, structName) + } else if starExpr, ok := arrType.Elt.(*ast.StarExpr); ok { + // Check for *[]*T - should be []*T + if ident, ok := starExpr.X.(*ast.Ident); ok { + const msg = "change the %q field type to %q in the struct %q" + pass.Reportf(tokenPos, msg, goFieldName, "[]*"+ident.Name, structName) + } + } else { + checkStructArrayType(structName, goFieldName, arrType, tokenPos, pass) + } + } + // Check for *map - should be map + if _, ok := ft.X.(*ast.MapType); ok { + const msg = "change the %q field type to %q in the struct %q" + pass.Reportf(tokenPos, msg, goFieldName, exprToString(ft.X), structName) + } + return true + case *ast.MapType: + return true + case *ast.ArrayType: + checkStructArrayType(structName, goFieldName, ft, tokenPos, pass) + return true + case *ast.SelectorExpr: + // Check for json.RawMessage + if ident, ok := ft.X.(*ast.Ident); ok && ident.Name == "json" && ft.Sel.Name == "RawMessage" { + return true + } + case *ast.Ident: + // Check for `any` type + if ft.Name == "any" { + return true + } + } + return false +} + +func checkStructArrayType(structName, goFieldName string, arrType *ast.ArrayType, tokenPos token.Pos, pass *analysis.Pass) { + if starExpr, ok := arrType.Elt.(*ast.StarExpr); ok { + if ident, ok := starExpr.X.(*ast.Ident); ok && isBuiltinType(ident.Name) { + const msg = "change the %q field type to %q in the struct %q" + pass.Reportf(tokenPos, msg, goFieldName, "[]"+ident.Name, structName) + } + return + } + + if ident, ok := arrType.Elt.(*ast.Ident); ok && ident.Obj != nil { + if _, ok := ident.Obj.Decl.(*ast.TypeSpec).Type.(*ast.StructType); ok { + const msg = "change the %q field type to %q in the struct %q" + pass.Reportf(tokenPos, msg, goFieldName, "[]*"+ident.Name, structName) + } + } +} + +func isBuiltinType(typeName string) bool { + return types.Universe.Lookup(typeName) != nil +} + +func exprToString(e ast.Expr) string { + switch t := e.(type) { + case *ast.Ident: + return t.Name + case *ast.SelectorExpr: + return exprToString(t.X) + "." + t.Sel.Name + case *ast.MapType: + return "map[" + exprToString(t.Key) + "]" + exprToString(t.Value) + default: + return fmt.Sprintf("%T", e) + } +} + +func splitTag(jsonTagName string) []string { + jsonTagName = strings.TrimPrefix(jsonTagName, "$") + + if strings.Contains(jsonTagName, "_") { + return strings.Split(jsonTagName, "_") + } + + if strings.Contains(jsonTagName, "-") { + return strings.Split(jsonTagName, "-") + } + + if strings.ToLower(jsonTagName) == jsonTagName { // single word + return []string{jsonTagName} + } + + s := camelCaseRE.ReplaceAllString(jsonTagName, "$1 $2") + parts := strings.Fields(s) + for i, part := range parts { + parts[i] = strings.ToLower(part) + } + + return parts +} + +var camelCaseRE = regexp.MustCompile(`([a-z0-9])([A-Z])`) + +func tagNameToPascal(tagName string) (want, alternate string) { + parts := splitTag(tagName) + alt := make([]string, len(parts)) + for i, part := range parts { + alt[i] = part + if part == "" { + continue + } + upper := strings.ToUpper(part) + if initialisms[upper] { + parts[i] = upper + alt[i] = upper + } else if specialCase, ok := specialCases[upper]; ok { + parts[i] = specialCase + alt[i] = specialCase + } else if possibleAlternate, ok := possibleAlternates[upper]; ok { + parts[i] = possibleAlternate + alt[i] = strings.ToUpper(part[:1]) + part[1:] + } else { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + alt[i] = parts[i] + } + } + return strings.Join(parts, ""), strings.Join(alt, "") +} + +// Common Go initialisms that should be all caps. +var initialisms = map[string]bool{ + "API": true, "ASCII": true, + "CAA": true, "CAS": true, "CNAME": true, "CPU": true, + "CSS": true, "CWE": true, "CVE": true, "CVSS": true, + "DN": true, "DNS": true, + "EOF": true, "EPSS": true, + "GB": true, "GHSA": true, "GPG": true, "GUID": true, + "HTML": true, "HTTP": true, "HTTPS": true, + "ID": true, "IDE": true, "IDP": true, "IP": true, "JIT": true, + "JSON": true, + "LDAP": true, "LFS": true, "LHS": true, + "MD5": true, "MS": true, "MX": true, + "NPM": true, "NTP": true, "NVD": true, + "OID": true, "OS": true, + "PEM": true, "PR": true, "QPS": true, + "RAM": true, "RHS": true, "RPC": true, + "SAML": true, "SBOM": true, "SCIM": true, + "SHA": true, "SHA1": true, "SHA256": true, + "SKU": true, "SLA": true, "SMTP": true, "SNMP": true, + "SPDX": true, "SPDXID": true, "SQL": true, "SSH": true, + "SSL": true, "SSO": true, "SVN": true, + "TCP": true, "TFVC": true, "TLS": true, "TTL": true, + "UDP": true, "UI": true, "UID": true, "UUID": true, + "URI": true, "URL": true, "UTF8": true, + "VCF": true, "VCS": true, "VM": true, + "XML": true, "XMPP": true, "XSRF": true, "XSS": true, +} + +var specialCases = map[string]string{ + "CPUS": "CPUs", + "CWES": "CWEs", + "GRAPHQL": "GraphQL", + "HREF": "HRef", + "IDS": "IDs", + "IPS": "IPs", + "OAUTH": "OAuth", + "OPENAPI": "OpenAPI", + "URLS": "URLs", +} + +var possibleAlternates = map[string]string{ + "ORGANIZATION": "Org", + "ORGANIZATIONS": "Orgs", + "REPOSITORY": "Repo", + "REPOSITORIES": "Repos", +} diff --git a/tools/jsonfieldname/jsonfieldname_test.go b/tools/structfield/structfield_test.go similarity index 64% rename from tools/jsonfieldname/jsonfieldname_test.go rename to tools/structfield/structfield_test.go index fa5044c7aba..a192782313a 100644 --- a/tools/jsonfieldname/jsonfieldname_test.go +++ b/tools/structfield/structfield_test.go @@ -3,7 +3,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package jsonfieldname +package structfield import ( "testing" @@ -14,7 +14,16 @@ import ( func TestRun(t *testing.T) { t.Parallel() testdata := analysistest.TestData() - plugin, _ := New(nil) + plugin, _ := New(map[string]any{ + "allowed-tag-names": []any{ + "JSONFieldName.Query", + "URLFieldName.Query", + }, + "allowed-tag-types": []any{ + "JSONFieldType.Exception", + "URLFieldType.Exception", + }, + }) analyzers, _ := plugin.BuildAnalyzers() analysistest.Run(t, testdata, analyzers[0], "has-warnings", "no-warnings") } diff --git a/tools/structfield/testdata/src/has-warnings/main.go b/tools/structfield/testdata/src/has-warnings/main.go new file mode 100644 index 00000000000..26d2704fc78 --- /dev/null +++ b/tools/structfield/testdata/src/has-warnings/main.go @@ -0,0 +1,37 @@ +// Copyright 2025 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +type JSONFieldName struct { + GitHubThing string `json:"github_thing"` // want `change Go field name "GitHubThing" to "GithubThing" for tag "github_thing" in struct "JSONFieldName"` + Id *string `json:"id,omitempty"` // want `change Go field name "Id" to "ID" for tag "id" in struct "JSONFieldName"` + strings *string `json:"strings,omitempty"` // want `change Go field name "strings" to "Strings" for tag "strings" in struct "JSONFieldName"` + camelcaseexample *int `json:"camelCaseExample,omitempty"` // want `change Go field name "camelcaseexample" to "CamelCaseExample" for tag "camelCaseExample" in struct "JSONFieldName"` + DollarRef string `json:"$ref"` // want `change Go field name "DollarRef" to "Ref" for tag "\$ref" in struct "JSONFieldName"` +} + +type JSONFieldType struct { + String string `json:"string,omitempty"` // want `change the "String" field type to "\*string" in the struct "JSONFieldType" because its tag uses "omitempty"` + SliceOfStringPointers []*string `json:"slice_of_string_pointers,omitempty"` // want `change the "SliceOfStringPointers" field type to "\[\]string" in the struct "JSONFieldType"` + PointerToSliceOfStrings *[]string `json:"pointer_to_slice_of_strings,omitempty"` // want `change the "PointerToSliceOfStrings" field type to "\[\]string" in the struct "JSONFieldType"` + SliceOfStructs []Struct `json:"slice_of_structs,omitempty"` // want `change the "SliceOfStructs" field type to "\[\]\*Struct" in the struct "JSONFieldType"` + PointerToSliceOfStructs *[]Struct `json:"pointer_to_slice_of_structs,omitempty"` // want `change the "PointerToSliceOfStructs" field type to "\[\]\*Struct" in the struct "JSONFieldType"` + PointerToSliceOfPointerStructs *[]*Struct `json:"pointer_to_slice_of_pointer_structs,omitempty"` // want `change the "PointerToSliceOfPointerStructs" field type to "\[\]\*Struct" in the struct "JSONFieldType"` + PointerToMap *map[string]string `json:"pointer_to_map,omitempty"` // want `change the "PointerToMap" field type to "map\[string\]string" in the struct "JSONFieldType"` + SliceOfInts []*int `json:"slice_of_ints,omitempty"` // want `change the "SliceOfInts" field type to "\[\]int" in the struct "JSONFieldType"` +} + +type Struct struct{} + +type URLFieldName struct { + GitHubThing string `url:"github_thing"` // want `change Go field name "GitHubThing" to "GithubThing" for tag "github_thing" in struct "URLFieldName"` +} + +type URLFieldType struct { + Page string `url:"page,omitempty"` // want `change the "Page" field type to "\*string" in the struct "URLFieldType" because its tag uses "omitempty"` + PerPage int `url:"per_page,omitempty"` // want `change the "PerPage" field type to "\*int" in the struct "URLFieldType" because its tag uses "omitempty"` + Participating bool `url:"participating,omitempty"` // want `change the "Participating" field type to "\*bool" in the struct "URLFieldType" because its tag uses "omitempty"` +} diff --git a/tools/structfield/testdata/src/no-warnings/main.go b/tools/structfield/testdata/src/no-warnings/main.go new file mode 100644 index 00000000000..fc0236f1cfe --- /dev/null +++ b/tools/structfield/testdata/src/no-warnings/main.go @@ -0,0 +1,46 @@ +// Copyright 2025 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "encoding/json" + "time" +) + +type JSONFieldName struct { + GithubThing string `json:"github_thing"` + ID *string `json:"id,omitempty"` + Strings *string `json:"strings,omitempty"` + Ref *string `json:"$ref,omitempty"` + Query string `json:"q"` +} + +type JSONFieldType struct { + WithoutTag string + + ID *string `json:"id,omitempty"` + HookAttributes map[string]string `json:"hook_attributes,omitempty"` + Inputs json.RawMessage `json:"inputs,omitempty"` + Exception string `json:"exception,omitempty"` + Value any `json:"value,omitempty"` + SliceOfPointerStructs []*Struct `json:"slice_of_pointer_structs,omitempty"` +} + +type URLFieldName struct { + ID *string `url:"id,omitempty"` + Query string `url:"q"` +} + +type URLFieldType struct { + Page *string `url:"page,omitempty"` + PerPage *int `url:"per_page,omitempty"` + Labels []string `url:"labels,omitempty,comma"` + Since *time.Time `url:"since,omitempty"` + Fields []int64 `url:"fields,omitempty,comma"` + Exception string `url:"exception,omitempty"` +} + +type Struct struct{}