go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/scripts/regen_golangci_config.py (about) 1 #!/usr/bin/env python3 2 # Copyright 2023 The LUCI Authors. 3 # 4 # Licensed under the Apache License, Version 2.0 (the "License"); 5 # you may not use this file except in compliance with the License. 6 # You may obtain a copy of the License at 7 # 8 # http://www.apache.org/licenses/LICENSE-2.0 9 # 10 # Unless required by applicable law or agreed to in writing, software 11 # distributed under the License is distributed on an "AS IS" BASIS, 12 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 # See the License for the specific language governing permissions and 14 # limitations under the License. 15 16 """Regenerates .golangci.yaml files based on .go-lintable config and templates. 17 18 golangci-lint tool has some usability problems when used in large 19 "monorepo-like" repositories, which either have many modules or want different 20 options in different directories: 21 https://github.com/golangci/golangci-lint/issues/828 22 https://github.com/golangci/golangci-lint/issues/2689 23 24 For luci-go, the existing style of sorting imports in most 25 subdirectories that belong to "some-project" is: 26 27 import ( 28 "stdlib1" 29 "stdlib2" 30 31 "github.com/third-party/pkg1" 32 "github.com/third-party/pkg2" 33 34 "go.chromium.org/luci/common/pkg1" 35 "go.chromium.org/luci/common/pkg2" 36 37 "go.chromium.org/luci/some-project/pkg1" 38 "go.chromium.org/luci/some-project/pkg2" 39 40 _ "blank1" 41 _ "blank2" 42 43 . "dot1" 44 . "dot2" 45 ) 46 47 Where dot imports are mostly used in tests and mostly related to GoConvey. 48 49 This is impossible to express via a single root .golangci.ymal config. 50 Especially considering that when "some-project-2" imports packages from 51 "some-project", they fall into"common LUCI imports" category and should be 52 bundled with the rest of LUCI common imports (this happens when "some-project" 53 exposes a client package that "some-project-2" is importing). 54 55 We could keep the root config and introduce per-project configs. But this end up 56 being dangerous, since golangci-lint searches for config in the *current 57 working directory* first, and only if not found, looks at Go package 58 directories under test. Tricium suggests to run e.g: 59 golangci-lint run --fix swarming/server/cfg/... 60 This already assumes luci-go repo root is the current working directory. This 61 invocation always picks up the root config, ignoring swarming/.golangci.yaml. 62 63 For that reason we remove the root config and instead generate a ton of 64 per-top-directory configs based on a list in `.go-lintable` file. This list 65 exists for two reasons: 66 * To configure what "templates" to use for generated configs. 67 * To simply the linter recipe by telling it where configs are. 68 """ 69 70 import argparse 71 import configparser 72 import os 73 import string 74 import subprocess 75 import sys 76 77 78 SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) 79 DEFAULT_ROOT = os.path.dirname(SCRIPTS_DIR) 80 LINTABLE_LIST = '.go-lintable' 81 82 83 def main(): 84 parser = argparse.ArgumentParser(description='Generates .golangci.yaml') 85 parser.add_argument( 86 '--root', 87 default=DEFAULT_ROOT, 88 type=str, 89 help='the root directory') 90 parser.add_argument( 91 '--check', 92 action='store_true', 93 help='if set, check existing configs are up-to-date') 94 95 args = parser.parse_args() 96 os.chdir(args.root) 97 98 # Discover all checked-in directories with *.go files. 99 out = subprocess.run( 100 ['git', 'ls-files', '.'], check=True, capture_output=True, text=True) 101 go_dirs = sorted(set( 102 (os.path.dirname(f) or '.') for f in out.stdout.splitlines() 103 if f.endswith('.go') 104 )) 105 106 # We don't want a root linter config, since it will override all other 107 # configs. It means root *.go files will be unlinted. This is fine, there's 108 # only one. 109 if '.' in go_dirs: 110 go_dirs.remove('.') 111 112 # Read the list of directories that should have a linter config. 113 try: 114 lint_roots = read_go_lintable(LINTABLE_LIST) 115 except (ValueError, OSError) as exc: 116 print('Bad %s: %s' % (LINTABLE_LIST, exc), file=sys.stderr) 117 return 1 118 119 # For every directory with a Go file, find what linted directories contain it. 120 covered_by = {} 121 for dirname in go_dirs: 122 covered_by[dirname] = [ 123 root for (root, _) in lint_roots 124 if dirname == root or dirname.startswith(root + os.path.sep) 125 ] 126 127 # All directories should be covered by at least one linter config. 128 uncovered = [ 129 path for (path, coverage) in covered_by.items() 130 if not coverage 131 ] 132 if uncovered: 133 print('These paths are not covered by any linter config:', file=sys.stderr) 134 for path in uncovered: 135 print(' %s' % path, file=sys.stderr) 136 print('Modify %s to cover them.' % LINTABLE_LIST, file=sys.stderr) 137 return 1 138 139 # All directories should be covered by at most one config. Otherwise 140 # golangci-lint may get confused and pick wrong config. 141 dups = False 142 for (path, coverage) in covered_by.items(): 143 if coverage and len(coverage) != 1: 144 dups = True 145 print( 146 'Path %s is covered by several linter configs: %s' % (path, coverage), 147 file=sys.stderr) 148 if dups: 149 print( 150 'Modify %s to make sure there are no intersections.' % LINTABLE_LIST, 151 file=sys.stderr) 152 return 1 153 154 # Actually generate all configs in requested lint_roots. 155 templates = {} 156 configs = {} 157 for (root, template) in lint_roots: 158 body = templates.get(template) 159 if body is None: 160 try: 161 body = read_template(template) 162 except (ValueError, OSError) as exc: 163 print('Bad template %s: %s' % (template, exc), file=sys.stderr) 164 return 1 165 templates[template] = body 166 configs[os.path.join(root, '.golangci.yaml')] = body.substitute(root=root) 167 168 # Check everything is already in place if running as a presubmit check. 169 if args.check: 170 errors = False 171 for path, body in configs.items(): 172 try: 173 with open(path, 'r') as f: 174 if f.read() != body: 175 print('Out-of-date linter config: %s' % path, file=sys.stderr) 176 errors = True 177 except FileNotFoundError: 178 print('Missing linter config: %s' % path, file=sys.stderr) 179 errors = True 180 if errors: 181 print('\nTo regenerate, run:', file=sys.stderr) 182 if args.root == DEFAULT_ROOT: 183 print(' %s' % __file__, file=sys.stderr) 184 else: 185 print(' %s --root %s' % (__file__, args.root), file=sys.stderr) 186 return 1 187 return 0 188 189 for path, body in configs.items(): 190 with open(path, 'w') as f: 191 f.write(body) 192 return 0 193 194 195 def read_go_lintable(path): 196 """Produces a list of pairs `[(path, template)]`.""" 197 config = configparser.ConfigParser() 198 config.read(path) 199 pairs = [] 200 for section in config: 201 if section == 'DEFAULT': 202 continue 203 template = config[section]['template'] 204 for path in config[section]['paths'].split(): 205 path = path.strip() 206 if path: 207 pairs.append((path, template)) 208 return pairs 209 210 211 def read_template(name): 212 with open(os.path.join(SCRIPTS_DIR, 'golangci', name)) as f: 213 return string.Template(f.read()) 214 215 216 if __name__ == '__main__': 217 sys.exit(main())