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())