Skip to content

Commit 036aeb4

Browse files
authored
Add custom jsonfieldname linter to ensure Go field name matches JSON tag name (#3757)
1 parent b6248e6 commit 036aeb4

File tree

9 files changed

+377
-6
lines changed

9 files changed

+377
-6
lines changed

.custom-gcl.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
version: v2.2.2
22
plugins:
3-
- module: "github.com/google/go-github/v75/tools/sliceofpointers"
4-
path: ./tools/sliceofpointers
5-
- module: "github.com/google/go-github/v75/tools/fmtpercentv"
3+
- module: "github.com/google/go-github/v76/tools/fmtpercentv"
64
path: ./tools/fmtpercentv
5+
- module: "github.com/google/go-github/v76/tools/jsonfieldname"
6+
path: ./tools/jsonfieldname
7+
- module: "github.com/google/go-github/v76/tools/sliceofpointers"
8+
path: ./tools/sliceofpointers

.golangci.yml

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ linters:
1616
- goheader
1717
- gosec
1818
- intrange
19+
- jsonfieldname
1920
- misspell
2021
- musttag
2122
- nakedret
@@ -142,11 +143,79 @@ linters:
142143
fmtpercentv:
143144
type: module
144145
description: Reports usage of %d or %s in format strings.
145-
original-url: github.com/google/go-github/v75/tools/fmtpercentv
146+
original-url: github.com/google/go-github/v76/tools/fmtpercentv
147+
jsonfieldname:
148+
type: module
149+
description: Reports mismatches between Go field and JSON tag names.
150+
original-url: github.com/google/go-github/v76/tools/jsonfieldname
151+
settings:
152+
allowed-exceptions:
153+
- ActionsCacheUsageList.RepoCacheUsage # TODO: RepoCacheUsages ?
154+
- AuditEntry.ExternalIdentityNameID
155+
- AuditEntry.Timestamp
156+
- CheckSuite.AfterSHA
157+
- CheckSuite.BeforeSHA
158+
- CodeSearchResult.CodeResults
159+
- CodeSearchResult.Total
160+
- CommitAuthor.Login
161+
- CommitsSearchResult.Commits
162+
- CommitsSearchResult.Total
163+
- CreateOrgInvitationOptions.TeamID # TODO: TeamIDs
164+
- DependencyGraphSnapshot.Sha # TODO: SHA
165+
- Discussion.DiscussionCategory # TODO: Category ?
166+
- EditOwner.OwnerInfo
167+
- EnterpriseLicensedUsers.GithubComSamlNameID # TODO: GithubComSAMLNameID
168+
- Event.RawPayload
169+
- HookRequest.RawPayload
170+
- HookResponse.RawPayload
171+
- Issue.PullRequestLinks # TODO: PullRequest
172+
- IssueImportRequest.IssueImport # TODO: Issue
173+
- IssuesSearchResult.Issues # TODO: Items
174+
- IssuesSearchResult.Total
175+
- LabelsSearchResult.Labels # TODO: Items
176+
- LabelsSearchResult.Total
177+
- ListCheckRunsResults.Total
178+
- ListCheckSuiteResults.Total
179+
- ListCustomDeploymentRuleIntegrationsResponse.AvailableIntegrations
180+
- ListDeploymentProtectionRuleResponse.ProtectionRules
181+
- OrganizationCustomRepoRoles.CustomRepoRoles # TODO: CustomRoles
182+
- OrganizationCustomRoles.CustomRepoRoles # TODO: Roles
183+
- PreReceiveHook.ConfigURL
184+
- ProjectV2ItemEvent.ProjectV2Item # TODO: ProjectsV2Item
185+
- Protection.RequireLinearHistory # TODO: RequiredLinearHistory
186+
- ProtectionRequest.RequireLinearHistory # TODO: RequiredLinearHistory
187+
- PullRequestComment.InReplyTo # TODO: InReplyToID
188+
- PullRequestReviewsEnforcementRequest.BypassPullRequestAllowancesRequest # TODO: BypassPullRequestAllowances
189+
- PullRequestReviewsEnforcementRequest.DismissalRestrictionsRequest # TODO: DismissalRestrictions
190+
- PullRequestReviewsEnforcementUpdate.BypassPullRequestAllowancesRequest # TODO: BypassPullRequestAllowances
191+
- PullRequestReviewsEnforcementUpdate.DismissalRestrictionsRequest # TODO: DismissalRestrictions
192+
- Reactions.MinusOne
193+
- Reactions.PlusOne
194+
- RepositoriesSearchResult.Repositories
195+
- RepositoriesSearchResult.Total
196+
- RepositoryVulnerabilityAlert.GitHubSecurityAdvisoryID
197+
- SCIMDisplayReference.Ref
198+
- SecretScanningAlertLocationDetails.Startline # TODO: StartLine
199+
- SecretScanningPatternOverride.Bypassrate # TODO: BypassRate
200+
- StarredRepository.Repository # TODO: Repo
201+
- Timeline.Requester # TODO: ReviewRequester
202+
- Timeline.Reviewer # TODO: RequestedReviewer
203+
- TopicsSearchResult.Topics # TODO: Items
204+
- TopicsSearchResult.Total
205+
- TotalCacheUsage.TotalActiveCachesUsageSizeInBytes # TODO: TotalActiveCachesSizeInBytes
206+
- TransferRequest.TeamID # TODO: TeamIDs
207+
- Tree.Entries
208+
- User.LdapDn # TODO: LDAPDN
209+
- UsersSearchResult.Total
210+
- UsersSearchResult.Users
211+
- WeeklyStats.Additions
212+
- WeeklyStats.Commits
213+
- WeeklyStats.Deletions
214+
- WeeklyStats.Week
146215
sliceofpointers:
147216
type: module
148217
description: Reports usage of []*string and slices of structs without pointers.
149-
original-url: github.com/google/go-github/v75/tools/sliceofpointers
218+
original-url: github.com/google/go-github/v76/tools/sliceofpointers
150219
exclusions:
151220
rules:
152221
- linters:

github/orgs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ type Organization struct {
100100
// MembersCanDeleteRepositories toggles whether members with admin permissions can delete a repository.
101101
MembersCanDeleteRepositories *bool `json:"members_can_delete_repositories,omitempty"`
102102
// MembersCanChangeRepoVisibility toggles whether members with admin permissions can change the visibility for a repository.
103-
MembersCanChangeRepoVisibility *bool `json:"members_can_change_repo_visiblilty,omitempty"`
103+
MembersCanChangeRepoVisibility *bool `json:"members_can_change_repo_visibility,omitempty"`
104104
// MembersCanInviteOutsideCollaborators toggles whether members with admin permissions can invite outside collaborators.
105105
MembersCanInviteOutsideCollaborators *bool `json:"members_can_invite_outside_collaborators,omitempty"`
106106
// MembersCanDeleteIssues toggles whether members with admin permissions can delete issues.

tools/jsonfieldname/go.mod

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module tools/jsonfieldname
2+
3+
go 1.24.0
4+
5+
require (
6+
github.com/golangci/plugin-module-register v0.1.1
7+
golang.org/x/tools v0.29.0
8+
)
9+
10+
require (
11+
golang.org/x/mod v0.22.0 // indirect
12+
golang.org/x/sync v0.10.0 // indirect
13+
)

tools/jsonfieldname/go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c=
2+
github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc=
3+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
4+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
5+
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
6+
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
7+
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
8+
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
9+
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
10+
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// Copyright 2025 The go-github AUTHORS. All rights reserved.
2+
//
3+
// Use of this source code is governed by a BSD-style
4+
// license that can be found in the LICENSE file.
5+
6+
// Package jsonfieldname is a custom linter to be used by
7+
// golangci-lint to find instances where the Go field name
8+
// of a struct does not match the JSON tag name.
9+
// It honors idiomatic Go initialisms and handles the
10+
// special case of `Github` vs `GitHub` as agreed upon
11+
// by the original author of the repo.
12+
package jsonfieldname
13+
14+
import (
15+
"go/ast"
16+
"go/token"
17+
"reflect"
18+
"regexp"
19+
"strings"
20+
21+
"github.com/golangci/plugin-module-register/register"
22+
"golang.org/x/tools/go/analysis"
23+
)
24+
25+
func init() {
26+
register.Plugin("jsonfieldname", New)
27+
}
28+
29+
// JSONFieldNamePlugin is a custom linter plugin for golangci-lint.
30+
type JSONFieldNamePlugin struct {
31+
allowedExceptions map[string]bool
32+
}
33+
34+
// Settings is the configuration for the jsonfieldname linter.
35+
type Settings struct {
36+
AllowedExceptions []string `json:"allowed-exceptions" yaml:"allowed-exceptions"`
37+
}
38+
39+
// New returns an analysis.Analyzer to use with golangci-lint.
40+
// It parses the "allowed-exceptions" section to determine which warnings to skip.
41+
func New(cfg any) (register.LinterPlugin, error) {
42+
allowedExceptions := map[string]bool{}
43+
44+
if cfg != nil {
45+
if settingsMap, ok := cfg.(map[string]any); ok {
46+
if exceptionsRaw, ok := settingsMap["allowed-exceptions"]; ok {
47+
if exceptionsList, ok := exceptionsRaw.([]any); ok {
48+
for _, item := range exceptionsList {
49+
if exception, ok := item.(string); ok {
50+
allowedExceptions[exception] = true
51+
}
52+
}
53+
}
54+
}
55+
}
56+
}
57+
58+
return &JSONFieldNamePlugin{allowedExceptions: allowedExceptions}, nil
59+
}
60+
61+
// BuildAnalyzers builds the analyzers for the JSONFieldNamePlugin.
62+
func (f *JSONFieldNamePlugin) BuildAnalyzers() ([]*analysis.Analyzer, error) {
63+
return []*analysis.Analyzer{
64+
{
65+
Name: "jsonfieldname",
66+
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.",
67+
Run: func(pass *analysis.Pass) (any, error) {
68+
return run(pass, f.allowedExceptions)
69+
},
70+
},
71+
}, nil
72+
}
73+
74+
// GetLoadMode returns the load mode for the JSONFieldNamePlugin.
75+
func (f *JSONFieldNamePlugin) GetLoadMode() string {
76+
return register.LoadModeSyntax
77+
}
78+
79+
func run(pass *analysis.Pass, allowedExceptions map[string]bool) (any, error) {
80+
for _, file := range pass.Files {
81+
ast.Inspect(file, func(n ast.Node) bool {
82+
if n == nil {
83+
return false
84+
}
85+
86+
switch t := n.(type) {
87+
case *ast.TypeSpec:
88+
structType, ok := t.Type.(*ast.StructType)
89+
if !ok {
90+
return true
91+
}
92+
structName := t.Name.Name
93+
94+
// Only check exported structs.
95+
if !ast.IsExported(structName) {
96+
return true
97+
}
98+
99+
for _, field := range structType.Fields.List {
100+
if field.Tag == nil || len(field.Names) == 0 {
101+
continue
102+
}
103+
104+
goField := field.Names[0]
105+
tagValue := strings.Trim(field.Tag.Value, "`")
106+
structTag := reflect.StructTag(tagValue)
107+
jsonTagName, ok := structTag.Lookup("json")
108+
if !ok || jsonTagName == "-" {
109+
continue
110+
}
111+
jsonTagName = strings.TrimSuffix(jsonTagName, ",omitempty")
112+
113+
checkGoFieldName(structName, goField.Name, jsonTagName, goField.Pos(), pass, allowedExceptions)
114+
}
115+
}
116+
117+
return true
118+
})
119+
}
120+
return nil, nil
121+
}
122+
123+
func checkGoFieldName(structName, goFieldName, jsonTagName string, tokenPos token.Pos, pass *analysis.Pass, allowedExceptions map[string]bool) {
124+
fullName := structName + "." + goFieldName
125+
if allowedExceptions[fullName] {
126+
return
127+
}
128+
129+
want, alternate := jsonTagToPascal(jsonTagName)
130+
if goFieldName != want && goFieldName != alternate {
131+
const msg = "change Go field name %q to %q for JSON tag %q in struct %q"
132+
pass.Reportf(tokenPos, msg, goFieldName, want, jsonTagName, structName)
133+
}
134+
}
135+
136+
func splitJSONTag(jsonTagName string) []string {
137+
if strings.Contains(jsonTagName, "_") {
138+
return strings.Split(jsonTagName, "_")
139+
}
140+
141+
if strings.Contains(jsonTagName, "-") {
142+
return strings.Split(jsonTagName, "-")
143+
}
144+
145+
if strings.ToLower(jsonTagName) == jsonTagName { // single word
146+
return []string{jsonTagName}
147+
}
148+
149+
s := camelCaseRE.ReplaceAllString(jsonTagName, "$1 $2")
150+
parts := strings.Fields(s)
151+
for i, part := range parts {
152+
parts[i] = strings.ToLower(part)
153+
}
154+
155+
return parts
156+
}
157+
158+
var camelCaseRE = regexp.MustCompile(`([a-z0-9])([A-Z])`)
159+
160+
func jsonTagToPascal(jsonTagName string) (want, alternate string) {
161+
parts := splitJSONTag(jsonTagName)
162+
alt := make([]string, len(parts))
163+
for i, part := range parts {
164+
alt[i] = part
165+
if part == "" {
166+
continue
167+
}
168+
upper := strings.ToUpper(part)
169+
if initialisms[upper] {
170+
parts[i] = upper
171+
alt[i] = upper
172+
} else if specialCase, ok := specialCases[upper]; ok {
173+
parts[i] = specialCase
174+
alt[i] = specialCase
175+
} else if possibleAlternate, ok := possibleAlternates[upper]; ok {
176+
parts[i] = possibleAlternate
177+
alt[i] = strings.ToUpper(part[:1]) + part[1:]
178+
} else {
179+
parts[i] = strings.ToUpper(part[:1]) + part[1:]
180+
alt[i] = parts[i]
181+
}
182+
}
183+
return strings.Join(parts, ""), strings.Join(alt, "")
184+
}
185+
186+
// Common Go initialisms that should be all caps.
187+
var initialisms = map[string]bool{
188+
"API": true, "ASCII": true,
189+
"CAA": true, "CAS": true, "CNAME": true, "CPU": true,
190+
"CSS": true, "CWE": true, "CVE": true, "CVSS": true,
191+
"DN": true, "DNS": true,
192+
"EOF": true, "EPSS": true,
193+
"GB": true, "GHSA": true, "GPG": true, "GUID": true,
194+
"HTML": true, "HTTP": true, "HTTPS": true,
195+
"ID": true, "IDE": true, "IDP": true, "IP": true, "JIT": true,
196+
"JSON": true,
197+
"LDAP": true, "LFS": true, "LHS": true,
198+
"MD5": true, "MS": true, "MX": true,
199+
"NPM": true, "NTP": true, "NVD": true,
200+
"OID": true, "OS": true,
201+
"PEM": true, "PR": true, "QPS": true,
202+
"RAM": true, "RHS": true, "RPC": true,
203+
"SAML": true, "SBOM": true, "SCIM": true,
204+
"SHA": true, "SHA1": true, "SHA256": true,
205+
"SKU": true, "SLA": true, "SMTP": true, "SNMP": true,
206+
"SPDX": true, "SPDXID": true, "SQL": true, "SSH": true,
207+
"SSL": true, "SSO": true, "SVN": true,
208+
"TCP": true, "TFVC": true, "TLS": true, "TTL": true,
209+
"UDP": true, "UI": true, "UID": true, "UUID": true,
210+
"URI": true, "URL": true, "UTF8": true,
211+
"VCF": true, "VCS": true, "VM": true,
212+
"XML": true, "XMPP": true, "XSRF": true, "XSS": true,
213+
}
214+
215+
var specialCases = map[string]string{
216+
"CPUS": "CPUs",
217+
"CWES": "CWEs",
218+
"GRAPHQL": "GraphQL",
219+
"HREF": "HRef",
220+
"IDS": "IDs",
221+
"IPS": "IPs",
222+
"OAUTH": "OAuth",
223+
"OPENAPI": "OpenAPI",
224+
"URLS": "URLs",
225+
}
226+
227+
var possibleAlternates = map[string]string{
228+
"ORGANIZATION": "Org",
229+
"ORGANIZATIONS": "Orgs",
230+
"REPOSITORY": "Repo",
231+
"REPOSITORIES": "Repos",
232+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright 2025 The go-github AUTHORS. All rights reserved.
2+
//
3+
// Use of this source code is governed by a BSD-style
4+
// license that can be found in the LICENSE file.
5+
6+
package jsonfieldname
7+
8+
import (
9+
"testing"
10+
11+
"golang.org/x/tools/go/analysis/analysistest"
12+
)
13+
14+
func TestRun(t *testing.T) {
15+
t.Parallel()
16+
testdata := analysistest.TestData()
17+
plugin, _ := New(nil)
18+
analyzers, _ := plugin.BuildAnalyzers()
19+
analysistest.Run(t, testdata, analyzers[0], "has-warnings", "no-warnings")
20+
}

0 commit comments

Comments
 (0)