blob: 98f9a56ddc97d1547a924fc3d66bf4e98f6afd71 [file] [log] [blame]
# Copyright 2012 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Run lint checks on the specified files."""
import collections
import functools
import importlib.machinery
import importlib.util
import itertools
import json
import logging
import os
from pathlib import Path
import stat
from typing import Callable, Dict, List, Optional, Union
from chromite.cli import analyzers
from chromite.cli import command
from chromite.lib import commandline
from chromite.lib import constants
from chromite.lib import cros_build_lib
from chromite.lib import git
from chromite.lib import json_lib
from chromite.lib import osutils
from chromite.lib import parallel
from chromite.lib import path_util
from chromite.lint import linters
from chromite.utils import path_filter
from chromite.utils import timer
from chromite.utils.parser import shebang
def _GetProjectPath(path: Path) -> Path:
"""Find the absolute path of the git checkout that contains |path|."""
ret = git.FindGitTopLevel(path)
if ret:
return Path(ret)
else:
# Maybe they're running on a file outside of a checkout.
# e.g. cros lint ~/foo.py /tmp/test.py
return path.parent
def _get_file_data(path: Union[str, os.PathLike], commit: Optional[str]) -> str:
"""Read the file data for |path| either from disk or git |commit|."""
if commit:
return git.GetObjectAtRev(None, f"./{path}", commit)
else:
return Path(path).read_text(encoding="utf-8")
def _GetPylintrc(path: Path) -> Path:
"""Locate pylintrc or .pylintrc file that applies to |path|.
If not found - use the default.
"""
def _test_func(pylintrc):
dotpylintrc = pylintrc.with_name(".pylintrc")
# Only allow one of these to exist to avoid confusing which one is used.
if pylintrc.exists() and dotpylintrc.exists():
cros_build_lib.Die(
'%s: Only one of "pylintrc" or ".pylintrc" is allowed',
pylintrc.parent,
)
return pylintrc.exists() or dotpylintrc.exists()
end_path = _GetProjectPath(path.parent).parent
ret = osutils.FindInPathParents(
"pylintrc", path.parent, test_func=_test_func, end_path=end_path
)
if ret:
return ret if ret.exists() else ret.with_name(".pylintrc")
return constants.CHROMITE_DIR / "pylintrc"
def _GetPythonPath():
"""Return the set of Python library paths to use."""
# Carry through custom PYTHONPATH that the host env has set.
return os.environ.get("PYTHONPATH", "").split(os.pathsep) + [
# Ideally we'd modify meta_path in pylint to handle our virtual chromite
# module, but that's not possible currently. We'll have to deal with
# that at some point if we want `cros lint` to work when the dir is not
# named 'chromite'.
str(constants.SOURCE_ROOT),
]
# The mapping between the "cros lint" --output-format flag and cpplint.py
# --output flag.
CPPLINT_OUTPUT_FORMAT_MAP = {
"colorized": "emacs",
"msvs": "vs7",
"parseable": "emacs",
}
# Default category filters to pass to cpplint.py when invoked via `cros lint`.
#
# `-foo/bar` means "don't show any lints from category foo/bar".
# See `cpplint.py --help` for more explanation of category filters.
CPPLINT_DEFAULT_FILTERS = ("-runtime/references",)
# The mapping between the "cros lint" --output-format flag and shellcheck
# flags.
# Note that the msvs mapping here isn't quite VS format, but it's closer than
# the default output.
SHLINT_OUTPUT_FORMAT_MAP = {
"colorized": ["--color=always"],
"msvs": ["--format=gcc"],
"parseable": ["--format=gcc"],
}
def _ToolRunCommand(cmd, debug, **kwargs):
"""Run the linter with common run args set as higher levels expect."""
return cros_build_lib.run(
cmd, check=False, print_cmd=debug, debug_level=logging.NOTICE, **kwargs
)
def _ConfLintFile(path, output_format, debug, relaxed: bool, commit: str):
"""Determine applicable .conf syntax and call the appropriate handler."""
ret = cros_build_lib.CompletedProcess(f'cros lint "{path}"', returncode=0)
if not os.path.isfile(path):
return ret
# Check for the description and author lines present in upstart configs.
with open(path, "rb") as file:
tokens_to_find = {b"author", b"description"}
for line in file:
try:
token = line.split()[0]
except IndexError:
continue
try:
tokens_to_find.remove(token)
except KeyError:
continue
if not tokens_to_find:
logging.warning(
"Found upstart .conf in a directory other than init or "
"upstart."
)
return _UpstartLintFile(
path, output_format, debug, relaxed, commit
)
return ret
@functools.lru_cache(maxsize=None)
def _cpplint_module():
"""Load the cpplint.py module.
We can't import the module directly because it lives in a non-standard path
(depot_tools), and we don't want to add that to sys.path because it has a
lot of unrelated Python module we don't want polluting our normal search.
"""
modname = "cpplint"
cpplint = str(constants.DEPOT_TOOLS_DIR / "cpplint.py")
loader = importlib.machinery.SourceFileLoader(modname, cpplint)
spec = importlib.util.spec_from_loader(modname, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
def _CpplintFile(path, output_format, _debug, _relaxed: bool, commit: str):
"""Returns result of running cpplint on |path|."""
result = cros_build_lib.CompletedProcess(f'cpplint "{path}"', returncode=0)
# pylint: disable=protected-access
# The cpplint API doesn't expose state that we need. We're pinned to a
# specific version, so we don't exactly have to worry about it changing.
cpplint = _cpplint_module()
cpplint._SetFilters(",".join(CPPLINT_DEFAULT_FILTERS))
cpplint._SetOutputFormat(
"emacs"
if output_format == "default"
else CPPLINT_OUTPUT_FORMAT_MAP[output_format]
)
if cpplint.ProcessConfigOverrides(str(path)):
data = _get_file_data(path, commit)
lines = data.split("\n")
ext = path.suffix[1:] or str(path)
cpplint.ProcessFileData(str(path), ext, lines, cpplint.Error)
result.returncode = 1 if cpplint._cpplint_state.error_count else 0
return result
def _PylintFile(path, output_format, debug, _relaxed: bool, _commit: str):
"""Returns result of running pylint on |path|."""
pylint = constants.CHROMITE_SCRIPTS_DIR / "pylint"
pylintrc = _GetPylintrc(path)
cmd = [pylint, "--rcfile=%s" % pylintrc]
if output_format != "default":
cmd.append("--output-format=%s" % output_format)
cmd.append(path)
extra_env = {
"PYTHONPATH": ":".join(_GetPythonPath()),
}
return _ToolRunCommand(cmd, debug, extra_env=extra_env)
def _GnlintFile(path, _, _debug, _relaxed: bool, commit: str):
"""Returns result of running gnlint on |path|."""
result = cros_build_lib.CompletedProcess(f'gnlint "{path}"', returncode=0)
data = _get_file_data(path, commit)
if linters.gnlint.Data(data, path):
result.returncode = 1
return result
def _GolintFile(path, _, debug, _relaxed: bool, _commit: str):
"""Returns result of running golint on |path|."""
# Try using golint if it exists.
try:
cmd = ["golint", "-set_exit_status", path]
return _ToolRunCommand(cmd, debug)
except cros_build_lib.RunCommandError:
logging.notice("Install golint for additional go linting.")
return cros_build_lib.CompletedProcess(f'gofmt "{path}"', returncode=0)
def _JsonLintFile(path, _output_format, _debug, _relaxed: bool, commit: str):
"""Returns result of running json lint checks on |path|."""
result = cros_build_lib.CompletedProcess(
f'python -mjson.tool "{path}"', returncode=0
)
data = _get_file_data(path, commit)
# See if it validates.
try:
json_lib.loads(data)
except ValueError as e:
result.returncode = 1
logging.notice("%s: %s", path, e)
return result
def _MarkdownLintFile(
path, _output_format, _debug, _relaxed: bool, commit: str
):
"""Returns result of running lint checks on |path|."""
result = cros_build_lib.CompletedProcess(
f'mdlint(internal) "{path}"', returncode=0
)
data = _get_file_data(path, commit)
# Check whitespace.
if not linters.whitespace.Data(data, Path(path)):
result.returncode = 1
return result
def _ShellLintFile(
path,
output_format,
debug,
_relaxed: bool,
_commit: str,
gentoo_format=False,
):
"""Returns result of running lint checks on |path|.
Args:
path: The path to the script on which to run the linter.
output_format: The format of the output that the linter should emit. See
|SHLINT_OUTPUT_FORMAT_MAP|.
debug: Whether to print out the linter command.
gentoo_format: Whether to treat this file as an ebuild style script.
Returns:
A CompletedProcess object.
"""
# Instruct shellcheck to run itself from the shell script's dir. Note that
# 'SCRIPTDIR' is a special string that shellcheck rewrites to the dirname of
# the given path.
extra_checks = [
"avoid-nullary-conditions", # SC2244
"check-unassigned-uppercase", # Include uppercase in SC2154
"require-variable-braces", # SC2250
]
if not gentoo_format:
extra_checks.append("quote-safe-variables") # SC2248
cmd = [
# pylint: disable=protected-access
linters.shell._find_shellcheck(),
"--source-path=SCRIPTDIR",
"--enable=%s" % ",".join(extra_checks),
]
if output_format != "default":
cmd.extend(SHLINT_OUTPUT_FORMAT_MAP[output_format])
cmd.append("-x")
# No warning for using local with /bin/sh.
cmd.append("--exclude=SC3043")
if gentoo_format:
# ebuilds don't explicitly export variables or contain a shebang.
cmd.append("--exclude=SC2148")
# ebuilds always use bash.
cmd.append("--shell=bash")
cmd.append(path)
lint_result = _ToolRunCommand(cmd, debug)
# Check whitespace.
if not linters.whitespace.Data(osutils.ReadFile(path), Path(path)):
lint_result.returncode = 1
return lint_result
def _GentooShellLintFile(
path, output_format, debug, relaxed: bool, commit: str
):
"""Run shell checks with Gentoo rules."""
return _ShellLintFile(
path, output_format, debug, relaxed, commit, gentoo_format=True
)
def _SeccompPolicyLintFile(
path, _output_format, debug, _relaxed: bool, commit: str
):
"""Run the seccomp policy linter."""
if commit:
stdin = _get_file_data(path, commit)
else:
stdin = ""
return _ToolRunCommand(
[
os.path.join(
constants.SOURCE_ROOT,
"src",
"platform",
"minijail",
"tools",
"seccomp_policy_lint.py",
),
"--assume-filename",
path,
"/dev/stdin" if commit else path,
],
debug,
input=stdin,
)
def _UpstartLintFile(path, _output_format, _debug, relaxed: bool, commit: str):
"""Run lints on upstart configs."""
# Skip .conf files that aren't in an init parent directory.
ret = cros_build_lib.CompletedProcess(f'cros lint "{path}"', returncode=0)
data = _get_file_data(path, commit)
if not linters.upstart.Data(data, Path(path), relaxed):
ret.returncode = 1
return ret
def _DirMdLintFile(path, _output_format, debug, _relaxed: bool, commit: str):
"""Run the dirmd linter."""
data = _get_file_data(path, commit)
ret = _ToolRunCommand(
[constants.DEPOT_TOOLS_DIR / "dirmd", "parse"],
debug,
input=data,
stdout=True,
)
if ret.returncode:
results = json.loads(ret.stdout)
print(path, results["stdin"]["error"])
return ret
def _OwnersLintFile(path, _output_format, _debug, _relaxed: bool, commit: str):
"""Run lints on OWNERS files."""
ret = cros_build_lib.CompletedProcess(f'cros lint "{path}"', returncode=0)
data = _get_file_data(path, commit)
if not linters.owners.lint_data(Path(path), data):
ret.returncode = 1
return ret
def _TextprotoLintFile(
path, _output_format, _debug, _relaxed: bool, _commit: str
) -> cros_build_lib.CompletedProcess:
"""Run lints on OWNERS files."""
ret = cros_build_lib.CompletedProcess(f'cros lint "{path}"', returncode=0)
# go/textformat-spec#text-format-files says to use .textproto.
if os.path.splitext(path)[1] != ".textproto":
logging.error(
"%s: use '.textproto' extension for text proto messages", path
)
ret.returncode = 1
# TODO(build): Assert file header has `proto-file:` and `proto-message:`
# keywords in it. Also allow `proto-import:`, but ban all other `proto-`
# directives (in case of typos). go/textformat-schema
return ret
def _WhitespaceLintFile(
path, _output_format, _debug, _relaxed: bool, commit: str
):
"""Returns result of running basic whitespace checks on |path|."""
result = cros_build_lib.CompletedProcess(
f'whitespace(internal) "{path}"', returncode=0
)
data = _get_file_data(path, commit)
# Check whitespace.
if not linters.whitespace.Data(data, Path(path)):
result.returncode = 1
return result
def _NonExecLintFile(path, _output_format, _debug, _relaxed: bool, commit: str):
"""Check file permissions on |path| are -x."""
result = cros_build_lib.CompletedProcess(
f'stat(internal) "{path}"', returncode=0
)
if commit:
entries = git.LsTree(
os.path.dirname(path) or None, commit, [os.path.basename(path)]
)
if entries and entries[0].is_exec:
result.returncode = 1
logging.notice(
"%s: file should not be executable; chmod -x to fix", path
)
else:
# Ignore symlinks.
st = os.lstat(path)
if stat.S_ISREG(st.st_mode):
mode = stat.S_IMODE(st.st_mode)
if mode & 0o111:
result.returncode = 1
logging.notice(
"%s: file should not be executable; chmod -x to fix", path
)
return result
def _MakeDefaultsLintFile(
path, _output_format, _debug, _relaxed: bool, commit: str
):
"""Lint make.defaults files."""
result = cros_build_lib.CompletedProcess(
f'cros lint "{path}"', returncode=0
)
data = _get_file_data(path, commit)
issues = linters.make_defaults.Data(data)
for issue in issues:
logging.error("%s: %s", path, issue)
if issues:
result.returncode = 1
return result
def _PortageLayoutConfLintFile(
path, _output_format, _debug, _relaxed: bool, commit: str
):
"""Lint metadata/layout.conf files."""
result = cros_build_lib.CompletedProcess(
f'cros lint "{path}"', returncode=0
)
data = _get_file_data(path, commit)
issues = linters.portage_layout_conf.Data(data, path)
for issue in issues:
logging.error("%s: %s", path, issue)
if issues:
result.returncode = 1
return result
def _BreakoutDataByTool(map_to_return, path) -> None:
"""Maps a tool method to the content of the |path|."""
# Detect by content of the file itself.
try:
with open(path, "rb") as fp:
# We read 128 bytes because that's the Linux kernel's current limit.
# Look for BINPRM_BUF_SIZE in fs/binfmt_script.c.
data = fp.read(128)
try:
result = shebang.parse(data)
except ValueError:
# If the file doesn't have a shebang, nothing to do.
return
basename = os.path.basename(result.real_command)
if basename.startswith("python") or basename.startswith("vpython"):
for tool in _TOOL_MAP[_PYTHON_EXT]:
map_to_return.setdefault(tool, []).append(path)
elif basename in ("sh", "dash", "bash"):
for tool in _TOOL_MAP[_SHELL_EXT]:
map_to_return.setdefault(tool, []).append(path)
except IOError as e:
logging.debug("%s: reading initial data failed: %s", path, e)
# These are used in _BreakoutDataByTool, so add a constant to keep in sync.
_PYTHON_EXT = frozenset({"*.py"})
_SHELL_EXT = frozenset({"*.sh"})
# Map file names to a tool function.
# NB: Order matters as earlier entries override later ones.
_TOOL_MAP = collections.OrderedDict(
(
(frozenset({"DIR_METADATA"}), (_DirMdLintFile, _NonExecLintFile)),
(frozenset({"OWNERS*"}), (_OwnersLintFile, _NonExecLintFile)),
# NB: Must come before *.conf rules below.
(
frozenset({"init/*.conf", "upstart/*.conf"}),
(
_UpstartLintFile,
_NonExecLintFile,
),
),
(
frozenset({"metadata/layout.conf"}),
(_PortageLayoutConfLintFile, _NonExecLintFile),
),
# Note these are defined to keep in line with cpplint.py. Technically,
# we could include additional ones, but cpplint.py would just filter
# them out.
(frozenset({"*.c"}), (_WhitespaceLintFile, _NonExecLintFile)),
# Remember to change cros_format to align supported extensions.
# LINT.IfChange(cpp_extensions)
(
frozenset({"*.cc", "*.cpp", "*.cxx", "*.h", "*.hh"}),
(
_CpplintFile,
_NonExecLintFile,
),
),
# LINT.ThenChange(cros_format.py:cpp_extensions)
(frozenset({"*.conf", "*.conf.in"}), (_ConfLintFile, _NonExecLintFile)),
(frozenset({"*.gn", "*.gni"}), (_GnlintFile, _NonExecLintFile)),
(
frozenset({"*.json", "*.jsonproto"}),
(_JsonLintFile, _NonExecLintFile),
),
(_PYTHON_EXT, (_PylintFile,)),
(frozenset({"*.go"}), (_GolintFile, _NonExecLintFile)),
(_SHELL_EXT, (_ShellLintFile,)),
(
frozenset({"*.ebuild", "*.eclass", "*.bashrc"}),
(
_GentooShellLintFile,
_NonExecLintFile,
),
),
(
frozenset({"make.defaults"}),
(_WhitespaceLintFile, _NonExecLintFile, _MakeDefaultsLintFile),
),
(frozenset({"*.md"}), (_MarkdownLintFile, _NonExecLintFile)),
# Yes, there's a lot of variations here. We catch these specifically to
# throw errors and force people to use the single correct name.
(
frozenset(
{
"*.pb",
"*.pb.txt",
"*.pb.text",
"*.pbtxt",
"*.pbtext",
"*.protoascii",
"*.prototxt",
"*.prototext",
"*.textpb",
"*.txtpb",
"*.textproto",
"*.txtproto",
}
),
(
_TextprotoLintFile,
_NonExecLintFile,
),
),
(
frozenset({"*.policy"}),
(
_SeccompPolicyLintFile,
_WhitespaceLintFile,
_NonExecLintFile,
),
),
(frozenset({"*.te"}), (_WhitespaceLintFile, _NonExecLintFile)),
(
frozenset(
{
"Dockerfile",
"Makefile",
"*.bzl",
"*.cfg",
"*.config",
"*.css",
"*.grd",
"*.gyp",
"*.gypi",
"*.htm",
"*.html",
"*.ini",
"*.jpeg",
"*.jpg",
"*.js",
"*.l",
"*.mk",
"*.patch",
"*.png",
"*.proto",
"*.rules",
"*.service",
"*.star",
"*.svg",
"*.toml",
"*.txt",
"*.vpython",
"*.vpython3",
"*.xml",
"*.xtb",
"*.y",
"*.yaml",
"*.yml",
}
),
(_NonExecLintFile,),
),
)
)
def _BreakoutFilesByTool(files: List[Path]) -> Dict[Callable, List[Path]]:
"""Maps a tool method to the list of files to process."""
map_to_return = {}
for f in files:
abs_f = f.absolute()
for patterns, tools in _TOOL_MAP.items():
if any(abs_f.match(x) for x in patterns):
for tool in tools:
map_to_return.setdefault(tool, []).append(f)
break
else:
if f.is_file():
_BreakoutDataByTool(map_to_return, f)
return map_to_return
def _Dispatcher(
output_format, debug, relaxed: bool, commit: str, tool, path: Path
) -> int:
"""Call |tool| on |path| and take care of coalescing exit codes/output."""
try:
result = tool(path, output_format, debug, relaxed, commit)
except UnicodeDecodeError:
logging.error("%s: file is not UTF-8 compatible", path)
return 1
return 1 if result.returncode else 0
@command.command_decorator("lint")
class LintCommand(analyzers.AnalyzerCommand):
"""Run lint checks on the specified files."""
EPILOG = """
For some file formats, see the CrOS style guide:
https://www.chromium.org/chromium-os/developer-library/reference/style-guides/style-guides/
Supported files: %s
NB: Not all linters work with `--commit` yet.
""" % (
" ".join(sorted(itertools.chain(*_TOOL_MAP))),
)
# The output formats supported by cros lint.
OUTPUT_FORMATS = ("default", "colorized", "msvs", "parseable")
@classmethod
def AddParser(cls, parser: commandline.ArgumentParser) -> None:
super().AddParser(parser)
parser.add_argument(
"--output",
default="default",
choices=LintCommand.OUTPUT_FORMATS,
help="Output format to pass to the linters. Supported "
"formats are: default (no option is passed to the "
"linter), colorized, msvs (Visual Studio) and "
"parseable.",
)
parser.add_argument(
"--relaxed",
default=False,
action="store_true",
help="Disable some strict checks. This is used for "
"cases like builds where a more permissive "
"behavior is desired.",
)
def _Run(self):
# Hack "pre-submit" to "HEAD" when being run by repohooks/pre-upload.py
# --pre-submit. We should drop support for this once we merge repohooks
# into `cros` with proper preupload/presubmit.
commit = (
"HEAD"
if self.options.commit == "pre-submit"
else self.options.commit
)
# Ignore symlinks.
files = []
syms = []
if commit:
for f in git.LsTree(None, commit, self.options.files):
if f.is_symlink:
syms.append(f.name)
else:
files.append(f.name)
else:
for f in path_util.ExpandDirectories(self.options.files):
if f.is_symlink():
syms.append(f)
else:
files.append(f)
if syms:
logging.info("Ignoring symlinks: %s", syms)
if not files:
# Running with no arguments is allowed to make the repo upload hook
# simple, but print a warning so that if someone runs this manually
# they are aware that nothing happened.
logging.warning("No files found to process. Doing nothing.")
return 0
# Ignore generated files. Some tools can do this for us, but not all,
# and it'd be faster if we just never spawned the tools in the first
# place. Prepend to exclude them early: a more general filter like
# `--include "*.py"` earlier in the list would otherwise nerf this.
# TODO(build): Move to a centralized configuration somewhere.
self.options.filter.rules[:0] = (
# Compiled python protobuf bindings.
path_filter.exclude("*_pb2.py"),
path_filter.exclude("*_pb2_grpc.py"),
)
files = self.options.filter.filter(files)
if not files:
logging.warning("All files are excluded. Doing nothing.")
return 0
tool_map = _BreakoutFilesByTool(files)
dispatcher = functools.partial(
_Dispatcher,
self.options.output,
self.options.debug,
self.options.relaxed,
commit,
)
# If we filtered out all files, do nothing.
# Special case one file (or fewer) as it's common -- faster to avoid the
# parallel startup penalty.
tasks = []
for tool, files in tool_map.items():
tasks.extend([tool, x] for x in files)
if not tasks:
return 0
elif len(tasks) == 1:
tool, files = next(iter(tool_map.items()))
return dispatcher(tool, files[0])
else:
# Run the tool in parallel on the files.
return sum(
parallel.RunTasksInProcessPool(
dispatcher, tasks, processes=self.options.jobs
)
)
def Run(self):
with timer.Timer() as t:
ret = self._Run()
if ret:
logging.error("Found lint errors in %i files in %s.", ret, t)
return 1 if ret else 0