为 Fuchsia 创建静态分析器

Shac(可扩缩的封闭式分析和检查)是一种统一且符合人体工程学的工具和框架,用于编写和运行静态分析检查。您可以在 shac-documentation 中找到该工具的来源。Shac 检查是用 Starlark 编写的。

设置

Shac 脚本实现位于 Fuchsia 的 //scripts/shac 目录中。

  • shac 检查以接受 ctx 实参的 Starlark 函数的形式实现。使用此 ctx 实参可访问 shac 标准库。
  • 如果您的检查是特定于语言的,则应将其放在特定于语言的文件中(例如:rust.star、go.star、fidl.star)。如果您的检查是特定于语言的,但没有 language.star 文件,请创建一个。如果它是通用的,请使用 title.star(其中 title 是检查函数的名称)。

简单示例

以下示例是一个针对所有文件的静态分析器,它会在存在“http://”字符串的更改上创建非阻塞的 Gerrit 警告注释,提示用户改用“https://”。

def http_links(ctx):
    for path, meta in ctx.scm.affected_files().items():
        for num, line in meta.new_lines():
            matches = ctx.re.allmatches(r"(http://)\w+", line)
            if not matches:
                continue
            for match in matches:
                ctx.emit.finding(
                    message = "Avoid http:// links, prefer https://",
                    # Change to "error" if the check should block presubmit.
                    level = "warning",
                    filepath = path,
                    line = num,
                    col = match.offset + 1,
                    end_col = match.offset + 1 + len(match.groups[1]),
                    replacements = ["https://"],
                )

详细了解 shac 对 emit.findings 的实现。

请注意,Shac 不会自动发现检查。为了使检查能够运行,必须在 //scripts/shac/main.star 中将检查函数传递给 shac.register_check():

load("./http_links.star", "http_links")  # NEW

...

def register_all_checks():
    ...
    shac.register_check(http_links)  # NEW
    ...

在已包含其他检查的文件中实现新检查时,您或许可以在该文件中注册新检查。例如,//scripts/shac/fidl.star 具有一个 register_fidl_checks() 函数,该函数从 //scripts/shac/main.star 调用。向 fidl.star 添加新的 FIDL 检查,并在同一文件中的 register_fidl_checks() 函数中注册这些检查。

高级示例

如果存在可执行检查的现有工具,或者检查逻辑很复杂(例如,不仅仅是子字符串搜索),则使用子进程非常有用。Starlark 有意限制了功能,以鼓励在具有自己的单元测试的独立工具中编写复杂的业务逻辑。

以下示例展示了在单独的 Python 脚本中实现并作为子进程运行的 JSON 格式化程序。

该检查会计算格式化后的内容,并将其传递给 ctx.emit.finding() 函数的 replacements 实参,而不是重写格式不正确的文件。出于以下原因,所有格式检查都必须以这种方式实现:

  • 检查运行的子进程不得写入签出目录中的文件。这样可以防止行为不当的工具做出意外更改,并确保可以安全地并行运行多项检查,而不会出现竞态条件。(请注意,文件系统沙盒仅在 Linux 上强制执行)。
  • Shac 旨在与其他需要向用户建议更改(例如在 Gerrit 中)而不是自动应用更改的自动化工具轻松集成,因此为了使这些使用情形能够正常运行,必须将差异传递到 shac 中,而不是通过子进程应用。
import json
import sys


def main():
    # Accepts one positional argument referring to the file to format.
    path = sys.args[1]
    with open(path) as f:
        original = f.read()
    # Always use 2-space indents and a trailing blank line.
    formatted = json.dumps(json.loads(original), indent=2) + "\n"
    if formatted == original:
        sys.exit(0)
    else:
        print(json.dumps(doc, indent=2) + "\n")
        sys.exit(1)


if __name__ == "__main__":
    main()
load("./common.star", "FORMATTER_MSG", "cipd_platform_name", "get_fuchsia_dir", "os_exec")

def json_format(ctx):
    # Launch processes in parallel.
    procs = {}
    for f in ctx.scm.affected_files():
        if not f.endswith(".json"):
            continue
        # Call fuchsia-specific `os_exec` function instead of
        # `ctx.os.exec()` to ensure proper executable resolution.
        # `os_exec` starts the subprocess but does not block.
        procs[f] = os_exec(ctx, [
            "%s/prebuilt/third_party/python3/%s/bin/python3" % (
                get_fuchsia_dir(ctx),
                cipd_platform_name(ctx),
            ),
            "scripts/shac/json_format.py",
            f,
        ])

    for f, proc in procs.items():
        # wait() blocks until the process completes.
        res = proc.wait()
        if proc.retcode != 0:
            ctx.emit.finding(
                level = "error",
                filepath = f,
                # FORMATTER_MSG is the standard message for formatters
                # in fuchsia.git.
                message = FORMATTER_MSG,
                # json_format.py prints the formatted file contents to stdout.
                # Passing it to `replacements` is necessary for shac to know
                # how to apply the fix.
                replacements = [res.stdout],
            )

# TODO: call this somewhere
shac.register_check(shac.check(
    json_format,
    # Mark the check as a formatter. Only checks with `formatter = True`
    # get run by `fx format-code`.
    formatter = True,
))

性能优化

某些格式化程序内置了对同时验证多个文件格式的支持,这些验证通常在内部并行化,因此比启动单独的子进程来检查每个文件要快得多。在这种情况下,您可以对所有文件运行一次格式化程序,以获取格式不正确的文件列表,然后仅对格式不正确的文件进行迭代,以获取格式化结果(而不是对所有文件进行迭代)。

示例:对于 rustfmt,首先运行 rustfmt --check --files-with-diff <all rust files> 以获取格式不正确的文件列表,然后分别对每个文件运行 rustfmt 以获取格式化结果。

如果格式化程序没有用于将格式化结果打印到 stdout 的试运行模式:格式化程序子进程将无法写入结账。不过,有些格式化程序会无条件写入文件。在这种情况下,您需要将每个文件复制到子进程可以写入的临时目录中,然后格式化临时文件并报告其内容,例如 buildifier。

默认情况下,如果子进程生成非零返回代码,os_exec 会引发不可恢复的错误。如果预期会返回非零返回代码,您可以使用 ok_retcodes 参数,例如,如果格式化程序在文件未格式化时生成返回代码 1,则 ok_retcodes = [0, 1] 可能比较合适。

在本地运行检查

如需在本地对更改中的修改文件运行静态分析检查,请使用 fx lint:

  • fx lint 会对修改后的文件运行所有树内 lint 工具和分析器。
  • fx lint --fix 会自动应用检查发出的建议替换项。
  • fx lint --only <check_name> 仅运行特定检查。
  • fx lint --all 会对代码库中的所有跟踪文件运行静态分析。

在本地检查开发期间,建议通过运行 fx lint 或直接通过 fx host-tool shac check <file> 调用 shac 来测试检查。我们来创建一个场景,以便测试上述 http_links 检查:

  1. 找到当前违反检查规则的文件,如果不存在,则创建一个新文件,例如:echo "http://example.com" > temp.txt
  2. fx host-tool shac check --only http_links temp.txt
    • 此命令应会失败,并输出突出显示“http://”的文件内容
    • --only 会导致 shac 仅运行 http_links 检查,排除其他检查,因为在此实例中,我们只关心测试 http_links,而不关心其他检查的结果
  3. fx host-tool shac fix --only http_links temp.txt 应将 http:// 更改为 https://
  4. fx host-tool shac check --only http_links temp.txt 现在应该能通过
  5. fx host-tool shac check --only http_links --all
    • 在树中的所有文件(除了 git 忽略的文件或在 //shac.textproto 中忽略的文件)上运行,而不仅仅是在更改的文件上运行
    • 如果此操作失败并出现错误,您需要在提交检查之前,在同一提交中或单独的提交中(如果需要修复的文件超过 10 个,建议采用此方法)修复有问题的文件中的这些错误。
      • 或者,先将检查设为非阻塞,修复错误,然后将其切换为阻塞
    • 如果检查发出警告,请记下警告的数量。如果数量非常大(超过数百个),则会导致许多无用的 Gerrit 注释,并可能会对其他贡献者造成干扰。请考虑预先进行批量修复、缩小检查范围或重新考虑检查的实用性。
  6. 最后,将您的检查上传到 Gerrit,运行预提交,检查失败情况,目标是实现 0 失败。(预提交的行为与运行 fx host-tool shac check --all 相同)

如果检查是选择性加入的(不在预提交中运行),或者存在不明显的选择退出机制,建议您记录检查。所有文件都应添加到 //docs/development/source_code/presubmit_checks.md

单元测试检查

检查应具有以 Starlark 编写并使用 shac test 运行的单元测试。测试会针对一个小型临时签出运行检查,该签出的文件由测试指定,因此测试速度快且密封,不依赖于实际签出的内容。

//scripts/shac 中检查的测试位于 //scripts/shac/tests 中,在以包含检查的文件命名的 <name>_test.star 文件中(例如,python_test.star 测试 python.star 中的检查)。名称以 test_ 开头的每个顶级函数都是一个测试用例。测试文件可以直接检查函数(包括私有函数)。load()

以下是 简单示例中 http_links 检查的测试示例:

load("//scripts/shac/http_links.star", "http_links")

def test_http_links():
    res = testing.run(http_links, files = {
        "foo.md": "See http://example.com.\n",
    })
    asserts.eq(res.findings, (
        testing.finding(
            level = "warning",
            message = "Avoid http:// links, prefer https://",
            filepath = "foo.md",
            line = 1,
            col = 5,
            end_col = 12,
            replacements = ["https://"],
        ),
    ))
    # `res.files` contains the file contents after applying all fixes.
    asserts.eq(res.files["foo.md"], "See https://example.com.\n")

def test_http_links_ok():
    res = testing.run(http_links, files = {
        "foo.md": "See https://example.com.\n",
    })
    asserts.eq(res.findings, ())

testing.run() 会将 files 中传递的每个文件都视为受测试更改的影响,除非设置了 affected_files。运行子进程的检查可以执行实际的预构建工具,也可以使用 testing.exec_mock() 对其进行模拟。 如需查看完整的 testing 和 asserts API,请参阅 shac-documentation。

Fuchsia 自身的检查也会在测试文件上运行,因此会触发对测试文件本身进行检查的测试数据(例如令人困惑的字符或 bug 网址)必须拆分或转义,例如 "http" + "://" 或 "\u200b"。

如需添加新的测试文件,请在 //scripts/shac/tests/BUILD.bazel 中为其添加一个以该文件命名的 host_shac_test() 目标,并将其添加到同一文件中的 host_tests 测试套件。如果测试执行任何实际工具,请将这些工具也添加到目标的 data 中。每当测试所依赖的检查或测试文件本身发生更改时,系统都会在预提交中运行相应测试。

如需在本地运行测试,请使用以下任一方法:

  • fx test //scripts/shac/tests:python_test 运行单个文件中的测试。
  • fx bazel test --config=host //scripts/shac/tests:host_tests 会运行所有测试。
  • fx host-tool shac test scripts/shac/tests/python_test.star,从结算的根目录运行,在单个文件中运行测试,而不构建任何内容。使用 --only <test_name> 仅运行特定测试用例。