| # Copyright 2023 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| def _get_affected_python_files(ctx): |
| return [ |
| f |
| for f in ctx.scm.affected_files() |
| if f.endswith(".py") and |
| # recipes.py is vendored from the recipe engine and should be |
| # ignored. |
| f != "recipes.py" |
| ] |
| |
| def ruff_format(ctx): |
| tools_dir = _install_tools(ctx) |
| |
| py_files = _get_affected_python_files(ctx) |
| if not py_files: |
| return |
| |
| original_contents = {} |
| |
| # First run `ruff check --fix --select=I` to sort imports, which `ruff |
| # format` doesn't support. |
| isort_procs = {} |
| for filepath in py_files: |
| original = str(ctx.io.read_file(filepath)) |
| original_contents[filepath] = original |
| isort_procs[filepath] = ctx.os.exec( |
| [ |
| tools_dir + "/ruff", |
| "check", |
| "--fix", |
| # Only check import sorting. |
| "--select=I", |
| "--stdin-filename", |
| filepath, |
| "-", |
| ], |
| stdin = original, |
| ) |
| |
| # Then run `ruff format`. |
| format_procs = {} |
| for filepath, proc in isort_procs.items(): |
| res = proc.wait() |
| format_procs[filepath] = ctx.os.exec( |
| [ |
| tools_dir + "/ruff", |
| "format", |
| "--stdin-filename", |
| filepath, |
| "-", |
| ], |
| stdin = res.stdout, |
| ) |
| |
| for filepath, proc in format_procs.items(): |
| res = proc.wait() |
| original = original_contents[filepath] |
| if res.stdout != original: |
| ctx.emit.finding( |
| filepath = filepath, |
| level = "error", |
| replacements = [res.stdout], |
| ) |
| |
| def ruff_lint(ctx): |
| tools_dir = _install_tools(ctx) |
| |
| py_files = _get_affected_python_files(ctx) |
| if not py_files: |
| return |
| |
| res = ctx.os.exec( |
| [ |
| tools_dir + "/ruff", |
| "check", |
| "--output-format", |
| "json", |
| "--show-fixes", |
| ] + |
| py_files, |
| ok_retcodes = [0, 1], |
| ).wait() |
| |
| for finding in json.decode(res.stdout): |
| filepath = finding["filename"] |
| if filepath.startswith(ctx.scm.root): |
| filepath = filepath[len(ctx.scm.root) + 1:] |
| |
| replacements = None |
| if finding.get("fix") and len(finding["fix"].get("edits", [])) == 1: |
| edit = finding["fix"]["edits"][0] |
| start_loc, end_loc = edit["location"], edit["end_location"] |
| replacements = [edit["content"]] |
| else: |
| start_loc, end_loc = finding["location"], finding["end_location"] |
| |
| ctx.emit.finding( |
| filepath = filepath, |
| message = "%s: %s" % (finding["code"], finding["message"]), |
| level = "error", |
| line = start_loc["row"], |
| col = start_loc["column"], |
| end_line = end_loc["row"], |
| end_col = end_loc["column"], |
| replacements = replacements, |
| ) |
| |
| def proto_format(ctx): |
| tools_dir = _install_tools(ctx) |
| |
| procs = [] |
| for p in ctx.scm.affected_files(): |
| if not p.endswith(".proto"): |
| continue |
| |
| # TODO(olivernewman): Use a single `buf format` invocation and the |
| # --diff option once shac knows how to parse diffs. |
| cmd = [ |
| tools_dir + "/buf", |
| "format", |
| "--exit-code", |
| p, |
| ] |
| procs.append((p, ctx.os.exec(cmd, ok_retcodes = [0, 100]))) |
| |
| for p, proc in procs: |
| res = proc.wait() |
| if res.retcode == 100: |
| ctx.emit.finding( |
| filepath = p, |
| level = "error", |
| replacements = [res.stdout], |
| ) |
| |
| def proto_field_numbering(ctx): |
| """Checks that recipe property protobuf files have clean field numbers. |
| |
| Recipe property protos need not subscribe to normal protobuf maintenance |
| conventions such as never deleting fields or changing field numbers, because |
| they are only used to de/serialize JSON-encoded protos, never binary-encoded |
| protos. |
| |
| So it's safe (and required, for code cleanliness) to keep proto fields |
| monotonically increasing with no jumps. |
| |
| Args: |
| ctx: A ctx instance. |
| """ |
| for f in ctx.scm.affected_files(): |
| # The recipe_proto directory contains protos that may be used in other |
| # places than just recipe properties, so we cannot safely renumber their |
| # fields. |
| if not f.endswith(".proto") or f.startswith("recipe_proto/"): |
| continue |
| res = ctx.os.exec( |
| [ |
| "python3", |
| "scripts/renumber_proto_fields.py", |
| "--dry-run", |
| f, |
| ], |
| ).wait() |
| if res.stdout != str(ctx.io.read_file(f)): |
| ctx.emit.finding( |
| filepath = f, |
| level = "error", |
| replacements = [res.stdout], |
| ) |
| |
| def _install_tools(ctx): |
| install_dir = ctx.scm.root + "/.tools" |
| ctx.os.exec( |
| [ctx.scm.root + "/scripts/install-shac-tools.sh", install_dir], |
| allow_network = True, |
| ).wait() |
| return install_dir |
| |
| def check_deps(ctx): |
| res = ctx.os.exec( |
| [ |
| "python3", |
| "scripts/cleanup_deps.py", |
| "--check", |
| "--json-output", |
| "-", |
| ], |
| ok_retcodes = [0, 65], |
| ).wait() |
| if res.retcode == 65: |
| for file in json.decode(res.stdout): |
| # TODO(olivernewman): Parse the diff so fixes can be applied with |
| # `shac fix`. |
| ctx.emit.finding( |
| filepath = file, |
| message = "DEPS are malformatted. Run ./scripts/cleanup_deps.py to fix.", |
| level = "error", |
| ) |
| |
| def recipe_style_guide(ctx): |
| """Enforces http://go/fuchsia-recipe-docs#style-guide.""" |
| procs = [] |
| for f in ctx.scm.affected_files(): |
| if f.endswith(".json") and ".expected" in f: |
| test_name = f.split("/")[-1].rsplit(".", 1)[0] |
| if " " in test_name: |
| # It would be nicer to parse the recipe file to find test case |
| # names because then we could emit the finding at the place |
| # where the test name is defined. But because tests are |
| # generated by arbitrary Python code, it's practically |
| # impossible to parse out their names in a foolproof way. |
| ctx.emit.finding( |
| filepath = f, |
| message = "Test name %r should not contain spaces" % test_name, |
| level = "error", |
| ) |
| |
| if not f.endswith(".py"): |
| continue |
| procs.append(ctx.os.exec(["python3", "scripts/enforce_style_guide.py", f])) |
| |
| for proc in procs: |
| res = proc.wait() |
| for finding in json.decode(res.stdout): |
| ctx.emit.finding(**finding) |
| |
| def prohibited_properties(ctx): |
| """ Checks that recipe protobufs do not contain prohibited properties |
| https://crrev.com/783ca64715fa405f0a4339d1ecd8b5a8d49caa7f/buildbucket/appengine/rpc/schedule_build.go#223 |
| |
| Recipes with prohibited properties can cause issues with led builds after |
| migrating to real builds because properties with prohibited names can not |
| be changed with `led edit`. |
| """ |
| |
| # Renaming schema for prohibited properties. |
| # Each occurrence of a prohibited property (keys) must be replaced with its corresponding value. |
| # We only need to be concerned with properties that can be set in protobufs. |
| renaming_schema = { |
| "repository": "remote", |
| "branch": "git_branch", |
| "buildername": "builder_name", |
| "buildbucket": "build_bucket", |
| } |
| for file in ctx.scm.affected_files(): |
| if not file.endswith(".proto"): |
| continue |
| if file.startswith("recipe_modules/"): |
| continue |
| contents = str(ctx.io.read_file(file)) |
| for i, line in enumerate(contents.splitlines()): |
| # Check line for declaration of prohibited properties. |
| # Only check for top level properties. |
| match = ctx.re.match(r"^ (\w+) (\w+) =", line) |
| if not match: |
| continue |
| property_name = match.groups[2] |
| if property_name not in renaming_schema: |
| continue |
| ctx.emit.finding( |
| level = "error", |
| filepath = file, |
| line = i + 1, |
| col = len(match.groups[1]) + 4, |
| end_col = len(match.groups[1]) + 4 + len(match.groups[2]), |
| message = "%r is a prohibited property. Replace this with %r." % ( |
| property_name, |
| renaming_schema[property_name], |
| ), |
| replacements = [renaming_schema[property_name]], |
| ) |
| |
| shac.register_check(shac.check(ruff_format, formatter = True)) |
| shac.register_check(shac.check(proto_format, formatter = True)) |
| shac.register_check(shac.check(proto_field_numbering, formatter = True)) |
| |
| shac.register_check(prohibited_properties) |
| shac.register_check(ruff_lint) |
| shac.register_check(check_deps) |
| shac.register_check(recipe_style_guide) |