建立 Fuchsia 的靜態分析工具

Shac (Scalable Hermetic Analysis and Checks) 是一套統一且符合人體工學的工具和框架,可用於編寫及執行靜態分析檢查。您可以在 shac-documentation 中找到這項工具的來源。Shac 檢查是以 Starlark 編寫。

設定

Shac 指令碼實作項目位於 Fuchsia 的 //scripts/shac 目錄中。

  • shac 檢查會以 Starlark 函式實作,並採用 ctx 引數。使用這個 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 不會自動探索檢查。如要執行檢查,必須將檢查函式傳遞至 shac.register_check() 中的 //scripts/shac/main.star:

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 檢查新增至 fidl.star,並在同一個檔案的 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,
))

效能最佳化

部分格式器內建支援一次驗證多個檔案的格式,這通常會在內部平行處理,因此比啟動個別子程序來檢查每個檔案快得多。在這種情況下,您可以對「check」模式中的所有檔案執行一次格式化工具,取得格式錯誤的檔案清單,然後只對格式錯誤的檔案進行疊代,取得格式化結果 (而不是對所有檔案進行疊代)。

範例:針對 rustfmt,請先執行 rustfmt --check --files-with-diff <all rust files> 取得格式錯誤的檔案清單,然後分別對每個檔案執行 rustfmt,取得格式化結果。

如果格式化工具沒有模擬測試模式,無法將格式化結果列印至 stdout:格式化工具子程序將無法寫入結帳程序。不過,部分格式化工具會無條件寫入檔案。在這種情況下,您需要將每個檔案複製到子程序可寫入的 tempdir,格式化臨時檔案,並回報其內容,範例請參閱 buildifier。

根據預設,如果子程序產生非零的傳回代碼,os_exec 會引發無法復原的錯誤。如果預期會傳回非零的傳回代碼,可以使用 ok_retcodes 參數,例如,如果格式化工具在檔案未格式化時產生傳回代碼 1,則 ok_retcodes = [0, 1] 可能適用。

在本機執行檢查

如要在變更中對修改過的檔案執行本機靜態分析檢查,請使用 fx lint:

  • fx lint 會對修改過的檔案執行所有樹狀結構內 Linter 和分析器。
  • 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 causes shac to only run the http_links check, excluding other checks because in this instance we only care about testing http_links and don't care about results from other checks
  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, ())

除非設定 affected_files,否則 testing.run() 會將 files 中傳遞的每個檔案視為受測試變更影響。執行子程序的檢查可以執行實際的預先建構工具,也可以使用 testing.exec_mock() 模擬這些工具。如要查看完整的 testing 和 asserts API,請參閱 shac 說明文件。

Fuchsia 本身也會對測試檔案執行檢查,因此會觸發測試檔案本身檢查的測試資料 (例如令人困惑的字元或錯誤網址) 必須分割或逸出,例如 "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> 僅執行特定測試案例。