Flutter Engine 开源合规检查器 licenses_cpp:构建、运行与源码解析
本文以 Flutter Engine 仓库中的 licenses_cpp/GEMINI.md 为核心指南,系统讲解这套在 CI 中运行的 C++ 许可证收集与校验工具的构建目标、目录结构、命令行用法,并结合同目录下的源码(main.cc、license_checker.cc、Catalog/Filter 等)剖析其"按 git 仓库 + DEPS 全量扫描、用正则目录匹配许可证、统一汇总输出"的底层工作原理。读完你将掌握如何在本机用 et 构建工具与测试、对单个文件快速做许可证合法性核验,以及理解整个检查流水线的判定逻辑。
一、licenses_cpp 是什么
根据 GEMINI.md 与同目录 README.md 的定义:
A tool run during ci to collect and verify source code's licenses.
licenses_cpp 是 Flutter Engine 构建 CI 流程中负责收集并验证源码许可证的命令行工具。它的职责可以拆成两半:
- 收集(collect):扫描 Engine 源码树,把每个"软件包"(package)对应的开源许可证文本汇总、去重后写入一个统一的许可证输出文件(例如仓库根目录的
licenses.txt)。 - 验证(verify):对源码文件头注释中的 license/copyright 声明、以及各 LICENSE/COPYING 文件的内容,与预先登记的"已知许可证目录"进行正则匹配。匹配不上的情况会被记录为错误,从而在 CI 阶段尽早暴露许可证合规问题。
从数据目录可以看到它的支撑规模:data/licenses/ 中登记了几十种许可证模板,包括 Apache 2.0(apache2.txt、apache2_header.txt)、BSD 2/3-Clause、MIT(mit.txt、mit_starred.txt 等多个变体)、Boost、libpng、ICU、SQLite、Mozilla 2.0、Unicode、V8 等,说明它需要识别的许可证面相当广。
二、构建目标(GN Targets)与目录结构
2.1 GN 构建目标
BUILD.gn 中定义了四个目标:
| Target | 类型 | 说明 |
|---|---|---|
//flutter/tools/licenses_cpp:licenses |
source_set | 核心库,包含 catalog、comments、data、deps_parser、filter、license_checker、mmap_file 等源文件 |
//flutter/tools/licenses_cpp |
executable | 主程序 licenses_cpp,入口为 src/main.cc |
//flutter/tools/licenses_cpp:license_file_compare |
executable | 辅助程序,对比许可证文件(基于 re2 + mmap) |
//flutter/tools/licenses_cpp:licenses_cpp_testrunner |
executable(testonly) | 单元测试运行器,聚合 catalog/comments/deps_parser/filter/license_checker 五个测试文件 |
其外部依赖主要为 //flutter/third_party/re2(正则引擎)和 //third_party/abseil-cpp 的 flags/parse、log、status 组件。测试目标还依赖 //flutter/third_party/googletest。文档建议主程序用 profile 配置构建(即文档所说 "best run with a profile config"),因为全量扫描对性能有要求。
2.2 目录布局
GEMINI.md 归纳了两个核心目录:
data/—— 工具所需的数据文件,如各种正则与许可证模板src/—— 源码目录
展开来看(见 目录列表):
licenses_cpp/
├── data/
│ ├── include.txt # 参与检查的文件路径正则(全匹配)
│ ├── exclude.txt # 需要排除的文件路径正则(全匹配)
│ ├── licenses/ # 可接受的已知许可证模板(数据格式见下文)
│ ├── secondary/ # 需要"原文附加"的二级许可证
│ └── README.md # 数据格式说明
├── src/
│ ├── main.cc # 入口:解析命令行 flags 并分派执行
│ ├── license_checker.{h,cc} # 核心扫描逻辑
│ ├── catalog.{h,cc} # 已知许可证目录与两级匹配
│ ├── filter.{h,cc} # 多正则过滤器(include/exclude 共用的实现)
│ ├── comments.{h,cc,l} # 注释提取(.l 为 flex 词法定义)
│ ├── comments_util.{h,cc}
│ ├── deps_parser.{h,cc} # DEPS 文件中依赖列表的解析
│ ├── data.{h,cc} # Data::Open 加载整个 data 目录
│ ├── mmap_file.{h,cc} # mmap 文件读取
│ └── *unittests.cc # 五个单元测试
├── tools/convert.dart
├── BUILD.gn
├── GEMINI.md
└── README.md
其中关于 include/exclude/catalog/匹配数据格式的权威说明记录在 data/README.md。
三、构建并运行单元测试
GEMINI.md 给出了构建测试并运行的命令。注意这些命令的 ../../ 相对路径都是以 engine/src/flutter/tools/licenses_cpp 目录为基准的(在 Engine 检出中即 flutter/engine/src/flutter/tools/licenses_cpp 一级):
../../bin/et build --no-rbe -c host_debug_unopt_arm64 //flutter/tools/licenses_cpp:licenses_cpp_testrunner
../../../out/host_debug_unopt_arm64/licenses_cpp_testrunner
参数含义:
../../bin/et:Flutter Engine 的构建入口脚本et,它封装了 gn 生成与 ninja 编译。--no-rbe:本次构建不经过 RBE 远程构建服务(本机编译)。-c host_debug_unopt_arm64:配置为 host 端、debug、非优化、arm64。//flutter/tools/licenses_cpp:licenses_cpp_testrunner:要构建的 GN 目标。../../../out/host_debug_unopt_arm64/:产物输出目录(相对该目录上溯三级)。
构建完成后直接执行测试运行器即可。它聚合了 catalog_unittests.cc、comments_unittests.cc、deps_parser_unittests.cc、filter_unittests.cc 与 license_checker_unittests.cc 这五组测试,验证的是目录匹配、注释提取、DEPS 解析、过滤器与整体检查器各自的行为。
四、对单个文件运行许可证检查
GEMINI.md 提供的最实用示例是只检查一个文件:
../../bin/et build --no-rbe -c host_profile_arm64 //flutter/tools/licenses_cpp
../../../out/host_profile_arm64/licenses_cpp \
--working_dir ../.. \
--data_dir ./data \
--licenses_path licenses.txt \
--input ../../third_party/icu/source/i18n/collunsafe.h \
--v=3
这里用 profile 配置构建主程序,然后对 ICU 的 collunsafe.h 做单文件核验。各参数:
| Flag | 必填 | 说明 |
|---|---|---|
--working_dir ../.. |
是 | 待扫描的目录根(此处为 engine 源码根)。源码中的相对路径都要相对它解析 |
--data_dir ./data |
是 | 存放许可证数据(licenses 模板、include/exclude 正则)的目录 |
--licenses_path licenses.txt |
是 | 汇总许可证内容的输出文件路径 |
--input <file> |
否 | 指定只检查这一个文件;缺省则执行全目录扫描 |
--include_filter <regex> |
否 | 覆盖默认 include 过滤器的正则(注意:与 --input 互斥,见下文) |
--root_package <name> |
否 | 指定根软件包名称 |
--treat_unmatched_comments_as_errors |
否 | 把"匹配不到已知许可证的注释"视为错误 |
--v=<n> |
否 | 日志详细级别,--v=3 可输出较详细的处理/未匹配信息 |
这些 flags 的定义可以在 main.cc 的 ABSL_FLAG 声明处逐一找到,例如 working_dir/data_dir/licenses_path 在源码注释中都标注为 [REQUIRED],--v 默认值为 0。
4.1 命令行解析与分派
main.cc 的执行逻辑可以归纳为:
- 用
absl::ParseCommandLine解析参数,并把--v应用到全局日志级别。 - 三个必填参数缺一不可:
working_dir、data_dir、licenses_path。三者同时存在才继续,否则逐个在 stderr 打印缺失项并返回 1。 - 打开
licenses_path输出流,写入失败则退出。 - 分派路径:
- 若提供了
--input(单文件模式):先拒绝同时使用--include_filter(打印--input_filter not supported with --input),然后用fs::canonical规范化路径后调用LicenseChecker::FileRun; - 否则是全量模式:有
--include_filter时用它构造 Filter 覆盖默认 include 规则后调用LicenseChecker::Run,无则直接全量扫描。
- 若提供了
- 收集到的错误逐条打印到 stderr,只要有错误,进程就返回 1——这正是 CI 中把它当作"检查门禁"的基础。
五、深入核心执行引擎:license_checker 的扫描模型
单文件与全量模式最终都汇入 license_checker.cc 的 ProcessFile/Run/FileRun。其整体数据流如下。
5.1 数据加载与双重过滤
LicenseChecker::Run 首先通过 data.h 中的 Data::Open(data_dir) 把 data 目录加载进内存,形成:
include_filter/exclude_filter:由 include.txt 与 exclude.txt 解析而来,每个文件就是多行正则拼接而成(实现见 filter.h,Filter::Matches用单个 RE2 做全匹配);catalog:已知许可证目录;secondary_dir:二级许可证目录。
每个待检文件进入 ProcessFile 后第一道关卡就是:
if (!data.include_filter.Matches(relative_path.string()) ||
data.exclude_filter.Matches(relative_path.string())) {
VLOG(1) << "EXCLUDE: ...";
return absl::OkStatus();
}
即"不在 include 白名单,或命中 exclude 黑名单"的文件被直接跳过。根据 data/README.md,include/exclude 中的每条正则都必须是对相对 --working_dir 路径的完整匹配(full match) 才生效,行首 # 为注释。
5.2 软件包归属判定(GetPackage)
接着 GetPackage 依据路径在目录树中的位置判定该文件属于哪个"软件包":
- 默认包名取根目录名(
working_dir的 filename,见GetDirFilename),可由--root_package覆盖; - 一旦路径组件中出现
third_party,该文件就被标记为非根包(is_root_package = false),third_party之后的第一个目录组件即包名; - 存在忽略名单
kThirdPartyIgnore = {"pkg", "vulkan-deps"},命中它们会被跳过、不作为包名; - 同时向上逐级查找最近的 LICENSE 文件(候选名单见
kLicenseFileNames:LICENSE、LICENSE.TXT/.txt/.md/.MIT、COPYING、License.txt、docs/FTL.TXT、README.ijg),作为该包最贴近的许可证文件。
5.3 两套正文处理策略
每个文件在 mmap 后按文件名分派:
- 名为
NOTICES的文件走ProcessNotices:用正则按"项目列表 + 空行 + 许可证正文 + 80 个-分隔线"的格式切块,把每块正文拿去匹配目录,并把该块头部列出的每个项目名都登记到该许可证下。 - 其它文件走
ProcessSourceCode:先用词法分析器(comments.l/comments.cc的IterateComments)抽出所有注释;凡注释中出现license或copyright(大小写不敏感,正则见LicenseChecker::kHeaderLicenseRegex)即送入 Catalog 匹配。若一个文件里连注释都没有,则把整段文本当作文本文件整体尝试匹配。
5.4 Catalog:两级目录匹配
Catalog 采用两级检索来压缩 RE2::Set 的规模:先用一个"selector"(由每个条目的 unique 正则构成)决定该用哪个 matcher,再对命中条目执行精确正文匹配。每个许可证条目对应 data 中一个文件,格式为(data/README.md):
- 第一行——matcher 名称;
- 第二行——unique 正则,各条目间不得重叠;
- 其余行——用于提取许可证完整正文的 matcher 正则,其中所有空白视作
\s+、行尾空白忽略、被捕获的分组会从输出中剔除。
匹配成功的正文会被登记进 LicenseMap(按许可证正文去重、追加包名集合);匹配失败时,若启用了 treat_unmatched_comments_as_errors,未匹配的注释会作为错误上报——这是"校验"语义的主要落点。
5.5 全量模式的扫描范围
Run 的全量扫描覆盖三块区域,理解它们就理解了 GEMINI.md 所说 "collect" 的广度:
- DEPS 中声明但非 git 仓库的依赖:
FindFileInParentDirectories向上查找DEPS文件,用 deps_parser.cc 解析出依赖子路径(须落在 working_dir 之内),对其整目录递归检查——这正是为了覆盖并非 git 仓库的依赖。 - working_dir 下所有 git 仓库:
GetGitRepos递归寻找.git目录得到仓库列表,再对每个仓库执行git ls-files(源码中调用git -C <repo> ls-files)拿到受版本控制的文件清单逐一检查。 - secondary 目录:
secondary/的目录结构需与 working_dir 一致;其中每个许可证文件会被原样(verbatim)附加到最终输出(这部分不参与目录匹配)。
全部文件处理完毕后,state.license_map.Write(licenses) 把结果写入 --licenses_path。输出格式(见 LicensesWriter):同一许可证先按包名排序逐行列出包名,空一行后是许可证正文;不同许可证之间用 80 个 - 分隔。在 TTY 上运行且日志级别不低于 1 时还会显示 progress: [oooo....] 样式的进度条。
六、错误语义与 CI 集成要点
- 所有问题(找不到 LICENSE 文件、正文无法匹配任何已知许可证、根包缺少期望的版权头等)都被收集为
absl::Status返回,最终汇总为退出码1并打印Error count: N。也就是说,licenses_cpp 是非零即失败的门禁型工具。 - 三级日志便于排查:
--v=1输出每个文件OK:/EXCLUDE:的判定,--v=2输出NOT_FOUND与具体原因,--v=3起还会打印未能归属到 LICENSE 文件等中间信息;注释原文默认在--v=4打印(见ProcessSourceCode中的 VLOG 分级)。 - 单文件模式(
--input)与--include_filter互斥;临时想看某个第三方文件能否被识别,推荐用单文件模式快速试错。
七、局限与注意事项
从源码结构可以看到一些约定与限制,使用前值得留意:
- 全部数据(include/exclude 正则、许可证模板)都存放在 data/ 内,判定能力完全取决于该目录的完备程度;新增一种第三方许可证时需要同时登记模板并保证其 unique 正则不与既有条目冲突。
- include/exclude 的正则必须是完整匹配,且
#仅支持行首注释(data/README.md 明确写到 trailing#注释暂不支持)。 - secondary 目录必须与 working_dir 结构一致,否则会报
secondary license path mismatch错误。 - 命令中的相对路径(
../../bin/et、../../../out/...)均以engine/src/flutter/tools/licenses_cpp为基准,实际执行前请把该目录下的engine/out换成你本地 Engine 检出(flutter/engine)中的对应位置,并保证已按 Engine 开发文档完成 gclient 同步与本机工具链准备。
参考文件索引
- 使用指南原文:GEMINI.md
- 工具简介:README.md
- 构建目标定义:BUILD.gn
- 命令行入口与全部 flag:src/main.cc
- 核心扫描器:src/license_checker.cc、src/license_checker.h
- 数据格式说明:data/README.md,数据实体见 data/licenses/、data/include.txt、data/exclude.txt
- 单元测试:
src/catalog_unittests.cc、src/comments_unittests.cc、src/deps_parser_unittests.cc、src/filter_unittests.cc、src/license_checker_unittests.cc
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00