From acf37b71b579e1fcf523df1c6cff77c43d9ba39e Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:25:39 +0800 Subject: [PATCH 01/12] docs: design configure-only compile database generation --- .../2026-08-08-configure-only-cdb-design.md | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 .agents/docs/2026-08-08-configure-only-cdb-design.md diff --git a/.agents/docs/2026-08-08-configure-only-cdb-design.md b/.agents/docs/2026-08-08-configure-only-cdb-design.md new file mode 100644 index 00000000..8751a5ba --- /dev/null +++ b/.agents/docs/2026-08-08-configure-only-cdb-design.md @@ -0,0 +1,212 @@ +# `build --configure-only` 与可靠 CDB 设计 + +## 1. 背景 + +普通 `mcpp build` 已在启动 Ninja 前生成 `compile_commands.json`。因此源码存在 +语法错误时仍能得到 CDB;真正缺失的是一个只完成项目解析、工具链解析、构建计划 +生成和 CDB 发布,而不编译普通翻译单元、不链接最终目标的入口。 + +PR #372 同时实现了 IDE snapshot、NDJSON 生命周期、内容寻址 reply、发布状态机和 +CDB 可靠性。RFC #379 决定拆分这些关注点:本设计只保留只有 mcpp 能可靠完成的 +构建配置能力,通用机器协议继续在 RFC 中讨论。 + +## 2. 目标 + +新增: + +```text +mcpp build --configure-only [现有 build selectors] +``` + +成功时必须满足: + +1. 复用真实 `prepare_build()` 和 `BuildPlan`,不复制编译参数推导。 +2. 根 CDB 覆盖所选 package 的普通源码与 `tests/**/*.cpp`。 +3. 测试 TU 带有 dev-dependencies 和匹配的 `[build].flags`。 +4. 准备标准库 BMI,并只物化已经命中全局缓存的依赖 BMI。 +5. 不启动 Ninja,不生成普通对象文件,不链接可执行文件或库。 +6. CDB 通过跨平台原子替换发布;失败时旧 CDB 保持不变。 +7. 仅当新 CDB 已成功发布时返回 0。 + +该命令不是只读操作。`prepare_build()` 仍可能执行 `build.mcpp`、安装缺失依赖或 +工具链、写 `mcpp.lock`、resolution、缓存及构建目录元数据。IDE 插件必须继续受 +workspace trust 约束。 + +## 3. 非目标 + +本 PR 不实现: + +- JSON 或 NDJSON stdout、wire envelope、protocol version。 +- `ide snapshot`、`ide configure` 或 daemon。 +- project/configuration/snapshot ID。 +- `.mcpp/ide/current.json`、内容寻址 reply、跨文件发布事务或发布锁。 +- `invalidatedBy`、manifest metadata 或依赖图。 +- 构建未缓存的项目或依赖模块 BMI。 +- 新的 toolchain、profile、feature、capability 或 workspace selector 语义。 +- `.xlings.json` pin 更新。 + +## 4. 方案选择 + +### 方案 A:复用 Ninja backend 的内部 dry-run,推荐 + +`prepare_build()` 生成包含普通目标和测试目标的计划,再以 +`BuildOptions::dryRun=true` 调用现有 Ninja backend。backend 仍生成 `build.ninja` +和 CDB,但在启动 Ninja 前返回。 + +优点:编译命令只有一个生成路径;现有 selector、工具链和平台逻辑全部复用。 +风险:不能调用普通 `run_build_plan()`,否则会错误填充 BMI cache 和 build fast-path +cache。需要独立的 `run_configure_plan()` 收口配置模式。 + +### 方案 B:在 CLI 直接调用 `emit_compile_commands()` + +代码更短,但会绕过 backend 的 manifest、命令长度检查和后续编译命令演进,形成 +第二条 CDB 路径。不采用。 + +### 方案 C:保留 #372 的 `ide configure` + +能提供更丰富状态,但需要 NDJSON、snapshot 发布和长期协议兼容,且插件当前只消费 +CDB 路径与成功结果。不采用。 + +## 5. CLI 与执行流 + +`build` 增加布尔选项 `--configure-only`,其余选项继续由现有 parser 和 +`BuildOverrides` 处理:`-p/--package`、`--workspace`、`--profile`、`--target`、 +`--features`、`--cap`、`--cache`、`--offline`、`--strict` 和 `--static`。 + +执行流: + +```text +cmd_build + -> workspace_fanout_members(沿用现有语义) + -> discover_test_targets(按 member 发现测试) + -> prepare_build(includeDevDeps = !tests.empty(), extraTargets = tests) + -> stage_cached_module_prerequisites + -> run_configure_plan + -> NinjaBackend::build(dryRun=true, requireCompileDatabase=true) + -> 生成 build.ninja + -> 原子发布 compile_commands.json + -> 在 spawn Ninja 前返回 +``` + +配置模式必须跳过 build fast path。它也不得: + +- 调用 `bmi_cache::populate_from()`; +- 写 `target/.build_cache`; +- 输出 `Finished ...` 这种表示已完成编译的状态; +- 把不存在的最终产物报告为 produced artifacts。 + +workspace fan-out 继续逐 member 调用相同流程。每个 member 的新 CDB 内容与已有根 +CDB 合并,fresh entry 优先,最终得到一个覆盖 workspace 的根 CDB。任一 member +失败时沿用现有 continue-on-failure 和首个非零结果规则。 + +## 6. 测试目标发现 + +从 `run_tests()` 抽取 `discover_test_targets()`,由 `mcpp test` 和配置模式共同调用。 +该函数负责: + +- 将 package selector 解析到同一个 member 根目录; +- 展开 `tests/**/*.cpp`; +- 用相对 `tests/` 的无扩展路径生成稳定测试名; +- 检测重复测试名; +- 把匹配的 `[build].flags` 中 defines、cflags、cxxflags 附到合成 target。 + +`mcpp test --list` 现有的 best-effort 行为必须保留:源码或 manifest 尚未可构建时, +只要能够发现测试文件就继续列出;严格 manifest 与依赖校验仍由后续 +`prepare_build()` 完成。 + +配置模式只有在发现测试 target 时启用 dev-dependencies。没有测试的项目不应仅因 +IDE 配置而安装无关开发依赖。 + +## 7. 模块前置产物 + +`prepare_build()` 已保证当前工具链需要的标准库模块可用。配置模式还会把计划中 +`servedFromCache && providesModule` 的依赖 BMI 从全局缓存物化到计划引用的构建目录。 + +该步骤只复制现有 BMI,不复制缓存对象文件,也不编译任何缺失模块。物化失败发生在 +CDB 发布前,并保留旧 CDB。项目自身模块或未缓存依赖模块保持 pending;用户显式 +执行普通 `mcpp build` 后才获得完整模块语义。 + +## 8. CDB 发布与错误处理 + +新增平台原语 `platform::fs::replace_file(source, destination, error_code)`: + +- POSIX 使用同文件系统 `rename`; +- Windows 使用 `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`; +- 不先删除 destination; +- 函数为 `noexcept` 风格,通过 `error_code` 报错。 + +CDB 写入流程: + +1. 生成并解析 JSON,要求顶层为数组。 +2. 与现有有效条目合并并删除已不存在源文件的旧条目。 +3. 内容未变化时不写文件,避免无意义触发 clangd 重索引。 +4. 在同目录完成唯一临时文件写入和 flush。 +5. 通过 `replace_file` 原子替换目标。 +6. 替换失败时清理临时文件,返回错误,旧文件保持不变。 + +`write_compile_commands()` 改为返回结构化成功或错误。为保持普通构建兼容性: + +- 普通 build/test 遇到 CDB 发布失败时输出 warning,继续真实构建; +- `--configure-only` 设置 `requireCompileDatabase=true`,发布失败直接返回非零。 + +不加入 `FileLock`。单文件原子替换已保证 clangd 不会读到半截 JSON;并发配置采用 +last-writer-wins,但每个可见版本都是完整文件。只有未来重新引入多文件事务时才需要 +发布锁。 + +## 9. 输出契约 + +本 PR 只提供人类输出和进程退出码,不承诺机器可读 stdout。建议成功信息为: + +```text +Configured ( compile commands) +``` + +错误继续通过现有 stderr/UI 通道报告。插件稳定依赖仅有: + +- 进程退出码; +- 工程或 workspace 根目录的 `compile_commands.json`。 + +未来 RFC 可以向同一命令增加 `--format json`,默认人类输出不需要变化。 + +## 10. 测试策略 + +### 单元测试 + +- CDB 合并继续覆盖 fresh-wins、删除失效文件、损坏旧 JSON 回退。 +- 原子 writer 拒绝非数组 JSON。 +- 内容不变时不改 mtime。 +- `replace_file` 成功替换旧文件。 +- source 不存在时替换失败且 destination 内容保持不变。 +- 测试发现覆盖 nested names、重复名、member scoping 和 `[build].flags`。 +- 损坏 manifest 下 `test --list` 仍保持 best-effort inventory。 + +### E2E + +- 语法错误源码:配置成功、CDB 含源码、没有普通对象或最终二进制。 +- 测试 TU:CDB 含 `tests/**/*.cpp`,并具有 dev-dependency include/define。 +- workspace:默认 virtual workspace fan-out 和 `-p ` 均生成正确条目。 +- selector:profile、target、features、capability 与普通 build 进入同一 BuildPlan。 +- 失败保留:新 CDB 发布失败时旧 CDB 内容不变且命令返回非零。 +- Windows:真实 MSVC 配置与 `MoveFileExW` 替换由 Windows CI 覆盖。 + +## 11. 对核心构建的影响 + +共享变化限定为三处: + +1. 测试发现从 `run_tests()` 抽为共享模块,行为由原有测试锁定。 +2. CDB 从截断写改为临时文件加原子替换;普通构建失败策略保持非致命 warning。 +3. backend 增加 `requireCompileDatabase` 选项,默认 false;所有现有调用语义不变。 + +配置模式使用独立执行函数,不写 fast-path cache 或全局 BMI cache,因此不会让后续 +真实 build 错误命中不存在的产物。 + +## 12. 后续工作 + +以下事项留在 RFC #379,不阻塞本 PR: + +1. 未知 format 的统一退出码、stdout 和诊断结构。 +2. `--format json` 与旧 `--json` 的兼容规则。 +3. `self env` JSON、`xpkg parse` schema version。 +4. read-only metadata 与 manifest 合法键/枚举词汇表。 +5. resolved metadata、依赖图与 CDB `invalidatedBy`。 +6. 至少三个稳定消费者出现后再抽取通用 `mcpp.wire`。 From 2be16478cb9669612ba7cd5d8c9ec939db3187b1 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:55:29 +0800 Subject: [PATCH 02/12] docs: plan configure-only compile database implementation --- ...-configure-only-cdb-implementation-plan.md | 1211 +++++++++++++++++ 1 file changed, 1211 insertions(+) create mode 100644 .agents/docs/2026-08-08-configure-only-cdb-implementation-plan.md diff --git a/.agents/docs/2026-08-08-configure-only-cdb-implementation-plan.md b/.agents/docs/2026-08-08-configure-only-cdb-implementation-plan.md new file mode 100644 index 00000000..8c43526b --- /dev/null +++ b/.agents/docs/2026-08-08-configure-only-cdb-implementation-plan.md @@ -0,0 +1,1211 @@ +# Configure-Only Compile Database Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 `mcpp build --configure-only`,在不编译普通翻译单元、不链接目标且不写构建成功缓存的前提下,基于真实 `BuildPlan` 原子发布包含源码与测试 TU 的 `compile_commands.json`。 + +**Architecture:** CLI 继续使用现有 build selectors 和 workspace fan-out;共享的测试发现模块为 `mcpp test` 与 configure-only 合成相同测试 target;`prepare_build()` 仍是解析工具链、依赖、feature、模块图与编译参数的唯一入口。独立的 `run_configure_plan()` 只物化 clangd 立即需要的已存在 BMI,然后以 Ninja backend dry-run 生成 `build.ninja` 和 CDB;CDB 发布失败在普通 build/test 中警告继续,在 configure-only 中返回失败。 + +**Tech Stack:** C++23 named modules、`std::expected`、nlohmann JSON (`mcpp.libs.json`)、Ninja backend、GoogleTest、跨平台 Bash E2E、GitHub Actions Linux/macOS/Windows。 + +--- + +## Scope Guard + +本计划只实现 `.agents/docs/2026-08-08-configure-only-cdb-design.md` 已确认的 A 部分: + +- `mcpp build --configure-only`; +- 普通源码和 `tests/**/*.cpp` 的真实 CDB; +- test TU 的 dev-dependencies 与 `[build].flags`; +- std BMI 和已命中缓存的依赖 BMI 物化; +- 单文件原子 CDB 发布; +- 普通 build/test 警告继续、configure-only 严格失败; +- 人类输出和退出码。 + +不得加入:JSON/NDJSON、`ide` 子命令、snapshot/ID/envelope、`current.json`、`FileLock`、`invalidatedBy`、metadata/dependency graph、`.xlings.json` pin、新 toolchain/profile/feature/capability/workspace 语义。 + +## Execution Setup + +所有命令从 worktree 根目录执行: + +```bash +cd /Users/cltx/projects/mcpp/mcpp/target/worktrees/configure-only-cdb +git status --short --branch +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" --version +``` + +预期:分支为 `codex/configure-only-cdb`;只包含本计划已知改动;fresh binary 输出当前 `mcpp.toml` 中的版本。 + +## File Map + +| File | Responsibility | +|---|---| +| `src/build/test_targets.cppm` | 共享 `tests/**/*.cpp` 发现、member scoping、稳定命名和 `[build].flags` 合成;不打印 UI,不解析 dev-dependencies。 | +| `src/build/configure.cppm` | 物化 std/cached dependency BMI,并运行只配置的 Ninja dry-run;不触碰 BMI populate 或 `.build_cache`。 | +| `src/build/execute.cppm` | `mcpp test` 改用共享发现 API;保留 list、build、run 和 summary 行为。 | +| `src/platform/fs.cppm` | 提供不先删除 destination 的跨平台 `replace_file()`。 | +| `src/build/compile_commands.cppm` | 校验、合并、稳定排序、同目录临时文件写入和原子发布 CDB。 | +| `src/build/backend.cppm` | 增加默认关闭的 `requireCompileDatabase`,并在结果中携带最终 CDB 条目数。 | +| `src/build/ninja_backend.cppm` | 将 CDB writer 结果落实为普通构建 warning 或 configure-only fatal;dry-run 在 spawn Ninja 前返回。 | +| `src/cli/cmd_build.cppm` | 解析 configure-only 模式,复用 workspace fan-out 与 selectors,并绕过 build fast path。 | +| `src/cli.cppm` | 注册并展示 `--configure-only`。 | +| `tests/unit/test_test_targets.cpp` | 共享测试发现契约。 | +| `tests/unit/test_platform_fs.cpp` | replace-existing 与失败保留 destination。 | +| `tests/unit/test_compile_commands.cpp` | 原子 writer、JSON 校验、mtime、失败保留和稳定排序。 | +| `tests/unit/test_configure.cpp` | 只物化 BMI、不物化 cached object 的配置前置步骤。 | +| `tests/unit/test_test_options.cpp` | `requireCompileDatabase` 默认值回归。 | +| `tests/e2e/202_configure_only_cdb.sh` | CLI、坏源码、测试/dev-dep flags、无对象/链接产物、workspace、失败策略和 build-cache 契约。 | +| `tests/e2e/01_help_and_version.sh` | 顶层 help 暴露 configure-only。 | +| `README.md`、`docs/00-getting-started.md`、`docs/zh/00-getting-started.md` | 用户入口、行为边界和 trust/副作用说明。 | + +### Task 1: Extract Shared Test Target Discovery + +**Files:** +- Create: `src/build/test_targets.cppm` +- Create: `tests/unit/test_test_targets.cpp` +- Modify: `src/build/execute.cppm:1026-1157` + +- [ ] **Step 1: Write failing discovery tests** + +新增 `tests/unit/test_test_targets.cpp`,先导入尚不存在的模块并覆盖嵌套命名、glob flags、member scoping 和损坏 manifest 的 best-effort 行为: + +```cpp +#include + +import std; +import mcpp.build.test_targets; + +namespace fs = std::filesystem; +using mcpp::build::discover_test_targets; + +namespace { + +struct TempProject { + fs::path path = fs::temp_directory_path() / + std::format("mcpp-test-targets-{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + TempProject() { fs::create_directories(path); } + ~TempProject() { std::error_code ec; fs::remove_all(path, ec); } +}; + +void write(const fs::path& path, std::string_view text) { + fs::create_directories(path.parent_path()); + std::ofstream out(path); + out << text; +} + +} // namespace + +TEST(TestTargets, DiscoversNestedNamesAndMatchingBuildFlags) { + TempProject p; + write(p.path / "mcpp.toml", R"( +[package] +name = "app" +version = "0.1.0" + +[build] +flags = [{ glob = "tests/tagged/**/*.cpp", defines = ["TAGGED=1"], cxxflags = ["-Wextra"] }] +)"); + write(p.path / "tests/tagged/nested/smoke.cpp", "int main() { return 0; }"); + write(p.path / "tests/plain.cpp", "int main() { return 0; }"); + + auto found = discover_test_targets(p.path, ""); + ASSERT_TRUE(found.has_value()) << found.error(); + ASSERT_EQ(found->targets.size(), 2u); + auto tagged = std::ranges::find(found->targets, "tagged/nested/smoke", + &mcpp::manifest::Target::name); + ASSERT_NE(tagged, found->targets.end()); + EXPECT_EQ(tagged->defines, std::vector({"TAGGED=1"})); + EXPECT_EQ(tagged->cxxflags, std::vector({"-Wextra"})); +} + +TEST(TestTargets, ScopesDiscoveryToSelectedWorkspaceMember) { + TempProject p; + write(p.path / "mcpp.toml", "[workspace]\nmembers = [\"a\", \"b\"]\n"); + write(p.path / "a/mcpp.toml", "[package]\nname=\"a\"\nversion=\"0.1.0\"\n"); + write(p.path / "b/mcpp.toml", "[package]\nname=\"b\"\nversion=\"0.1.0\"\n"); + write(p.path / "a/tests/main.cpp", "int main() { return 0; }"); + write(p.path / "b/tests/main.cpp", "int main() { return 0; }"); + + auto found = discover_test_targets(p.path, "a"); + ASSERT_TRUE(found.has_value()) << found.error(); + ASSERT_EQ(found->targets.size(), 1u); + EXPECT_EQ(found->packageRoot, p.path / "a"); + EXPECT_EQ(found->targets.front().main, "tests/main.cpp"); +} + +TEST(TestTargets, InvalidManifestStillProvidesBestEffortInventory) { + TempProject p; + write(p.path / "mcpp.toml", "[package\nthis is invalid\n"); + write(p.path / "tests/broken.cpp", "this does not parse"); + + auto found = discover_test_targets(p.path, ""); + ASSERT_TRUE(found.has_value()) << found.error(); + ASSERT_EQ(found->targets.size(), 1u); + EXPECT_EQ(found->targets.front().name, "broken"); +} +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +```bash +mcpp test --no-color -- --gtest_filter='TestTargets.*' +``` + +预期:编译失败,明确提示找不到 `mcpp.build.test_targets`;不能因为没有匹配测试而显示成功。 + +- [ ] **Step 3: Implement the shared discovery module** + +新增 `src/build/test_targets.cppm`,公开一个无 UI 副作用的结果类型和函数: + +```cpp +export module mcpp.build.test_targets; + +import std; +import mcpp.manifest; +import mcpp.modgraph.scanner; +import mcpp.project; + +export namespace mcpp::build { + +struct TestTargetSet { + std::filesystem::path packageRoot; + std::vector targets; +}; + +std::expected +discover_test_targets(const std::filesystem::path& manifestRoot, + std::string_view packageFilter); + +} // namespace mcpp::build +``` + +实现严格复用当前 `run_tests()` 的算法:有效 workspace manifest 下调用 `resolve_member_dir()`;manifest 无法解析时保持 manifestRoot 以支持 `test --list` best-effort;展开 `tests/**/*.cpp`;用 tests-relative 去扩展名路径作为 name;重复 name 返回错误;匹配 package manifest 的 base `[build].flags`,把 defines/cflags/cxxflags 放入合成 `Target::TestBinary`。 + +核心循环必须保持如下字段映射: + +```cpp +mcpp::manifest::Target target; +target.name = relative.replace_extension("").generic_string(); +target.kind = mcpp::manifest::Target::TestBinary; +target.main = std::filesystem::relative(file, packageRoot).string(); +``` + +只使用必要中文注释解释两个非直观约束:损坏 manifest 下的 best-effort inventory,以及 member root 必须与 `prepare_build()` 的 package filter 一致。 + +- [ ] **Step 4: Rewire `run_tests()` without changing list semantics** + +在 `src/build/execute.cppm` 导入 `mcpp.build.test_targets`,把 1056-1126 的发现/合成代码替换为: + +```cpp +auto root = mcpp::project::find_manifest_root(std::filesystem::current_path()); +if (!root) { + mcpp::ui::error("no mcpp.toml found in current directory or any parent"); + return 2; +} + +auto discovered = discover_test_targets(*root, overrides.package_filter); +if (!discovered) { + mcpp::ui::error(discovered.error()); + return 2; +} +auto testRoot = discovered->packageRoot; +auto testTargets = std::move(discovered->targets); +if (testTargets.empty()) { + std::println("no tests found in tests/"); + return 0; +} +``` + +`--list` 后面的过滤、JSON record、summary、`prepare_build(includeDevDeps=true)` 和运行逻辑保持原样。 + +- [ ] **Step 5: Run unit and existing E2E tests and verify GREEN** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" test --no-color -- --gtest_filter='TestTargets.*' +MCPP="$FRESH_MCPP" bash tests/e2e/159_test_list.sh +MCPP="$FRESH_MCPP" bash tests/e2e/157_test_glob_flags.sh +MCPP="$FRESH_MCPP" bash tests/e2e/90_workspace_test.sh +``` + +预期:unit 通过;三个 E2E 均输出 `OK`;损坏源码仍可 list;workspace 中同名测试不冲突。 + +- [ ] **Step 6: Commit the extraction** + +```bash +git add src/build/test_targets.cppm src/build/execute.cppm tests/unit/test_test_targets.cpp +git commit -m "refactor(test): share test target discovery" +``` + +### Task 2: Add Cross-Platform Atomic Replacement Primitive + +**Files:** +- Modify: `src/platform/fs.cppm` +- Create: `tests/unit/test_platform_fs.cpp` + +- [ ] **Step 1: Write failing filesystem tests** + +新增 `tests/unit/test_platform_fs.cpp`: + +```cpp +#include + +import std; +import mcpp.platform.fs; + +namespace fs = std::filesystem; + +namespace { +struct TempDir { + fs::path path = fs::temp_directory_path() / + std::format("mcpp-platform-fs-{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + TempDir() { fs::create_directories(path); } + ~TempDir() { std::error_code ec; fs::remove_all(path, ec); } +}; +void write(const fs::path& p, std::string_view s) { std::ofstream(p) << s; } +std::string read(const fs::path& p) { std::ifstream in(p); return {std::istreambuf_iterator(in), {}}; } +} // namespace + +TEST(PlatformFs, ReplaceFileAtomicallyReplacesExistingFile) { + TempDir t; + auto source = t.path / "new.tmp"; + auto destination = t.path / "compile_commands.json"; + write(source, "new"); + write(destination, "old"); + std::error_code ec; + EXPECT_TRUE(mcpp::platform::fs::replace_file(source, destination, ec)) << ec.message(); + EXPECT_FALSE(fs::exists(source)); + EXPECT_EQ(read(destination), "new"); +} + +TEST(PlatformFs, ReplaceFailureKeepsExistingDestination) { + TempDir t; + auto destination = t.path / "compile_commands.json"; + write(destination, "last-known-good"); + std::error_code ec; + EXPECT_FALSE(mcpp::platform::fs::replace_file(t.path / "missing.tmp", destination, ec)); + EXPECT_TRUE(ec); + EXPECT_EQ(read(destination), "last-known-good"); +} +``` + +- [ ] **Step 2: Run focused tests and verify RED** + +```bash +mcpp test --no-color -- --gtest_filter='PlatformFs.*' +``` + +预期:编译失败,`replace_file` 尚未声明。 + +- [ ] **Step 3: Implement `replace_file()`** + +在 `src/platform/fs.cppm` 的 exported namespace 中增加: + +```cpp +bool replace_file(const std::filesystem::path& source, + const std::filesystem::path& destination, + std::error_code& ec); +``` + +Windows 实现必须直接调用: + +```cpp +if (MoveFileExW(source.wstring().c_str(), destination.wstring().c_str(), + MOVEFILE_REPLACE_EXISTING)) { + ec.clear(); + return true; +} +ec = std::error_code(static_cast(GetLastError()), std::system_category()); +return false; +``` + +POSIX 实现使用同文件系统 rename: + +```cpp +std::filesystem::rename(source, destination, ec); +return !ec; +``` + +禁止在任何平台先 `remove(destination)`;添加中文注释说明这是 last-known-good CDB 的原子性边界。 + +- [ ] **Step 4: Run focused tests and verify GREEN** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" test --no-color -- --gtest_filter='PlatformFs.*' +``` + +预期:两项测试通过;Windows CI 真实执行 `MoveFileExW` 分支,Linux/macOS 执行 rename 分支。 + +- [ ] **Step 5: Commit the filesystem primitive** + +```bash +git add src/platform/fs.cppm tests/unit/test_platform_fs.cpp +git commit -m "feat(fs): add atomic file replacement" +``` + +### Task 3: Publish Compile Databases Atomically + +**Files:** +- Modify: `src/build/compile_commands.cppm` +- Modify: `src/build/backend.cppm` +- Modify: `src/build/ninja_backend.cppm` +- Modify: `tests/unit/test_compile_commands.cpp` +- Modify: `tests/unit/test_test_options.cpp` + +- [ ] **Step 1: Add failing writer and option tests** + +在 `tests/unit/test_compile_commands.cpp` 增加临时目录 helper,并新增: + +```cpp +TEST(CompileCommandsWriter, RejectsNonArrayFreshJson) { + TempDir t; + auto result = publish_compile_commands( + t.path / "compile_commands.json", R"({"file":"not-an-array"})", + [](const std::filesystem::path&) { return true; }); + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().message.find("JSON array"), std::string::npos); +} + +TEST(CompileCommandsWriter, UnchangedContentKeepsMtime) { + TempDir t; + auto path = t.path / "compile_commands.json"; + auto content = cdb({entry((t.path / "a.cpp").string(), "-DOK")}); + std::ofstream(path) << content; + auto before = std::filesystem::last_write_time(path); + auto result = publish_compile_commands( + path, content, [](const std::filesystem::path&) { return true; }); + ASSERT_TRUE(result.has_value()) << result.error().message; + EXPECT_FALSE(result->changed); + EXPECT_EQ(std::filesystem::last_write_time(path), before); +} + +TEST(CompileCommandsWriter, ReplacementFailurePreservesOldDatabase) { + TempDir t; + auto path = t.path / "compile_commands.json"; + auto oldContent = cdb({entry((t.path / "old.cpp").string(), "-DOLD")}); + auto newContent = cdb({entry((t.path / "new.cpp").string(), "-DNEW")}); + std::ofstream(path) << oldContent; + auto failReplace = [](const std::filesystem::path&, + const std::filesystem::path&, + std::error_code& ec) { + ec = std::make_error_code(std::errc::permission_denied); + return false; + }; + auto result = publish_compile_commands( + path, newContent, [](const std::filesystem::path&) { return true; }, failReplace); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(read_file(path), oldContent); + EXPECT_EQ(std::distance(std::filesystem::directory_iterator(t.path), + std::filesystem::directory_iterator{}), 1); +} + +TEST(CompileCommandsMerge, SortsFinalEntriesByFile) { + auto fresh = cdb({entry("/p/z.cpp", "-DZ"), entry("/p/a.cpp", "-DA")}); + auto merged = merge_compile_commands( + fresh, "[]", [](const std::filesystem::path&) { return true; }); + EXPECT_LT(merged.find("/p/a.cpp"), merged.find("/p/z.cpp")); +} +``` + +在 `tests/unit/test_test_options.cpp` 增加: + +```cpp +TEST(BuildOptions, CompileDatabaseIsOptionalByDefault) { + BuildOptions options; + EXPECT_FALSE(options.requireCompileDatabase); +} +``` + +- [ ] **Step 2: Run focused tests and verify RED** + +```bash +mcpp test --no-color -- --gtest_filter='CompileCommandsWriter.*:CompileCommandsMerge.SortsFinalEntriesByFile:BuildOptions.CompileDatabaseIsOptionalByDefault' +``` + +预期:编译失败,因为 writer 结果类型、`publish_compile_commands()` 和 `requireCompileDatabase` 尚不存在。 + +- [ ] **Step 3: Add structured writer result and atomic publication** + +在 `src/build/compile_commands.cppm` 导入 `mcpp.platform.fs`,公开: + +```cpp +struct CompileCommandsWriteResult { + bool changed = false; + std::size_t commandCount = 0; +}; + +struct CompileCommandsWriteError { + std::string message; +}; + +using ReplaceFile = std::function; + +std::expected +publish_compile_commands( + const std::filesystem::path& path, + std::string_view fresh, + const std::function& fileExists, + ReplaceFile replaceFile = mcpp::platform::fs::replace_file); + +std::expected +write_compile_commands(const BuildPlan& plan, const CompileFlags& flags); +``` + +`publish_compile_commands()` 按以下顺序实现: + +1. parse fresh,拒绝 discarded 或非数组; +2. 若旧文件可读则调用 `merge_compile_commands()`; +3. parse 最终文本并再次要求顶层数组; +4. 按 `file` 稳定排序; +5. 与旧文本相同则返回 `{false, size}`; +6. 在同目录写唯一 sibling temp,`flush()`、`close()` 并检查 stream 状态; +7. 调用 `replaceFile(temp, path, ec)`; +8. 失败时删除 temp 并返回带 path 和 OS error 的错误;不得删除 path; +9. 成功返回 `{true, size}`。 + +临时文件名使用时间戳加进程内原子序号,避免同一进程并发碰撞: + +```cpp +static std::atomic sequence{0}; +auto temp = path.parent_path() / + std::format(".{}.tmp.{}.{}", path.filename().string(), + std::chrono::steady_clock::now().time_since_epoch().count(), + sequence.fetch_add(1, std::memory_order_relaxed)); +``` + +- [ ] **Step 4: Propagate required/optional CDB policy through the backend** + +在 `src/build/backend.cppm` 增加: + +```cpp +struct BuildOptions { + bool requireCompileDatabase = false; + // existing fields unchanged +}; + +struct BuildResult { + std::size_t compileCommands = 0; + // existing fields unchanged +}; +``` + +在 `src/build/ninja_backend.cppm` 替换裸调用: + +```cpp +auto cdb = write_compile_commands(plan, flags); +if (!cdb) { + if (opts.requireCompileDatabase) { + return std::unexpected(BuildError{ + std::format("cannot publish compile_commands.json: {}", cdb.error().message), + plan.compileDbPath.empty() ? plan.projectRoot / "compile_commands.json" + : plan.compileDbPath}); + } + mcpp::ui::warning(std::format( + "compile_commands.json was not updated: {}", cdb.error().message)); +} +``` + +创建 dry-run 和真实 build 的 `BuildResult` 时都设置: + +```cpp +r.compileCommands = cdb ? cdb->commandCount : 0; +``` + +普通调用点不设置 `requireCompileDatabase`,所以现有 build/test 在 CDB 失败时继续 Ninja;只有后续 configure runner 将其设为 true。 + +- [ ] **Step 5: Run focused and regression tests and verify GREEN** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" test --no-color -- --gtest_filter='CompileCommands*:BuildOptions.CompileDatabaseIsOptionalByDefault' +MCPP="$FRESH_MCPP" bash tests/e2e/76_compile_commands_generated.sh +MCPP="$FRESH_MCPP" bash tests/e2e/77_cdb_preserves_test_entries.sh +``` + +预期:writer tests 通过;现有 build 仍生成合法 CDB;test entries 的 merge/prune 行为不变。 + +- [ ] **Step 6: Commit atomic CDB publication** + +```bash +git add src/build/compile_commands.cppm src/build/backend.cppm src/build/ninja_backend.cppm tests/unit/test_compile_commands.cpp tests/unit/test_test_options.cpp +git commit -m "fix(build): publish compile database atomically" +``` + +### Task 4: Add Configure Prerequisite Staging and Runner + +**Files:** +- Create: `src/build/configure.cppm` +- Create: `tests/unit/test_configure.cpp` + +- [ ] **Step 1: Write failing BMI staging tests** + +新增 `tests/unit/test_configure.cpp`。fixture 构造最小 `BuildPlan`,准备 std BMI、cached dependency BMI 和 cached object: + +```cpp +#include + +import std; +import mcpp.build.configure; +import mcpp.toolchain.model; + +namespace fs = std::filesystem; + +TEST(ConfigurePrerequisites, StagesOnlyBmisNeededByLanguageTools) { + TempDir t; + mcpp::build::BuildPlan plan; + plan.outputDir = t.path / "out"; + plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; + plan.stdBmiPath = t.path / "cache/std.pcm"; + plan.stdCompatBmiPath = t.path / "cache/std.compat.pcm"; + write_file(plan.stdBmiPath, "std-bmi"); + write_file(plan.stdCompatBmiPath, "std-compat-bmi"); + + mcpp::build::CompileUnit dep; + dep.servedFromCache = true; + dep.providesModule = "demo.dep"; + dep.cachedBmi = t.path / "cache/demo.dep.pcm"; + dep.cachedObject = t.path / "cache/demo.dep.o"; + dep.object = "obj/demo.dep.o"; + write_file(dep.cachedBmi, "dep-bmi"); + write_file(dep.cachedObject, "dep-object"); + plan.compileUnits.push_back(dep); + + auto staged = mcpp::build::stage_configure_prerequisites(plan); + ASSERT_TRUE(staged.has_value()) << staged.error(); + EXPECT_TRUE(fs::exists(plan.outputDir / "pcm.cache/std.pcm")); + EXPECT_TRUE(fs::exists(plan.outputDir / "pcm.cache/std.compat.pcm")); + EXPECT_TRUE(fs::exists(plan.outputDir / "pcm.cache/demo.dep.pcm")); + EXPECT_FALSE(fs::exists(plan.outputDir / dep.object)); +} + +TEST(ConfigurePrerequisites, MissingCachedBmiFailsBeforePublication) { + TempDir t; + mcpp::build::BuildPlan plan; + plan.outputDir = t.path / "out"; + plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; + mcpp::build::CompileUnit dep; + dep.servedFromCache = true; + dep.providesModule = "demo.dep"; + dep.cachedBmi = t.path / "missing/demo.dep.pcm"; + plan.compileUnits.push_back(dep); + + auto staged = mcpp::build::stage_configure_prerequisites(plan); + ASSERT_FALSE(staged.has_value()); + EXPECT_NE(staged.error().find("demo.dep"), std::string::npos); +} +``` + +`TempDir`、`write_file()` 与 Task 2 fixture 同形,但在本文件本地定义,避免测试间隐藏依赖。 + +- [ ] **Step 2: Run focused tests and verify RED** + +```bash +mcpp test --no-color -- --gtest_filter='ConfigurePrerequisites.*' +``` + +预期:编译失败,模块与函数尚不存在。 + +- [ ] **Step 3: Implement `stage_configure_prerequisites()`** + +新增 `src/build/configure.cppm`,在 module fragment 中包含 ``,导入 `mcpp.build.prepare`、`mcpp.build.stage`、`mcpp.build.ninja`、`mcpp.diag`、`mcpp.toolchain.model`、`mcpp.toolchain.registry` 和 `mcpp.ui`。公开: + +```cpp +std::expected +stage_configure_prerequisites(const BuildPlan& plan); + +int run_configure_plan(BuildContext& ctx, bool verbose); +``` + +staging 使用 `stage::StageOptions{.verify = stage::Verify::Size}`,并严格限定为: + +```cpp +// std 与 std.compat 只物化 BMI;clangd 不需要对应 object。 +if (!plan.stdBmiPath.empty()) { + stage_one(plan.stdBmiPath, + mcpp::toolchain::staged_std_bmi_path(plan.toolchain, plan.outputDir), + "std"); +} +if (!plan.stdCompatBmiPath.empty()) { + stage_one(plan.stdCompatBmiPath, + mcpp::toolchain::staged_std_compat_bmi_path(plan.toolchain, plan.outputDir), + "std.compat"); +} + +auto traits = mcpp::toolchain::bmi_traits(plan.toolchain); +for (const auto& unit : plan.compileUnits) { + if (!unit.servedFromCache || !unit.providesModule || unit.cachedBmi.empty()) continue; + std::string fileName; + for (char ch : *unit.providesModule) fileName.push_back(ch == ':' ? '-' : ch); + fileName += traits.bmiExt; + stage_one(unit.cachedBmi, plan.outputDir / traits.bmiDir / fileName, + *unit.providesModule); +} +``` + +任何 staging 失败都返回带 module 名和 `stage_file` 原始诊断的 error;此函数不得复制 `cachedObject`、`stdObjectPath` 或 `stdCompatObjectPath`。 + +- [ ] **Step 4: Implement independent `run_configure_plan()`** + +执行顺序必须是: + +```cpp +int run_configure_plan(BuildContext& ctx, bool verbose) { + auto staged = stage_configure_prerequisites(ctx.plan); + if (!staged) { + mcpp::ui::error(staged.error()); + return 1; + } + + auto backend = mcpp::build::make_ninja_backend(); + BuildOptions options; + options.verbose = verbose; + options.dryRun = true; + options.requireCompileDatabase = true; + auto result = backend->build(ctx.plan, options); + if (!result) { + mcpp::ui::error(result.error().message); + if (!result.error().diagnosticOutput.empty()) + std::fputs(result.error().diagnosticOutput.c_str(), stderr); + return 1; + } + if (!mcpp::diag::flush(ctx.strict)) return 1; + + mcpp::ui::status("Configured", std::format( + "{} ({} compile command{})", ctx.manifest.package.name, + result->compileCommands, result->compileCommands == 1 ? "" : "s")); + return 0; +} +``` + +此函数中不得出现 `bmi_cache::populate_from()`、`write_build_cache()`、`ui::finished()`、`producedArtifacts` 或对 `target/.build_cache` 的写入。不要调用 `run_build_plan()`。 + +- [ ] **Step 5: Run focused tests and verify GREEN** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" test --no-color -- --gtest_filter='ConfigurePrerequisites.*' +``` + +预期:只出现 BMI destination;cached object 和 std object 不存在;缺失 cached BMI 明确失败。 + +- [ ] **Step 6: Commit the configure execution layer** + +```bash +git add src/build/configure.cppm tests/unit/test_configure.cpp +git commit -m "feat(build): add configure-only execution" +``` + +### Task 5: Add `build --configure-only` CLI Routing + +**Files:** +- Modify: `src/cli.cppm:49-90,229-255` +- Modify: `src/cli/cmd_build.cppm:11-104` +- Create: `tests/e2e/202_configure_only_cdb.sh` +- Modify: `tests/e2e/01_help_and_version.sh` + +- [ ] **Step 1: Write the failing CLI E2E shell** + +新增 `tests/e2e/202_configure_only_cdb.sh`,line 2 保持空 capability: + +```bash +#!/usr/bin/env bash +# requires: +set -euo pipefail + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "$TMP/app/src" "$TMP/app/tests" +cat > "$TMP/app/mcpp.toml" <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[build] +flags = [{ glob = "tests/**/*.cpp", defines = ["TEST_CDB_FLAG=1"], cxxflags = ["-DTEST_CXX_FLAG=1"] }] +EOF +cat > "$TMP/app/src/main.cpp" <<'EOF' +int main( { this source is intentionally invalid +EOF +cat > "$TMP/app/tests/smoke.cpp" <<'EOF' +int main() { return 0; } +EOF + +cd "$TMP/app" +out=$("$MCPP" build --configure-only 2>&1) || { + echo "configure-only rejected syntax-error source: $out"; exit 1; +} +[[ "$out" == *"Configured app"* ]] || { echo "missing configured status: $out"; exit 1; } +[[ -s compile_commands.json ]] || { echo "compile_commands.json missing"; exit 1; } +grep -q 'src/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'tests/smoke\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'TEST_CDB_FLAG=1' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'TEST_CXX_FLAG=1' compile_commands.json || { cat compile_commands.json; exit 1; } + +# 只允许配置元数据和 BMI;不得出现普通目标 object、binary 或成功缓存。 +if find target -type f \( -name '*.o' -o -name '*.obj' -o -path '*/bin/*' \) | grep -q .; then + echo "configure-only produced compile/link artifacts"; find target -type f; exit 1 +fi +[[ ! -e target/.build_cache ]] || { echo "configure-only wrote target/.build_cache"; exit 1; } + +echo OK +``` + +在 `tests/e2e/01_help_and_version.sh` 增加: + +```bash +[[ "$out" == *"--configure-only"* ]] || { echo "--help missing --configure-only"; exit 1; } +``` + +- [ ] **Step 2: Run E2E and verify RED** + +```bash +MCPP="$FRESH_MCPP" bash tests/e2e/202_configure_only_cdb.sh +MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh +``` + +预期:`202` 以 unknown option 失败,help test 因缺少 flag 失败。 + +- [ ] **Step 3: Register the CLI flag and help text** + +在 `src/cli.cppm` 的 build subcommand 增加: + +```cpp +.option(cl::Option("configure-only") + .help("Resolve the build plan and write compile_commands.json without compiling or linking")) +``` + +在顶层 `print_usage()` 的 Build options 增加同名行;不要增加 JSON format 选项或 `ide` 子命令。 + +- [ ] **Step 4: Route single-package and workspace configure requests** + +在 `src/cli/cmd_build.cppm` 导入 `mcpp.build.configure` 和 `mcpp.build.test_targets`,读取: + +```cpp +const bool configureOnly = parsed.is_flag_set("configure-only"); +``` + +抽出 cmd-local lambda,确保 workspace 与单 package 使用同一流程: + +```cpp +auto configure = [&](mcpp::build::BuildOverrides selected) -> int { + auto root = mcpp::project::find_manifest_root(std::filesystem::current_path()); + if (!root) { + mcpp::ui::error("no mcpp.toml found in current directory or any parent"); + return 2; + } + auto tests = mcpp::build::discover_test_targets(*root, selected.package_filter); + if (!tests) { + mcpp::ui::error(tests.error()); + return 2; + } + const bool includeDevDeps = !tests->targets.empty(); + auto ctx = mcpp::build::prepare_build( + print_fp, includeDevDeps, std::move(tests->targets), selected); + if (!ctx) { + mcpp::ui::error(ctx.error()); + return 2; + } + return mcpp::build::run_configure_plan(*ctx, verbose); +}; +``` + +workspace fan-out 中按现有 continue-on-failure/首个非零规则调用 `configure(mo)`;单 package 在 fast-path 判断之前处理 `configureOnly` 并直接返回。必须保证 configure-only 永远不调用 `try_fast_build()` 或 `run_build_plan()`。 + +普通 build 路径保持 `includeDevDeps=false` 与现有 fast-path 条件不变。 + +- [ ] **Step 5: Build fresh binary and verify GREEN** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +MCPP="$FRESH_MCPP" bash tests/e2e/202_configure_only_cdb.sh +MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh +``` + +预期:坏源码不触发编译但 CDB 含 source/test/flags;无 `.o/.obj`、无 bin、无 `.build_cache`;help 展示 flag。 + +- [ ] **Step 6: Commit CLI routing** + +```bash +git add src/cli.cppm src/cli/cmd_build.cppm tests/e2e/202_configure_only_cdb.sh tests/e2e/01_help_and_version.sh +git commit -m "feat(cli): add build configure-only mode" +``` + +### Task 6: Complete Dev-Dependency, Workspace, and Publication-Failure Coverage + +**Files:** +- Modify: `tests/e2e/202_configure_only_cdb.sh` + +- [ ] **Step 1: Extend E2E with a local dev-dependency include path** + +在脚本创建 app 前新增本地 dev package: + +```bash +mkdir -p "$TMP/devkit/include" "$TMP/devkit/src" +cat > "$TMP/devkit/mcpp.toml" <<'EOF' +[package] +name = "devkit" +version = "0.1.0" + +[build] +include_dirs = ["include"] +EOF +cat > "$TMP/devkit/src/devkit.cppm" <<'EOF' +export module devkit; +EOF +echo '#define DEVKIT_MARKER 1' > "$TMP/devkit/include/devkit.hpp" +``` + +给 app manifest 增加: + +```toml +[dev-dependencies] +devkit = { path = "../devkit" } +``` + +测试源码改为 `#include `,并用 Python 在 CDB 中精确找到 test entry,断言 arguments 含 devkit include path 和两个 test flags,而 main entry 不含 `TEST_CDB_FLAG`: + +```bash +python3 - compile_commands.json "$TMP/devkit/include" <<'PY' +import json, os, sys +entries = json.load(open(sys.argv[1], encoding="utf-8")) +test = next(e for e in entries if e["file"].replace("\\", "/").endswith("/tests/smoke.cpp")) +main = next(e for e in entries if e["file"].replace("\\", "/").endswith("/src/main.cpp")) +test_args = test["arguments"] +assert any(os.path.normpath(sys.argv[2]) in os.path.normpath(a) for a in test_args), test_args +assert any("TEST_CDB_FLAG=1" in a for a in test_args), test_args +assert any("TEST_CXX_FLAG=1" in a for a in test_args), test_args +assert not any("TEST_CDB_FLAG=1" in a for a in main["arguments"]), main["arguments"] +PY +``` + +- [ ] **Step 2: Extend E2E with workspace fan-out and member selection** + +同一脚本创建 virtual workspace `ws/{a,b}`,每个 member 有独立 `src/main.cpp` 和 `tests/main.cpp`。执行: + +```bash +cd "$TMP/ws" +"$MCPP" build --configure-only > configure-workspace.log +grep -q 'a/src/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'b/src/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'a/tests/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'b/tests/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } + +rm compile_commands.json +"$MCPP" build --configure-only -p a > configure-a.log +grep -q 'a/src/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +if grep -q 'b/src/main\.cpp' compile_commands.json; then + echo "-p a leaked member b into CDB"; cat compile_commands.json; exit 1 +fi +``` + +manifest 使用现有 `[workspace] members = ["a", "b"]` 和最小 package metadata,不引入新的 selector 语义。 + +- [ ] **Step 3: Extend E2E with strict versus warning CDB publication policy** + +创建语法正确的 `publish-policy` 工程,把 `compile_commands.json` 预先建为含 sentinel 的非空目录,使 temp 写成功但 replace 到 directory 失败: + +```bash +mkdir compile_commands.json +echo keep > compile_commands.json/last-known-good + +out=$("$MCPP" build 2>&1) || { echo "normal build failed on optional CDB: $out"; exit 1; } +[[ "$out" == *"compile_commands.json was not updated"* ]] || { + echo "normal build did not warn: $out"; exit 1; +} +[[ -f compile_commands.json/last-known-good ]] || { + echo "normal build removed prior destination"; exit 1; +} + +rc=0 +out=$("$MCPP" build --configure-only 2>&1) || rc=$? +[[ $rc -ne 0 ]] || { echo "configure-only accepted failed CDB publication"; exit 1; } +[[ -f compile_commands.json/last-known-good ]] || { + echo "configure-only removed prior destination"; exit 1; +} +``` + +普通 build 还要断言最终 executable 存在,证明 warning 后 Ninja 确实继续;configure-only 不得输出 `Configured`。 + +- [ ] **Step 4: Run the extended E2E and fix only contract failures** + +```bash +MCPP="$FRESH_MCPP" bash tests/e2e/202_configure_only_cdb.sh +``` + +预期:输出 `OK`。若失败,只修 Task 1-5 已定义的行为;不得为测试新增协议、selector 或 toolchain 分支。 + +- [ ] **Step 5: Run adjacent regression E2Es** + +```bash +MCPP="$FRESH_MCPP" bash tests/e2e/18_devdeps_isolation.sh +MCPP="$FRESH_MCPP" bash tests/e2e/35_workspace.sh +MCPP="$FRESH_MCPP" bash tests/e2e/76_compile_commands_generated.sh +MCPP="$FRESH_MCPP" bash tests/e2e/77_cdb_preserves_test_entries.sh +MCPP="$FRESH_MCPP" bash tests/e2e/157_test_glob_flags.sh +MCPP="$FRESH_MCPP" bash tests/e2e/159_test_list.sh +``` + +预期:全部 `OK`;普通 build 仍不解析 dev-dependencies;现有 workspace/test/CDB 行为无回归。 + +- [ ] **Step 6: Commit complete E2E coverage** + +```bash +git add tests/e2e/202_configure_only_cdb.sh +git commit -m "test(build): cover configure-only workflows" +``` + +### Task 7: Document the User Contract + +**Files:** +- Modify: `README.md:217-223` +- Modify: `docs/00-getting-started.md:74-113` +- Modify: `docs/zh/00-getting-started.md:72-107` + +- [ ] **Step 1: Add concise English and Chinese usage docs** + +README feature list改为明确区分 build 与 configure-only: + +```markdown +- `compile_commands.json` generated automatically; `mcpp build --configure-only` + refreshes it for clangd/ccls without compiling ordinary translation units or + linking final targets +``` + +英文 Getting Started 在 build/run 示例后加入: + +```markdown +For editor setup before the source is buildable, run: + +```bash +mcpp build --configure-only +# Configured hello (... compile commands) +``` + +This resolves the same package, workspace member, profile, features, capability +providers, target and toolchain as a real build, and writes a CDB containing +regular sources plus `tests/**/*.cpp`. It may still run `build.mcpp`, install +missing dependencies/toolchains, and update lock/resolution metadata, so IDEs +must invoke it only in trusted workspaces. It does not compile ordinary TUs, +link final artifacts, or mark the project as successfully built. +``` + +中文文档加入语义等价段落,使用“只配置”而不是“只读”;明确插件只应依赖退出码与根 `compile_commands.json`,不承诺机器 stdout。 + +- [ ] **Step 2: Verify documentation matches help and design** + +```bash +rg -n "configure-only|只配置|trusted workspaces|可信工作区" README.md docs/00-getting-started.md docs/zh/00-getting-started.md +rg -n "NDJSON|snapshot|envelope|current.json|invalidatedBy" README.md docs/00-getting-started.md docs/zh/00-getting-started.md +``` + +预期:第一条命令命中三份文档;第二条无输出,避免把 RFC B/C 内容混入用户契约。 + +- [ ] **Step 3: Verify examples against the fresh binary** + +```bash +"$FRESH_MCPP" --help | grep -F -- '--configure-only' +MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh +MCPP="$FRESH_MCPP" bash tests/e2e/202_configure_only_cdb.sh +``` + +预期:help 和文档使用同一 flag;两个 E2E 均 `OK`。 + +- [ ] **Step 4: Commit documentation** + +```bash +git add README.md docs/00-getting-started.md docs/zh/00-getting-started.md +git commit -m "docs: explain configure-only compile database generation" +``` + +### Task 8: Full Verification, Diff Review, Push, and A-Part PR + +**Files:** +- Review all files changed from `origin/main` + +- [ ] **Step 1: Rebuild from the current branch and select the fresh binary** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" --version +``` + +预期:build 成功,fresh binary 版本与 `mcpp.toml` 一致。 + +- [ ] **Step 2: Run the complete unit/integration suite** + +```bash +"$FRESH_MCPP" test --no-color +``` + +预期:所有 test binaries 通过;不能把 cache hit、未发现测试或旧 binary 当作成功证据。 + +- [ ] **Step 3: Run focused cross-cutting E2Es with the fresh binary** + +```bash +for test_script in \ + tests/e2e/01_help_and_version.sh \ + tests/e2e/18_devdeps_isolation.sh \ + tests/e2e/35_workspace.sh \ + tests/e2e/76_compile_commands_generated.sh \ + tests/e2e/77_cdb_preserves_test_entries.sh \ + tests/e2e/90_workspace_test.sh \ + tests/e2e/157_test_glob_flags.sh \ + tests/e2e/159_test_list.sh \ + tests/e2e/202_configure_only_cdb.sh; do + MCPP="$FRESH_MCPP" bash "$test_script" +done +``` + +预期:每个脚本输出 `OK` 或其既有成功文本。 + +- [ ] **Step 4: Review the diff for scope and cache safety** + +```bash +git diff --check origin/main...HEAD +git diff --stat origin/main...HEAD +git diff origin/main...HEAD -- src/build src/platform src/cli.cppm tests README.md docs/00-getting-started.md docs/zh/00-getting-started.md +rg -n "populate_from|write_build_cache|\.build_cache|ui::finished|run_build_plan" src/build/configure.cppm src/cli/cmd_build.cppm +rg -n "ide |NDJSON|snapshot|envelope|current.json|invalidatedBy|FileLock" src tests/e2e/202_configure_only_cdb.sh README.md docs/00-getting-started.md docs/zh/00-getting-started.md +``` + +预期:`git diff --check` 无输出;configure module 不含被禁止的 build-success side effects;第二个范围扫描不出现 A 部分外协议实现。`cmd_build.cppm` 中普通 build 原有 `run_build_plan()` 命中属于预期,必须人工确认 configure-only 分支不可达。 + +- [ ] **Step 5: Confirm branch state and commit any review-only corrections** + +```bash +git status --short --branch +git log --oneline --decorate origin/main..HEAD +``` + +若自审只发现注释或窄小修正,修改后重新运行对应 focused tests,并提交: + +```bash +git add \ + src/build/test_targets.cppm \ + src/build/configure.cppm \ + src/build/execute.cppm \ + src/build/compile_commands.cppm \ + src/build/backend.cppm \ + src/build/ninja_backend.cppm \ + src/platform/fs.cppm \ + src/cli/cmd_build.cppm \ + src/cli.cppm \ + tests/unit/test_test_targets.cpp \ + tests/unit/test_platform_fs.cpp \ + tests/unit/test_compile_commands.cpp \ + tests/unit/test_configure.cpp \ + tests/unit/test_test_options.cpp \ + tests/e2e/01_help_and_version.sh \ + tests/e2e/202_configure_only_cdb.sh \ + README.md docs/00-getting-started.md docs/zh/00-getting-started.md +git commit -m "fix(build): tighten configure-only guarantees" +``` + +上述命令仅列出本计划拥有的路径;`git add` 会忽略其中未变化的文件。提交前仍需用 +`git diff --cached --name-only` 确认没有用户或其他任务的改动,禁止 `git add -A`。 + +- [ ] **Step 6: Rebase current `origin/main` only after preserving unrelated work** + +```bash +git fetch origin +git status --short +git rebase origin/main +``` + +预期:worktree clean 后 rebase;若出现用户或其他任务的未提交修改,先停止并区分归属,不得丢弃。rebase 后重复 Step 1-4 的 build、full test、focused E2E 与 diff review。 + +- [ ] **Step 7: Push only the feature branch to the fork** + +```bash +git push -u fork codex/configure-only-cdb +``` + +不得 push `main`,不得覆盖 `.xlings.json` pin。 + +- [ ] **Step 8: Create the A-part PR** + +PR title: + +```text +feat: generate compile database without building +``` + +PR body: + +```markdown +## Summary +- add `mcpp build --configure-only` using the real `prepare_build()` / `BuildPlan` path +- include regular sources and `tests/**/*.cpp`, with dev-dependencies and matching `[build].flags` +- stage only std/cached dependency BMIs needed by language tooling +- publish `compile_commands.json` atomically while keeping normal build failures non-fatal + +Part of #379. + +## Scope +- human output and exit code only +- no IDE wire protocol, snapshots, IDs, metadata graph, file lock, or `.xlings.json` change + +## Test plan +- [x] `mcpp build --no-color` +- [x] fresh binary `mcpp test --no-color` +- [x] focused CDB/test/workspace E2Es including `202_configure_only_cdb.sh` +- [ ] GitHub Actions Linux/macOS/Windows required checks +``` + +创建命令: + +```bash +gh pr create \ + --repo mcpp-community/mcpp \ + --head wellwei:codex/configure-only-cdb \ + --base main \ + --title "feat: generate compile database without building" \ + --body-file /tmp/mcpp-configure-only-pr.md +``` + +`/tmp/mcpp-configure-only-pr.md` 只作为临时 PR body,不提交仓库;内容必须与上方正文一致。 + +- [ ] **Step 9: Monitor CI and classify failures before editing** + +```bash +gh pr checks --repo mcpp-community/mcpp --watch +``` + +任一失败先读取本分支最新 run 的失败日志: + +```bash +RUN_ID="$(gh run list --repo mcpp-community/mcpp --branch codex/configure-only-cdb \ + --limit 1 --json databaseId --jq '.[0].databaseId')" +gh run view --repo mcpp-community/mcpp "$RUN_ID" --log-failed +``` + +比较本分支相关测试、同平台 main baseline、capability gating 和缓存状态。只修可归因于 +本 PR 的失败;不把既有环境/索引/toolchain 故障伪装成 configure-only 代码问题。 + +## Final Acceptance Checklist + +- [ ] 坏 C++ 源码仍可成功生成 CDB。 +- [ ] CDB 同时覆盖普通 source 和 tests,并携带 dev-dep include/defines 与 `[build].flags`。 +- [ ] 无测试项目不解析无关 dev-dependencies。 +- [ ] configure-only 不 spawn Ninja、不生成普通 object/bin、不写 `target/.build_cache`、不 populate BMI cache。 +- [ ] std BMI 与已缓存 dependency BMI 在 CDB 发布前物化;缺失/失败时旧 CDB 不变。 +- [ ] CDB 内容未变化不改 mtime。 +- [ ] Windows replacement 不先删除旧文件;Linux/macOS 使用同文件系统 rename。 +- [ ] 普通 build/test 的 CDB failure 只 warning 并继续;configure-only 同类 failure 返回非零。 +- [ ] workspace bare fan-out 与 `-p` 选择沿用既有语义。 +- [ ] 文档明确命令不是只读操作,IDE 必须遵守 workspace trust。 +- [ ] 没有 A 部分外 wire protocol、snapshot、metadata 或 pin 改动。 From 447af308de266c6ea0961d98da12e013e9177e66 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:01:34 +0800 Subject: [PATCH 03/12] refactor(test): share test target discovery --- src/build/execute.cppm | 71 +++-------------------- src/build/test_targets.cppm | 74 ++++++++++++++++++++++++ tests/unit/test_test_targets.cpp | 99 ++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 62 deletions(-) create mode 100644 src/build/test_targets.cppm create mode 100644 tests/unit/test_test_targets.cpp diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 960c45d4..b785ef45 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -11,6 +11,7 @@ export module mcpp.build.execute; import std; import mcpp.build.build_program; // #359 glob inputs the mtime sweep cannot see import mcpp.build.prepare; +import mcpp.build.test_targets; import mcpp.diag; import mcpp.build.plan; import mcpp.build.backend; @@ -1073,72 +1074,18 @@ export int run_tests(std::span passthrough, return 2; } - // Workspace scoping: discovery must run against the MEMBER, not the - // workspace root — otherwise `tests/**/*.cpp` globs every member's tests - // together (two `tests/main.cpp` → "duplicate test name 'main'"). When a - // member is selected (via -p, threaded as package_filter), glob from its - // dir; prepare_build below resolves the SAME member, so the two agree. - // (--workspace fans out over members at the cmd layer, one call per member.) - auto testRoot = *root; - if (auto rm = mcpp::manifest::load(*root / "mcpp.toml"); rm) { - auto member = mcpp::project::resolve_member_dir(*rm, *root, overrides.package_filter); - if (!member) { mcpp::ui::error(member.error()); return 2; } - if (!member->empty()) testRoot = *member; + auto discovered = mcpp::build::discover_test_targets( + *root, overrides.package_filter); + if (!discovered) { + mcpp::ui::error(discovered.error()); + return 2; } - - // 1. Discover test files (scoped to the member/package). - auto testFiles = mcpp::modgraph::expand_glob(testRoot, "tests/**/*.cpp"); - if (testFiles.empty()) { + auto testRoot = discovered->packageRoot; + auto testTargets = std::move(discovered->targets); + if (testTargets.empty()) { std::println("no tests found in tests/"); return 0; } - - // [build].flags globs also cover tests: a glob names files — whether they - // are scanned sources or test TUs is orthogonal. Matched entries ride the - // per-target flag channel (issue #131) on the synthesized test target. - // (Feature-folded entries are prepare-time state; tests take the base - // [build].flags — sufficient for per-test compile options.) - struct TestGlobFlags { - mcpp::manifest::GlobFlags gf; - std::set files; - }; - std::vector testGlobFlags; - if (auto mm = mcpp::manifest::load(testRoot / "mcpp.toml")) { - for (auto const& gf : mm->buildConfig.globFlags) { - auto hits = mcpp::modgraph::expand_glob(testRoot, gf.glob); - testGlobFlags.push_back({gf, {hits.begin(), hits.end()}}); - } - } - - // 2. Synthesize a Target for each test file. - // Name = path relative to tests/, extension dropped, '/' separators — - // so tests/00-a/0.cpp and tests/01-b/0.cpp coexist as '00-a/0' and - // '01-b/0' (stems alone would collide). Flat layouts keep their old - // names ('tests/smoke.cpp' → 'smoke'). - std::vector testTargets; - std::set seenNames; - for (auto& f : testFiles) { - auto rel = std::filesystem::relative(f, testRoot / "tests"); - auto name = rel.replace_extension("").generic_string(); - if (!seenNames.insert(name).second) { - mcpp::ui::error(std::format( - "duplicate test name '{}' (two test files map to the same name)", name)); - return 2; - } - mcpp::manifest::Target t; - t.name = name; - t.kind = mcpp::manifest::Target::TestBinary; - // Relative to the member/package root prepare_build will operate on. - t.main = std::filesystem::relative(f, testRoot).string(); - for (auto const& tgf : testGlobFlags) { - if (!tgf.files.contains(f)) continue; - for (auto const& d : tgf.gf.defines) t.defines.push_back(d); - for (auto const& fl : tgf.gf.cflags) t.cflags.push_back(fl); - for (auto const& fl : tgf.gf.cxxflags) t.cxxflags.push_back(fl); - } - testTargets.push_back(std::move(t)); - } - // --list: enumerate (filtered) tests and stop — no toolchain resolution, // no build. Names/paths come straight from discovery, so this also works // on tests that do not currently compile. diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm new file mode 100644 index 00000000..62441cde --- /dev/null +++ b/src/build/test_targets.cppm @@ -0,0 +1,74 @@ +// mcpp.build.test_targets — discovers convention-based test binaries. + +export module mcpp.build.test_targets; + +import std; +import mcpp.manifest; +import mcpp.modgraph.scanner; +import mcpp.project; + +export namespace mcpp::build { + +struct TestTargetSet { + std::filesystem::path packageRoot; + std::vector targets; +}; + +std::expected +discover_test_targets(const std::filesystem::path& manifestRoot, + std::string_view packageFilter) { + auto packageRoot = manifestRoot; + std::optional packageManifest; + + // manifest 解析失败时保留文件清单,支持 `test --list` 的 best-effort 语义。 + if (auto rootManifest = mcpp::manifest::load(manifestRoot / "mcpp.toml")) { + auto member = mcpp::project::resolve_member_dir( + *rootManifest, manifestRoot, packageFilter); + if (!member) return std::unexpected(member.error()); + // workspace member root is the only root accepted by prepare_build. + if (!member->empty()) packageRoot = *member; + if (auto manifest = mcpp::manifest::load(packageRoot / "mcpp.toml")) + packageManifest = std::move(*manifest); + } + + std::vector globFlags; + if (packageManifest) globFlags = packageManifest->buildConfig.globFlags; + + const auto testFiles = mcpp::modgraph::expand_glob(packageRoot, "tests/**/*.cpp"); + std::vector> globHits; + globHits.reserve(globFlags.size()); + for (auto const& gf : globFlags) { + auto hits = mcpp::modgraph::expand_glob(packageRoot, gf.glob); + globHits.emplace_back(hits.begin(), hits.end()); + } + + TestTargetSet result{packageRoot, {}}; + result.targets.reserve(testFiles.size()); + std::set seenNames; + for (auto const& file : testFiles) { + auto relative = std::filesystem::relative(file, packageRoot / "tests"); + auto name = relative.replace_extension("").generic_string(); + if (!seenNames.insert(name).second) { + return std::unexpected(std::format( + "duplicate test name '{}' (two test files map to the same name)", name)); + } + + mcpp::manifest::Target target; + target.name = name; + target.kind = mcpp::manifest::Target::TestBinary; + target.main = std::filesystem::relative(file, packageRoot).string(); + for (std::size_t i = 0; i < globFlags.size(); ++i) { + if (!globHits[i].contains(file)) continue; + for (auto const& define : globFlags[i].defines) + target.defines.push_back(define); + for (auto const& flag : globFlags[i].cflags) + target.cflags.push_back(flag); + for (auto const& flag : globFlags[i].cxxflags) + target.cxxflags.push_back(flag); + } + result.targets.push_back(std::move(target)); + } + return result; +} + +} // namespace mcpp::build diff --git a/tests/unit/test_test_targets.cpp b/tests/unit/test_test_targets.cpp new file mode 100644 index 00000000..2cb8bb87 --- /dev/null +++ b/tests/unit/test_test_targets.cpp @@ -0,0 +1,99 @@ +#include + +import std; +import mcpp.build.test_targets; + +namespace { + +struct Tmp { + std::filesystem::path path; + Tmp() { + path = std::filesystem::temp_directory_path() + / std::format("mcpp_test_targets_{}", std::random_device{}()); + std::filesystem::create_directories(path); + } + ~Tmp() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } +}; + +void write_file(const std::filesystem::path& path, std::string_view body) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream(path) << body; +} + +void write_manifest(const std::filesystem::path& root, std::string_view body) { + write_file(root / "mcpp.toml", body); +} + +TEST(TestTargets, NestedNamesUseTestsRelativePath) { + Tmp tmp; + write_manifest(tmp.path, R"([package] +name = "demo" +version = "0.1.0" +)"); + write_file(tmp.path / "tests/tagged/nested/smoke.cpp", "int main() {}\n"); + + auto result = mcpp::build::discover_test_targets(tmp.path, {}); + ASSERT_TRUE(result.has_value()) << result.error(); + ASSERT_EQ(result->targets.size(), 1u); + EXPECT_EQ(result->targets[0].name, "tagged/nested/smoke"); + EXPECT_EQ(result->targets[0].main, "tests/tagged/nested/smoke.cpp"); +} + +TEST(TestTargets, GlobFlagsBecomePerTargetFlags) { + Tmp tmp; + write_manifest(tmp.path, R"([package] +name = "demo" +version = "0.1.0" + +[build] +flags = [{ glob = "tests/**/*.cpp", defines = ["TEST_FEATURE"], cflags = ["-Wall"], cxxflags = ["-Wextra"] }] +)"); + write_file(tmp.path / "tests/main.cpp", "int main() {}\n"); + + auto result = mcpp::build::discover_test_targets(tmp.path, {}); + ASSERT_TRUE(result.has_value()) << result.error(); + ASSERT_EQ(result->targets.size(), 1u); + EXPECT_EQ(result->targets[0].defines, std::vector{"TEST_FEATURE"}); + EXPECT_EQ(result->targets[0].cflags, std::vector{"-Wall"}); + EXPECT_EQ(result->targets[0].cxxflags, std::vector{"-Wextra"}); +} + +TEST(TestTargets, PackageFilterScopesWorkspaceMember) { + Tmp tmp; + write_manifest(tmp.path, R"([workspace] +members = ["a", "b"] +)"); + write_manifest(tmp.path / "a", R"([package] +name = "a" +version = "0.1.0" +)"); + write_manifest(tmp.path / "b", R"([package] +name = "b" +version = "0.1.0" +)"); + write_file(tmp.path / "a/tests/main.cpp", "int main() {}\n"); + write_file(tmp.path / "b/tests/main.cpp", "int main() {}\n"); + + auto result = mcpp::build::discover_test_targets(tmp.path, "a"); + ASSERT_TRUE(result.has_value()) << result.error(); + ASSERT_EQ(result->packageRoot, tmp.path / "a"); + ASSERT_EQ(result->targets.size(), 1u); + EXPECT_EQ(result->targets[0].main, "tests/main.cpp"); +} + +TEST(TestTargets, BrokenManifestStillReturnsInventory) { + Tmp tmp; + write_file(tmp.path / "mcpp.toml", "this is not valid TOML\n"); + write_file(tmp.path / "tests/main.cpp", "int main() {}\n"); + + auto result = mcpp::build::discover_test_targets(tmp.path, {}); + ASSERT_TRUE(result.has_value()) << result.error(); + ASSERT_EQ(result->packageRoot, tmp.path); + ASSERT_EQ(result->targets.size(), 1u); + EXPECT_EQ(result->targets[0].name, "main"); +} + +} // namespace From 19163553a881e2420b6b52e06fd8a10656427c2f Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:42:31 +0800 Subject: [PATCH 04/12] feat(fs): add atomic file replacement --- src/platform/fs.cppm | 24 ++++++++++++++ tests/unit/test_platform_fs.cpp | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tests/unit/test_platform_fs.cpp diff --git a/src/platform/fs.cppm b/src/platform/fs.cppm index c0afa2da..d72ad221 100644 --- a/src/platform/fs.cppm +++ b/src/platform/fs.cppm @@ -52,6 +52,11 @@ std::filesystem::path self_exe_path(); // POSIX: `command -v ` std::optional which(std::string_view binary_name); +// 不先删除目标文件,避免发布失败时丢失最后一份可用的编译数据库。 +bool replace_file(const std::filesystem::path& source, + const std::filesystem::path& destination, + std::error_code& ec); + // ── FileLock ────────────────────────────────────────────────────────────── // // RAII exclusive non-blocking file lock. @@ -136,6 +141,25 @@ std::optional which(std::string_view binary_name) { return std::filesystem::path(out); } +bool replace_file(const std::filesystem::path& source, + const std::filesystem::path& destination, + std::error_code& ec) { +#if defined(_WIN32) + // 直接替换已有文件,不能先删除 last-known-good CDB。 + if (MoveFileExW(source.wstring().c_str(), destination.wstring().c_str(), + MOVEFILE_REPLACE_EXISTING)) { + ec.clear(); + return true; + } + ec = std::error_code(static_cast(GetLastError()), std::system_category()); + return false; +#else + // 临时文件与目标文件位于同一文件系统时,rename 提供原子替换。 + std::filesystem::rename(source, destination, ec); + return !ec; +#endif +} + // ── FileLock ────────────────────────────────────────────────────────────── #if defined(_WIN32) diff --git a/tests/unit/test_platform_fs.cpp b/tests/unit/test_platform_fs.cpp new file mode 100644 index 00000000..17e6149d --- /dev/null +++ b/tests/unit/test_platform_fs.cpp @@ -0,0 +1,57 @@ +#include + +import std; +import mcpp.platform.fs; + +namespace { + +struct TempDir { + std::filesystem::path path = std::filesystem::temp_directory_path() + / std::format("mcpp-platform-fs-{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + + TempDir() { std::filesystem::create_directories(path); } + + ~TempDir() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } +}; + +void write_file(const std::filesystem::path& path, std::string_view text) { + std::ofstream out(path); + out << text; +} + +std::string read_file(const std::filesystem::path& path) { + std::ifstream in(path); + return {std::istreambuf_iterator(in), {}}; +} + +TEST(PlatformFs, ReplaceFileAtomicallyReplacesExistingFile) { + TempDir temp; + auto source = temp.path / "new.tmp"; + auto destination = temp.path / "compile_commands.json"; + write_file(source, "new"); + write_file(destination, "old"); + + std::error_code ec; + EXPECT_TRUE(mcpp::platform::fs::replace_file(source, destination, ec)) + << ec.message(); + EXPECT_FALSE(std::filesystem::exists(source)); + EXPECT_EQ(read_file(destination), "new"); +} + +TEST(PlatformFs, ReplaceFailureKeepsExistingDestination) { + TempDir temp; + auto destination = temp.path / "compile_commands.json"; + write_file(destination, "last-known-good"); + + std::error_code ec; + EXPECT_FALSE(mcpp::platform::fs::replace_file( + temp.path / "missing.tmp", destination, ec)); + EXPECT_TRUE(ec); + EXPECT_EQ(read_file(destination), "last-known-good"); +} + +} // namespace From 841487801d42e418662b60f30a65a8d53167fdcb Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:51:18 +0800 Subject: [PATCH 05/12] fix(test): keep discovered targets within package --- src/build/test_targets.cppm | 25 ++++++++++++++++++++--- tests/unit/test_test_targets.cpp | 34 +++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm index 62441cde..a769742d 100644 --- a/src/build/test_targets.cppm +++ b/src/build/test_targets.cppm @@ -14,6 +14,7 @@ struct TestTargetSet { std::vector targets; }; +// 损坏 manifest 时仅提供 best-effort 文件清单;严格调用方仍须走 prepare/validate。 std::expected discover_test_targets(const std::filesystem::path& manifestRoot, std::string_view packageFilter) { @@ -46,8 +47,26 @@ discover_test_targets(const std::filesystem::path& manifestRoot, result.targets.reserve(testFiles.size()); std::set seenNames; for (auto const& file : testFiles) { - auto relative = std::filesystem::relative(file, packageRoot / "tests"); - auto name = relative.replace_extension("").generic_string(); + auto lexical_relative = [&](const std::filesystem::path& base, + std::string_view boundary) + -> std::expected { + auto relative = file.lexically_relative(base); + auto first = relative.begin(); + if (relative.empty() || relative.is_absolute() + || (first != relative.end() && *first == "..")) { + return std::unexpected(std::format( + "test file '{}' escapes {} '{}'", + file.string(), boundary, base.string())); + } + return relative; + }; + + auto testRelative = lexical_relative(packageRoot / "tests", "tests root"); + if (!testRelative) return std::unexpected(testRelative.error()); + auto mainRelative = lexical_relative(packageRoot, "package root"); + if (!mainRelative) return std::unexpected(mainRelative.error()); + + auto name = testRelative->replace_extension("").generic_string(); if (!seenNames.insert(name).second) { return std::unexpected(std::format( "duplicate test name '{}' (two test files map to the same name)", name)); @@ -56,7 +75,7 @@ discover_test_targets(const std::filesystem::path& manifestRoot, mcpp::manifest::Target target; target.name = name; target.kind = mcpp::manifest::Target::TestBinary; - target.main = std::filesystem::relative(file, packageRoot).string(); + target.main = mainRelative->string(); for (std::size_t i = 0; i < globFlags.size(); ++i) { if (!globHits[i].contains(file)) continue; for (auto const& define : globFlags[i].defines) diff --git a/tests/unit/test_test_targets.cpp b/tests/unit/test_test_targets.cpp index 2cb8bb87..ea93fdda 100644 --- a/tests/unit/test_test_targets.cpp +++ b/tests/unit/test_test_targets.cpp @@ -8,8 +8,11 @@ namespace { struct Tmp { std::filesystem::path path; Tmp() { + static std::atomic_uint64_t sequence{0}; + auto timestamp = std::chrono::steady_clock::now().time_since_epoch().count(); path = std::filesystem::temp_directory_path() - / std::format("mcpp_test_targets_{}", std::random_device{}()); + / std::format("mcpp_test_targets_{}_{}", timestamp, + sequence.fetch_add(1, std::memory_order_relaxed)); std::filesystem::create_directories(path); } ~Tmp() { @@ -39,7 +42,8 @@ version = "0.1.0" ASSERT_TRUE(result.has_value()) << result.error(); ASSERT_EQ(result->targets.size(), 1u); EXPECT_EQ(result->targets[0].name, "tagged/nested/smoke"); - EXPECT_EQ(result->targets[0].main, "tests/tagged/nested/smoke.cpp"); + EXPECT_EQ(std::filesystem::path(result->targets[0].main), + std::filesystem::path("tests") / "tagged" / "nested" / "smoke.cpp"); } TEST(TestTargets, GlobFlagsBecomePerTargetFlags) { @@ -81,7 +85,31 @@ version = "0.1.0" ASSERT_TRUE(result.has_value()) << result.error(); ASSERT_EQ(result->packageRoot, tmp.path / "a"); ASSERT_EQ(result->targets.size(), 1u); - EXPECT_EQ(result->targets[0].main, "tests/main.cpp"); + EXPECT_EQ(std::filesystem::path(result->targets[0].main), + std::filesystem::path("tests") / "main.cpp"); +} + +TEST(TestTargets, DirectorySymlinkKeepsLexicalPackageBoundary) { + Tmp tmp; + write_manifest(tmp.path, R"([package] +name = "demo" +version = "0.1.0" +)"); + + Tmp outside; + write_file(outside.path / "external.cpp", "int main() {}\n"); + std::filesystem::create_directories(tmp.path / "tests"); + std::error_code ec; + std::filesystem::create_directory_symlink( + outside.path, tmp.path / "tests/vendor", ec); + if (ec) GTEST_SKIP() << "directory symlink unavailable: " << ec.message(); + + auto result = mcpp::build::discover_test_targets(tmp.path, {}); + ASSERT_TRUE(result.has_value()) << result.error(); + ASSERT_EQ(result->targets.size(), 1u); + EXPECT_EQ(result->targets[0].name, "vendor/external"); + EXPECT_EQ(std::filesystem::path(result->targets[0].main), + std::filesystem::path("tests") / "vendor" / "external.cpp"); } TEST(TestTargets, BrokenManifestStillReturnsInventory) { From 6969b1157ca4b3866b49d7c7f6b74c976f0a574a Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:17:21 +0800 Subject: [PATCH 06/12] fix(build): publish compile database atomically --- src/build/backend.cppm | 2 + src/build/compile_commands.cppm | 150 +++++++++++++++++++++++---- src/build/ninja_backend.cppm | 16 ++- tests/unit/test_compile_commands.cpp | 74 +++++++++++++ tests/unit/test_test_options.cpp | 5 + 5 files changed, 225 insertions(+), 22 deletions(-) diff --git a/src/build/backend.cppm b/src/build/backend.cppm index 66679acc..6d243f3f 100644 --- a/src/build/backend.cppm +++ b/src/build/backend.cppm @@ -12,6 +12,7 @@ enum class BackendKind { Ninja, Native }; struct BuildOptions { bool verbose = false; bool dryRun = false; + bool requireCompileDatabase = false; std::size_t parallelJobs = 0; // Explicit ninja goal targets (LinkUnit::output paths, relative to the // plan's outputDir). Empty = build the full plan (default behavior). @@ -38,6 +39,7 @@ struct BuildResult { std::chrono::milliseconds elapsed { 0 }; std::size_t cacheHits = 0; std::size_t cacheMisses = 0; + std::size_t compileCommands = 0; std::string ninjaProgram; // P0: cached for fast-path rebuilds std::string runtimeEnvKey; // cached for fast-path rebuilds std::string runtimeEnvValue; // cached for fast-path rebuilds diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index f2438d3a..36f7f888 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -18,6 +18,7 @@ import std; import mcpp.build.plan; import mcpp.build.flags; import mcpp.libs.json; +import mcpp.platform.fs; export namespace mcpp::build { @@ -40,8 +41,28 @@ std::string merge_compile_commands( std::string_view existing, const std::function& fileExists); -// Write compile_commands.json to the project root. -void write_compile_commands(const BuildPlan& plan, const CompileFlags& flags); +struct CompileCommandsWriteResult { + bool changed = false; + std::size_t commandCount = 0; +}; + +struct CompileCommandsWriteError { + std::string message; +}; + +using ReplaceFile = std::function; + +std::expected +publish_compile_commands( + const std::filesystem::path& path, + std::string_view fresh, + const std::function& fileExists, + ReplaceFile replaceFile = mcpp::platform::fs::replace_file); + +std::expected +write_compile_commands(const BuildPlan& plan, const CompileFlags& flags); } // namespace mcpp::build @@ -122,6 +143,21 @@ std::vector package_flag_args(const CompileUnit& cu, bool isCSource return split_flags(joined); } +void sort_entries_by_file(nlohmann::json& entries) { + std::stable_sort(entries.begin(), entries.end(), [](auto const& lhs, auto const& rhs) { + auto file = [](auto const& entry) { + return entry.contains("file") && entry["file"].is_string() + ? entry["file"].template get() + : std::string{}; + }; + return file(lhs) < file(rhs); + }); +} + +CompileCommandsWriteError write_error(std::string message) { + return CompileCommandsWriteError{std::move(message)}; +} + } // namespace std::string emit_compile_commands(const BuildPlan& plan, const CompileFlags& flags) { @@ -200,35 +236,107 @@ std::string merge_compile_commands( } } + sort_entries_by_file(merged); + return merged.dump(2) + "\n"; } -void write_compile_commands(const BuildPlan& plan, const CompileFlags& flags) { - auto content = emit_compile_commands(plan, flags); - auto path = plan.compileDbPath.empty() - ? plan.projectRoot / "compile_commands.json" - : plan.compileDbPath; +std::expected +publish_compile_commands( + const std::filesystem::path& path, + std::string_view fresh, + const std::function& fileExists, + ReplaceFile replaceFile) { + auto freshJson = nlohmann::json::parse(fresh, nullptr, /*allow_exceptions=*/false); + if (freshJson.is_discarded() || !freshJson.is_array()) { + return std::unexpected(write_error(std::format( + "fresh compile database for '{}' is not a JSON array", path.string()))); + } - if (std::filesystem::exists(path)) { - std::ifstream is(path); + std::optional existing; + if (std::ifstream is(path, std::ios::binary); is) { std::stringstream ss; ss << is.rdbuf(); - auto existing = ss.str(); + if (is.bad()) { + return std::unexpected(write_error(std::format( + "cannot read existing compile database '{}'", path.string()))); + } + existing = ss.str(); + } - // Preserve still-valid prior entries this plan doesn't cover — chiefly - // tests/ entries a previous `mcpp test` wrote — so a plain `mcpp build` - // doesn't wipe clangd's coverage of test files. Offline-safe: no extra - // dependency resolution, just a merge of real prior plans. - content = merge_compile_commands(content, existing, - [](const std::filesystem::path& p) { return std::filesystem::exists(p); }); + // 完全相同的有效输入不重写文件,避免 clangd 因 mtime 变化重复索引。 + if (existing && *existing == fresh) { + return CompileCommandsWriteResult{false, freshJson.size()}; + } - // Only write if content changed (avoid triggering clangd re-index). - if (existing == content) - return; + std::string content(fresh); + if (existing) { + // 保留仍存在但当前 plan 未覆盖的条目,主要是之前 test 生成的 TU。 + content = merge_compile_commands(content, *existing, fileExists); } - std::ofstream os(path); - os << content; + auto finalJson = nlohmann::json::parse(content, nullptr, /*allow_exceptions=*/false); + if (finalJson.is_discarded() || !finalJson.is_array()) { + return std::unexpected(write_error(std::format( + "final compile database for '{}' is not a JSON array", path.string()))); + } + sort_entries_by_file(finalJson); + content = finalJson.dump(2) + "\n"; + + if (existing && *existing == content) { + return CompileCommandsWriteResult{false, finalJson.size()}; + } + + static std::atomic sequence{0}; + auto temp = path.parent_path() + / std::format(".{}.tmp.{}.{}", path.filename().string(), + std::chrono::steady_clock::now().time_since_epoch().count(), + sequence.fetch_add(1, std::memory_order_relaxed)); + auto cleanup_temp = [&] { + std::error_code cleanupEc; + std::filesystem::remove(temp, cleanupEc); + }; + + std::ofstream output(temp, std::ios::binary | std::ios::trunc); + if (!output) { + return std::unexpected(write_error(std::format( + "cannot open temporary compile database '{}'", temp.string()))); + } + output << content; + output.flush(); + if (!output) { + output.close(); + cleanup_temp(); + return std::unexpected(write_error(std::format( + "cannot write temporary compile database '{}'", temp.string()))); + } + output.close(); + if (!output) { + cleanup_temp(); + return std::unexpected(write_error(std::format( + "cannot close temporary compile database '{}'", temp.string()))); + } + + std::error_code ec; + if (!replaceFile(temp, path, ec)) { + cleanup_temp(); + return std::unexpected(write_error(std::format( + "cannot replace '{}': {}", path.string(), ec.message()))); + } + + return CompileCommandsWriteResult{true, finalJson.size()}; +} + +std::expected +write_compile_commands(const BuildPlan& plan, const CompileFlags& flags) { + auto path = plan.compileDbPath.empty() + ? plan.projectRoot / "compile_commands.json" + : plan.compileDbPath; + return publish_compile_commands( + path, emit_compile_commands(plan, flags), + [](const std::filesystem::path& candidate) { + return std::filesystem::exists(candidate); + }); } } // namespace mcpp::build diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 4c78eb44..2627dba1 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -1546,7 +1546,19 @@ std::expected NinjaBackend::build(const BuildPlan& plan // compile_commands.json — via the dedicated module. auto flags = compute_flags(plan); - write_compile_commands(plan, flags); + auto cdb = write_compile_commands(plan, flags); + if (!cdb) { + if (opts.requireCompileDatabase) { + return std::unexpected(BuildError{ + std::format("cannot publish compile_commands.json: {}", + cdb.error().message), + plan.compileDbPath.empty() + ? plan.projectRoot / "compile_commands.json" + : plan.compileDbPath}); + } + mcpp::ui::warning(std::format( + "compile_commands.json was not updated: {}", cdb.error().message)); + } // A distribution contract that could not be honored is reported, never // silently downgraded — the whole point of the model (INV-1/INV-4 in @@ -1567,6 +1579,7 @@ std::expected NinjaBackend::build(const BuildPlan& plan if (opts.dryRun) { BuildResult r; r.exitCode = 0; + r.compileCommands = cdb ? cdb->commandCount : 0; r.elapsed = std::chrono::duration_cast( std::chrono::steady_clock::now() - t0); return r; @@ -1603,6 +1616,7 @@ std::expected NinjaBackend::build(const BuildPlan& plan // Record ninja binary for P0 fast-path cache. BuildResult r; + r.compileCommands = cdb ? cdb->commandCount : 0; r.ninjaProgram = ninjaProgram; if (!plan.toolchain.envOverrides.empty()) { // Toolchain-declared env (MSVC INCLUDE/LIB/PATH/VSLANG). Encode all diff --git a/tests/unit/test_compile_commands.cpp b/tests/unit/test_compile_commands.cpp index b894106a..1d70fcde 100644 --- a/tests/unit/test_compile_commands.cpp +++ b/tests/unit/test_compile_commands.cpp @@ -7,6 +7,24 @@ using namespace mcpp::build; namespace { +struct TempDir { + std::filesystem::path path = std::filesystem::temp_directory_path() + / std::format("mcpp-compile-commands-{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + + TempDir() { std::filesystem::create_directories(path); } + + ~TempDir() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } +}; + +std::string read_file(const std::filesystem::path& path) { + std::ifstream in(path); + return {std::istreambuf_iterator(in), {}}; +} + // Build a single CDB entry as JSON text. `flag` is a marker we can grep for. std::string entry(std::string_view file, std::string_view flag) { // Keep the file path out of `arguments` so it appears exactly once (in @@ -92,3 +110,59 @@ TEST(CompileCommandsMerge, MalformedExistingFallsBackToFresh) { EXPECT_NE(merged.find("src/main.cpp"), std::string::npos) << merged; EXPECT_NE(merged.find("-O2"), std::string::npos) << merged; } + +TEST(CompileCommandsMerge, SortsFinalEntriesByFile) { + auto fresh = cdb({entry("/p/z.cpp", "-DZ"), entry("/p/a.cpp", "-DA")}); + auto merged = merge_compile_commands( + fresh, "[]", [](const std::filesystem::path&) { return true; }); + + EXPECT_LT(merged.find("/p/a.cpp"), merged.find("/p/z.cpp")); +} + +TEST(CompileCommandsWriter, RejectsNonArrayFreshJson) { + TempDir temp; + auto result = publish_compile_commands( + temp.path / "compile_commands.json", R"({"file":"not-an-array"})", + [](const std::filesystem::path&) { return true; }); + + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().message.find("JSON array"), std::string::npos); +} + +TEST(CompileCommandsWriter, UnchangedContentKeepsMtime) { + TempDir temp; + auto path = temp.path / "compile_commands.json"; + auto content = cdb({entry((temp.path / "a.cpp").string(), "-DOK")}); + std::ofstream(path) << content; + auto before = std::filesystem::last_write_time(path); + + auto result = publish_compile_commands( + path, content, [](const std::filesystem::path&) { return true; }); + + ASSERT_TRUE(result.has_value()) << result.error().message; + EXPECT_FALSE(result->changed); + EXPECT_EQ(std::filesystem::last_write_time(path), before); +} + +TEST(CompileCommandsWriter, ReplacementFailurePreservesOldDatabase) { + TempDir temp; + auto path = temp.path / "compile_commands.json"; + auto oldContent = cdb({entry((temp.path / "old.cpp").string(), "-DOLD")}); + auto newContent = cdb({entry((temp.path / "new.cpp").string(), "-DNEW")}); + std::ofstream(path) << oldContent; + auto failReplace = [](const std::filesystem::path&, + const std::filesystem::path&, + std::error_code& ec) { + ec = std::make_error_code(std::errc::permission_denied); + return false; + }; + + auto result = publish_compile_commands( + path, newContent, [](const std::filesystem::path&) { return true; }, + failReplace); + + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(read_file(path), oldContent); + EXPECT_EQ(std::distance(std::filesystem::directory_iterator(temp.path), + std::filesystem::directory_iterator{}), 1); +} diff --git a/tests/unit/test_test_options.cpp b/tests/unit/test_test_options.cpp index a87ee31f..266a6a7d 100644 --- a/tests/unit/test_test_options.cpp +++ b/tests/unit/test_test_options.cpp @@ -46,6 +46,11 @@ TEST(BuildOptions, BuildTimeoutDefaultsToUnbounded) { EXPECT_EQ(bo.buildTimeoutSecs, 0u); } +TEST(BuildOptions, CompileDatabaseIsOptionalByDefault) { + BuildOptions options; + EXPECT_FALSE(options.requireCompileDatabase); +} + // The timeout verdict travels as a flag, not as a message prefix: run_tests // reports a timed-out compile differently from a broken one, and matching on // prose is exactly how that distinction rots silently. From 784f724df4a3a093d34425bde6e7eb0acc57710e Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:40:22 +0800 Subject: [PATCH 07/12] feat(build): add configure-only execution --- src/build/configure.cppm | 107 ++++++++++++++++++++++++++++++++++ tests/unit/test_configure.cpp | 74 +++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/build/configure.cppm create mode 100644 tests/unit/test_configure.cpp diff --git a/src/build/configure.cppm b/src/build/configure.cppm new file mode 100644 index 00000000..a316f9af --- /dev/null +++ b/src/build/configure.cppm @@ -0,0 +1,107 @@ +// mcpp.build.configure - configure-only prerequisites and execution. + +module; +#include + +export module mcpp.build.configure; + +import std; +import mcpp.build.backend; +import mcpp.build.ninja; +import mcpp.build.plan; +import mcpp.build.prepare; +import mcpp.build.stage; +import mcpp.diag; +import mcpp.toolchain.model; +import mcpp.toolchain.registry; +import mcpp.ui; + +export namespace mcpp::build { + +std::expected +stage_configure_prerequisites(const BuildPlan& plan) { + const auto options = mcpp::build::stage::StageOptions{ + .verify = mcpp::build::stage::Verify::Size}; + std::size_t staged = 0; + + auto stage_one = [&](const std::filesystem::path& source, + const std::filesystem::path& destination, + std::string_view label) + -> std::expected { + auto result = mcpp::build::stage::stage_file(source, destination, options); + if (!result) { + return std::unexpected(std::format( + "cannot stage configure prerequisite '{}' ({}): {}", + label, source.string(), result.error().message)); + } + if (result->copied) ++staged; + return {}; + }; + + // clangd needs the BMI files for import resolution, but never the std + // module's matching object files during configure-only. + if (!plan.stdBmiPath.empty()) { + auto result = stage_one( + plan.stdBmiPath, + mcpp::toolchain::staged_std_bmi_path(plan.toolchain, plan.outputDir), + "std"); + if (!result) return std::unexpected(result.error()); + } + if (!plan.stdCompatBmiPath.empty()) { + auto result = stage_one( + plan.stdCompatBmiPath, + mcpp::toolchain::staged_std_compat_bmi_path( + plan.toolchain, plan.outputDir), + "std.compat"); + if (!result) return std::unexpected(result.error()); + } + + const auto traits = mcpp::toolchain::bmi_traits(plan.toolchain); + for (const auto& unit : plan.compileUnits) { + if (!unit.servedFromCache || !unit.providesModule + || unit.cachedBmi.empty()) continue; + + std::string fileName; + fileName.reserve(unit.providesModule->size() + traits.bmiExt.size()); + for (char ch : *unit.providesModule) + fileName.push_back(ch == ':' ? '-' : ch); + fileName += traits.bmiExt; + + auto result = stage_one( + unit.cachedBmi, plan.outputDir / traits.bmiDir / fileName, + *unit.providesModule); + if (!result) return std::unexpected(result.error()); + } + + return staged; +} + +int run_configure_plan(BuildContext& ctx, bool verbose) { + auto staged = stage_configure_prerequisites(ctx.plan); + if (!staged) { + mcpp::ui::error(staged.error()); + return 1; + } + + auto backend = mcpp::build::make_ninja_backend(); + BuildOptions options; + options.verbose = verbose; + options.dryRun = true; + options.requireCompileDatabase = true; + auto result = backend->build(ctx.plan, options); + if (!result) { + mcpp::ui::error(result.error().message); + if (!result.error().diagnosticOutput.empty()) + std::fputs(result.error().diagnosticOutput.c_str(), stderr); + return 1; + } + if (!mcpp::diag::flush(ctx.strict)) return 1; + + mcpp::ui::status("Configured", std::format( + "{} ({} compile command{})", ctx.manifest.package.name, + result->compileCommands, + result->compileCommands == 1 ? "" : "s")); + return 0; +} + +} // namespace mcpp::build diff --git a/tests/unit/test_configure.cpp b/tests/unit/test_configure.cpp new file mode 100644 index 00000000..78ca5c02 --- /dev/null +++ b/tests/unit/test_configure.cpp @@ -0,0 +1,74 @@ +#include + +import std; +import mcpp.build.configure; +import mcpp.build.plan; +import mcpp.toolchain.model; + +namespace { + +struct TempDir { + std::filesystem::path path = std::filesystem::temp_directory_path() + / std::format("mcpp-configure-{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + + TempDir() { std::filesystem::create_directories(path); } + + ~TempDir() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } +}; + +void write_file(const std::filesystem::path& path, std::string_view text) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path); + out << text; +} + +TEST(ConfigurePrerequisites, StagesOnlyBmisNeededByLanguageTools) { + TempDir temp; + mcpp::build::BuildPlan plan; + plan.outputDir = temp.path / "out"; + plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; + plan.stdBmiPath = temp.path / "cache/std.pcm"; + plan.stdCompatBmiPath = temp.path / "cache/std.compat.pcm"; + write_file(plan.stdBmiPath, "std-bmi"); + write_file(plan.stdCompatBmiPath, "std-compat-bmi"); + + mcpp::build::CompileUnit dep; + dep.servedFromCache = true; + dep.providesModule = "demo.dep"; + dep.cachedBmi = temp.path / "cache/demo.dep.pcm"; + dep.cachedObject = temp.path / "cache/demo.dep.o"; + dep.object = "obj/demo.dep.o"; + write_file(dep.cachedBmi, "dep-bmi"); + write_file(dep.cachedObject, "dep-object"); + plan.compileUnits.push_back(dep); + + auto staged = mcpp::build::stage_configure_prerequisites(plan); + ASSERT_TRUE(staged.has_value()) << staged.error(); + EXPECT_TRUE(std::filesystem::exists(plan.outputDir / "pcm.cache/std.pcm")); + EXPECT_TRUE(std::filesystem::exists(plan.outputDir / "pcm.cache/std.compat.pcm")); + EXPECT_TRUE(std::filesystem::exists(plan.outputDir / "pcm.cache/demo.dep.pcm")); + EXPECT_FALSE(std::filesystem::exists(plan.outputDir / dep.object)); +} + +TEST(ConfigurePrerequisites, MissingCachedBmiFailsBeforePublication) { + TempDir temp; + mcpp::build::BuildPlan plan; + plan.outputDir = temp.path / "out"; + plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; + + mcpp::build::CompileUnit dep; + dep.servedFromCache = true; + dep.providesModule = "demo.dep"; + dep.cachedBmi = temp.path / "missing/demo.dep.pcm"; + plan.compileUnits.push_back(dep); + + auto staged = mcpp::build::stage_configure_prerequisites(plan); + ASSERT_FALSE(staged.has_value()); + EXPECT_NE(staged.error().find("demo.dep"), std::string::npos); +} + +} // namespace From 4b0ef8b7f807ae96455bd963231940644c4ebf38 Mon Sep 17 00:00:00 2001 From: wellwei Date: Sat, 8 Aug 2026 17:07:41 +0800 Subject: [PATCH 08/12] feat(cli): add build configure-only mode --- README.md | 3 +- docs/00-getting-started.md | 21 ++++ docs/zh/00-getting-started.md | 17 +++ src/cli.cppm | 3 + src/cli/cmd_build.cppm | 36 ++++++ tests/e2e/01_help_and_version.sh | 1 + tests/e2e/202_configure_only_cdb.sh | 172 ++++++++++++++++++++++++++++ 7 files changed, 252 insertions(+), 1 deletion(-) create mode 100755 tests/e2e/202_configure_only_cdb.sh diff --git a/README.md b/README.md index 98b8e55b..324c0fe7 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,8 @@ import mcpplibs.cmdline; - Three-layer incremental optimization: front-end dirty check + per-file P1689 dyndep + BMI copy-if-different restat - Fingerprinted BMI cache: hashed by compiler/flags/standard library, shared across projects - Ninja backend: auto-generated build.ninja, parallel compilation -- compile_commands.json generated automatically (ready for clangd / ccls) +- `compile_commands.json` generated automatically (ready for clangd / ccls); use + `mcpp build --configure-only` to refresh it before ordinary sources compile - First-class C support: `.c` files auto-detected, mixed C/C++ projects - User-defined cflags / cxxflags / ldflags / c_standard diff --git a/docs/00-getting-started.md b/docs/00-getting-started.md index 6b7b23a5..31afad59 100644 --- a/docs/00-getting-started.md +++ b/docs/00-getting-started.md @@ -84,6 +84,27 @@ mcpp run The first build downloads the host-aware default toolchain, showing progress and speed along the way. Once downloaded, all mcpp projects share the same sandbox. +### Configure an editor before the first successful build + +If the source is not buildable yet, generate the compilation database without +compiling ordinary translation units or linking final targets: + +```bash +mcpp build --configure-only +# Configured hello (... compile commands) +``` + +The command resolves the same package, workspace member, profile, features, +capability providers, target and toolchain as a real build. Its +`compile_commands.json` covers regular sources and `tests/**/*.cpp`, including +test-only dependencies and matching `[build].flags`, so clangd/ccls can index +the project while it is still being edited. It is a configuration operation, +not a read-only operation: `build.mcpp`, missing dependencies or toolchains, +lock/resolution metadata and build-directory metadata may still be updated. +Run it only in a trusted workspace. The process exit code and the resulting +`compile_commands.json` are the stable integration contract; stdout remains +human-readable. + ## Incremental Compilation and Testing ```bash diff --git a/docs/zh/00-getting-started.md b/docs/zh/00-getting-started.md index 3d5024d6..2531ae09 100644 --- a/docs/zh/00-getting-started.md +++ b/docs/zh/00-getting-started.md @@ -82,6 +82,23 @@ mcpp run 首次构建需下载随宿主选择的默认工具链,期间显示进度与速度。下载完成后,所有 mcpp 项目共用同一份沙盒。 +### 在首次成功构建前配置 IDE + +源码尚未可构建时,可以先生成编译数据库,而不编译普通翻译单元或链接最终目标: + +```bash +mcpp build --configure-only +# Configured hello (... compile commands) +``` + +该命令与普通构建使用相同的包、workspace member、profile、feature、capability、 +target 和 toolchain 解析结果。生成的 `compile_commands.json` 同时覆盖普通源码与 +`tests/**/*.cpp`,并把测试专用依赖及匹配的 `[build].flags` 带入测试 TU,因此 clangd/ccls +可以在代码尚未编译通过时索引工程。它是“只配置”而不是只读操作:`build.mcpp`、缺失的 +依赖或 toolchain、lock/resolution 元数据以及构建目录元数据仍可能被更新,只能在可信 +workspace 中运行。插件稳定依赖进程退出码和生成的 `compile_commands.json`,标准输出仍是 +面向人的文本,不作为机器协议。 + ## 增量编译与测试 ```bash diff --git a/src/cli.cppm b/src/cli.cppm index 5e6c6c10..dd752f6b 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -82,6 +82,7 @@ void print_usage() { std::println(" --verbose, -v Verbose compiler output"); std::println(" --quiet, -q Suppress status output"); std::println(" --print-fingerprint Show toolchain fingerprint and 10 inputs"); + std::println(" --configure-only Generate CDB without compiling or linking"); std::println(" --cache Dependency cache: global (default) | local | off"); std::println(" --no-cache Deprecated alias for --cache=off (clears the build dir)"); std::println(" --no-color Disable colored output"); @@ -228,6 +229,8 @@ int run(int argc, char** argv) { .action(wrap_rc(cmd_new))) .subcommand(cl::App("build") .description("Build the current package") + .option(cl::Option("configure-only") + .help("Generate compile_commands.json without compiling or linking")) .option(cl::Option("print-fingerprint") .help("Show toolchain fingerprint and 10 inputs")) .option(cl::Option("cache").takes_value().value_name("MODE") diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 026deb6e..36a3a8ec 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -12,7 +12,9 @@ import std; import mcpplibs.cmdline; import mcpp.build.prepare; import mcpp.build.execute; +import mcpp.build.configure; import mcpp.build.stage; +import mcpp.build.test_targets; import mcpp.dyndep; import mcpp.log; import mcpp.project; @@ -42,6 +44,7 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) { bool verbose = parsed.is_flag_set("verbose") || mcpp::log::is_verbose(); bool print_fp = parsed.is_flag_set("print-fingerprint"); bool no_cache = parsed.is_flag_set("no-cache"); + bool configure_only = parsed.is_flag_set("configure-only"); mcpp::build::BuildOverrides ov; if (auto t = parsed.value("target")) ov.target_triple = *t; @@ -62,6 +65,31 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) { ov.strict = parsed.is_flag_set("strict"); ov.force_static = parsed.is_flag_set("static"); + auto configure_member = [&](mcpp::build::BuildOverrides memberOv) -> int { + auto root = mcpp::project::find_manifest_root(std::filesystem::current_path()); + if (!root) { + std::println(stderr, "error: no mcpp.toml found in current directory or any parent"); + return 2; + } + auto discovered = mcpp::build::discover_test_targets(*root, + memberOv.package_filter); + if (!discovered) { + std::println(stderr, "error: {}", discovered.error()); + return 2; + } + // configure-only deliberately includes dev-dependencies and test TUs: + // clangd needs the same include/module surface as `mcpp test`, even + // though Ninja is run in dry-run mode and compiles no object. + auto ctx = mcpp::build::prepare_build( + print_fp, /*includeDevDeps=*/!discovered->targets.empty(), + std::move(discovered->targets), std::move(memberOv)); + if (!ctx) { + std::println(stderr, "error: {}", ctx.error()); + return 2; + } + return mcpp::build::run_configure_plan(*ctx, verbose); + }; + // Workspace fan-out: build every member, one per the existing per-package // pipeline (continue-on-failure; first non-zero exit wins). Checked before // the fast path, which is single-package only. @@ -71,6 +99,11 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) { for (auto& mp : *members) { mcpp::build::BuildOverrides mo = ov; mo.package_filter = mp; + if (configure_only) { + int r = configure_member(std::move(mo)); + if (r != 0) rc = r; + continue; + } auto ctx = mcpp::build::prepare_build(print_fp, /*includeDevDeps=*/false, /*extraTargets=*/{}, mo); if (!ctx) { std::println(stderr, "error: {}: {}", mp, ctx.error()); rc = 2; continue; } @@ -80,6 +113,9 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) { return rc; } + if (configure_only) + return configure_member(std::move(ov)); + // P0: try fast-path if inputs haven't changed. Any resolution-affecting // override (--profile/--features/--strict, like --target/--static) must // bypass it: the cached build.ninja was generated without them, so taking diff --git a/tests/e2e/01_help_and_version.sh b/tests/e2e/01_help_and_version.sh index abc81620..ef5e585e 100755 --- a/tests/e2e/01_help_and_version.sh +++ b/tests/e2e/01_help_and_version.sh @@ -19,6 +19,7 @@ out=$("$MCPP" --help) [[ "$out" == *"Usage:"* ]] || { echo "--help missing 'Usage:' section"; exit 1; } [[ "$out" == *"mcpp new"* ]] || { echo "--help missing 'mcpp new'"; exit 1; } [[ "$out" == *"mcpp build"* ]] || { echo "--help missing 'mcpp build'"; exit 1; } +[[ "$out" == *"--configure-only"* ]] || { echo "--help missing '--configure-only'"; exit 1; } # Unknown command exit code (127) — must capture rc explicitly under `set -e` rc=0 diff --git a/tests/e2e/202_configure_only_cdb.sh b/tests/e2e/202_configure_only_cdb.sh new file mode 100755 index 00000000..2f66ede1 --- /dev/null +++ b/tests/e2e/202_configure_only_cdb.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# requires: +# 202_configure_only_cdb.sh — `mcpp build --configure-only` must publish the +# compile database before compiling source files. The generated database is +# intended for clangd, so a broken source is deliberately part of the test. +set -euo pipefail + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "$TMP/devkit/include" "$TMP/devkit/src" +cat > "$TMP/devkit/mcpp.toml" <<'EOF' +[package] +name = "devkit" +version = "0.1.0" + +[build] +include_dirs = ["include"] +EOF +echo '#define DEVKIT_MARKER 1' > "$TMP/devkit/include/devkit.hpp" + +mkdir -p "$TMP/app/src" "$TMP/app/tests" +cd "$TMP/app" + +cat > mcpp.toml <<'EOF' +[package] +name = "configure-only" +version = "0.1.0" +standard = "c++23" + +[build] +flags = [{ glob = "tests/**/*.cpp", cxxflags = ["-DMCPP_CONFIGURE_ONLY_TEST_FLAG=1"] }] + +[dev-dependencies] +devkit = { path = "../devkit" } +EOF + +# This must never reach a compiler in configure-only mode. It still needs a +# CDB entry so clangd can report the actual syntax error interactively. +printf 'int main( { return 0; }\n' > src/main.cpp +cat > tests/test_smoke.cpp <<'EOF' +#include +int test_entry() { return 0; } +EOF + +out=$($MCPP build --configure-only 2>&1) || { + echo "configure-only failed unexpectedly:" + echo "$out" + exit 1 +} + +[[ -f compile_commands.json ]] || { + echo "configure-only did not publish compile_commands.json" + echo "$out" + exit 1 +} + +grep -q 'src/main.cpp' compile_commands.json || { + echo "CDB is missing the ordinary source TU" + cat compile_commands.json + exit 1 +} +grep -q 'tests/test_smoke.cpp' compile_commands.json || { + echo "CDB is missing the test TU" + cat compile_commands.json + exit 1 +} +if command -v python3 >/dev/null 2>&1; then + python3 - compile_commands.json "$TMP/devkit/include" <<'PY' +import json, os, sys +entries = json.load(open(sys.argv[1], encoding="utf-8")) +normal = lambda p: p.replace("\\", "/") +test = next(e for e in entries if normal(e["file"]).endswith("/tests/test_smoke.cpp")) +main = next(e for e in entries if normal(e["file"]).endswith("/src/main.cpp")) +args = test["arguments"] +assert any(os.path.normpath(sys.argv[2]) in os.path.normpath(a) for a in args), args +assert any("MCPP_CONFIGURE_ONLY_TEST_FLAG=1" in a for a in args), args +assert not any("MCPP_CONFIGURE_ONLY_TEST_FLAG=1" in a for a in main["arguments"]), main +PY +else + grep -q -- '-DMCPP_CONFIGURE_ONLY_TEST_FLAG=1' compile_commands.json || { + echo "CDB is missing [build].flags for the test TU" + cat compile_commands.json + exit 1 + } +fi + +if find target -type f \( -name '*.o' -o -name '*.obj' \) -print -quit 2>/dev/null | grep -q .; then + echo "configure-only produced an object file" + find target -type f \( -name '*.o' -o -name '*.obj' \) + exit 1 +fi +[[ ! -d target/bin ]] || { echo "configure-only produced target/bin"; exit 1; } +[[ ! -e target/.build_cache ]] || { echo "configure-only wrote target/.build_cache"; exit 1; } + +# A virtual workspace is configured member-by-member, with each member's CDB +# scoped to its own package. `-p` must select the same scope as normal build. +mkdir -p "$TMP/ws/a/src" "$TMP/ws/a/tests" "$TMP/ws/b/src" "$TMP/ws/b/tests" +cat > "$TMP/ws/mcpp.toml" <<'EOF' +[workspace] +members = ["a", "b"] +EOF +for member in a b; do + cat > "$TMP/ws/$member/mcpp.toml" < "$TMP/ws/$member/src/main.cpp" + printf 'int test_entry() { return 0; }\n' > "$TMP/ws/$member/tests/main.cpp" +done +cd "$TMP/ws" +"$MCPP" build --configure-only > configure-workspace.log 2>&1 || { + cat configure-workspace.log + exit 1 +} +for member in a b; do + cdb="$member/compile_commands.json" + [[ -s "$cdb" ]] || { echo "missing $cdb"; exit 1; } + grep -q "src/main.cpp" "$cdb" || { cat "$cdb"; exit 1; } + grep -q "tests/main.cpp" "$cdb" || { cat "$cdb"; exit 1; } +done +rm -f a/compile_commands.json +"$MCPP" build --configure-only -p a > configure-member.log 2>&1 || { + cat configure-member.log + exit 1 +} +grep -q 'src/main.cpp' a/compile_commands.json || { cat a/compile_commands.json; exit 1; } +if grep -q 'b/src/main.cpp' a/compile_commands.json; then + echo "-p a leaked member b into the CDB" + cat a/compile_commands.json + exit 1 +fi + +# A failed replacement must be non-fatal for a normal build, but fatal for +# configure-only; the pre-existing destination must remain untouched. +mkdir -p "$TMP/publish-policy/src" "$TMP/publish-policy/compile_commands.json" +cat > "$TMP/publish-policy/mcpp.toml" <<'EOF' +[package] +name = "publish-policy" +version = "0.1.0" +standard = "c++20" +EOF +printf 'int main() { return 0; }\n' > "$TMP/publish-policy/src/main.cpp" +echo keep > "$TMP/publish-policy/compile_commands.json/last-known-good" +cd "$TMP/publish-policy" +normal_out=$("$MCPP" build 2>&1) || { + echo "normal build failed on optional CDB publication:" + echo "$normal_out" + exit 1 +} +[[ "$normal_out" == *"compile_commands.json was not updated"* ]] || { + echo "normal build did not warn about CDB publication: $normal_out" + exit 1 +} +[[ -f compile_commands.json/last-known-good ]] || { + echo "normal build removed the prior CDB destination" + exit 1 +} +configure_rc=0 +configure_out=$("$MCPP" build --configure-only 2>&1) || configure_rc=$? +[[ $configure_rc -ne 0 ]] || { + echo "configure-only accepted failed CDB publication: $configure_out" + exit 1 +} +[[ -f compile_commands.json/last-known-good ]] || { + echo "configure-only removed the prior CDB destination" + exit 1 +} + +echo "OK" From 076b145e54cc1c7d7ccafaad1cd36da61fdd6079 Mon Sep 17 00:00:00 2001 From: wellwei Date: Sat, 8 Aug 2026 18:05:39 +0800 Subject: [PATCH 09/12] fix(build): preserve CDB links during atomic publication --- .../2026-08-08-configure-only-cdb-design.md | 8 +-- src/build/compile_commands.cppm | 50 ++++++++++++++++--- src/platform/fs.cppm | 22 +++++--- tests/unit/test_compile_commands.cpp | 48 ++++++++++++++++++ 4 files changed, 111 insertions(+), 17 deletions(-) diff --git a/.agents/docs/2026-08-08-configure-only-cdb-design.md b/.agents/docs/2026-08-08-configure-only-cdb-design.md index 8751a5ba..8c370b65 100644 --- a/.agents/docs/2026-08-08-configure-only-cdb-design.md +++ b/.agents/docs/2026-08-08-configure-only-cdb-design.md @@ -132,6 +132,7 @@ CDB 发布前,并保留旧 CDB。项目自身模块或未缓存依赖模块保 - POSIX 使用同文件系统 `rename`; - Windows 使用 `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`; +- Windows 遇到短暂 sharing violation 时有限退避重试; - 不先删除 destination; - 函数为 `noexcept` 风格,通过 `error_code` 报错。 @@ -140,9 +141,10 @@ CDB 写入流程: 1. 生成并解析 JSON,要求顶层为数组。 2. 与现有有效条目合并并删除已不存在源文件的旧条目。 3. 内容未变化时不写文件,避免无意义触发 clangd 重索引。 -4. 在同目录完成唯一临时文件写入和 flush。 -5. 通过 `replace_file` 原子替换目标。 -6. 替换失败时清理临时文件,返回错误,旧文件保持不变。 +4. 若根 CDB 是文件符号链接,解析并原子更新其目标,保留链接本身。 +5. 在同目录完成带跨进程随机量的临时文件写入和 flush。 +6. 通过 `replace_file` 原子替换目标。 +7. 替换失败时清理临时文件,返回错误,旧文件保持不变。 `write_compile_commands()` 改为返回结构化成功或错误。为保持普通构建兼容性: diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index 36f7f888..6aaa958d 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -253,15 +253,46 @@ publish_compile_commands( "fresh compile database for '{}' is not a JSON array", path.string()))); } + std::filesystem::path publishPath = path; + std::error_code statusEc; + const bool isLink = std::filesystem::is_symlink(path, statusEc); + if (statusEc) { + return std::unexpected(write_error(std::format( + "cannot inspect compile database '{}': {}", path.string(), + statusEc.message()))); + } + if (isLink) { + auto target = std::filesystem::read_symlink(path, statusEc); + if (statusEc) { + return std::unexpected(write_error(std::format( + "cannot resolve compile database link '{}': {}", path.string(), + statusEc.message()))); + } + publishPath = target.is_absolute() ? target : path.parent_path() / target; + } + std::optional existing; - if (std::ifstream is(path, std::ios::binary); is) { + std::ifstream input(publishPath, std::ios::binary); + if (input) { std::stringstream ss; - ss << is.rdbuf(); - if (is.bad()) { + ss << input.rdbuf(); + if (input.bad()) { return std::unexpected(write_error(std::format( - "cannot read existing compile database '{}'", path.string()))); + "cannot read existing compile database '{}'", publishPath.string()))); } existing = ss.str(); + } else { + std::error_code existsEc; + auto exists = std::filesystem::exists(publishPath, existsEc); + if (existsEc) { + return std::unexpected(write_error(std::format( + "cannot inspect existing compile database '{}': {}", + publishPath.string(), existsEc.message()))); + } + if (exists) { + return std::unexpected(write_error(std::format( + "cannot read existing compile database '{}'", publishPath.string()))); + } } // 完全相同的有效输入不重写文件,避免 clangd 因 mtime 变化重复索引。 @@ -288,9 +319,12 @@ publish_compile_commands( } static std::atomic sequence{0}; - auto temp = path.parent_path() - / std::format(".{}.tmp.{}.{}", path.filename().string(), + const auto nonce = std::random_device{}(); + // 临时文件和链接目标同目录,避免 rename 跨文件系统;随机量降低跨进程碰撞概率。 + auto temp = publishPath.parent_path() + / std::format(".{}.tmp.{}.{}.{}", publishPath.filename().string(), std::chrono::steady_clock::now().time_since_epoch().count(), + static_cast(nonce), sequence.fetch_add(1, std::memory_order_relaxed)); auto cleanup_temp = [&] { std::error_code cleanupEc; @@ -318,10 +352,10 @@ publish_compile_commands( } std::error_code ec; - if (!replaceFile(temp, path, ec)) { + if (!replaceFile(temp, publishPath, ec)) { cleanup_temp(); return std::unexpected(write_error(std::format( - "cannot replace '{}': {}", path.string(), ec.message()))); + "cannot replace '{}': {}", publishPath.string(), ec.message()))); } return CompileCommandsWriteResult{true, finalJson.size()}; diff --git a/src/platform/fs.cppm b/src/platform/fs.cppm index d72ad221..90fb3fa9 100644 --- a/src/platform/fs.cppm +++ b/src/platform/fs.cppm @@ -145,13 +145,23 @@ bool replace_file(const std::filesystem::path& source, const std::filesystem::path& destination, std::error_code& ec) { #if defined(_WIN32) - // 直接替换已有文件,不能先删除 last-known-good CDB。 - if (MoveFileExW(source.wstring().c_str(), destination.wstring().c_str(), - MOVEFILE_REPLACE_EXISTING)) { - ec.clear(); - return true; + // 直接替换已有文件,不能先删除 last-known-good CDB。编辑器或杀毒软件 + // 可能短暂占用目标文件,sharing violation 仅做有限退避后再报告失败。 + auto delay = std::chrono::milliseconds{50}; + for (int attempt = 0; attempt < 4; ++attempt) { + if (MoveFileExW(source.wstring().c_str(), destination.wstring().c_str(), + MOVEFILE_REPLACE_EXISTING)) { + ec.clear(); + return true; + } + const auto error = GetLastError(); + if (error != ERROR_SHARING_VIOLATION || attempt == 3) { + ec = std::error_code(static_cast(error), std::system_category()); + return false; + } + std::this_thread::sleep_for(delay); + delay *= 3; } - ec = std::error_code(static_cast(GetLastError()), std::system_category()); return false; #else // 临时文件与目标文件位于同一文件系统时,rename 提供原子替换。 diff --git a/tests/unit/test_compile_commands.cpp b/tests/unit/test_compile_commands.cpp index 1d70fcde..43836d87 100644 --- a/tests/unit/test_compile_commands.cpp +++ b/tests/unit/test_compile_commands.cpp @@ -166,3 +166,51 @@ TEST(CompileCommandsWriter, ReplacementFailurePreservesOldDatabase) { EXPECT_EQ(std::distance(std::filesystem::directory_iterator(temp.path), std::filesystem::directory_iterator{}), 1); } + +TEST(CompileCommandsWriter, ReplacesSymlinkTargetWithoutRemovingLink) { + TempDir temp; + auto target = temp.path / "build" / "compile_commands.json"; + auto link = temp.path / "compile_commands.json"; + std::filesystem::create_directories(target.parent_path()); + auto oldContent = cdb({entry((temp.path / "old.cpp").string(), "-DOLD")}); + auto newContent = cdb({entry((temp.path / "new.cpp").string(), "-DNEW")}); + std::ofstream(target) << oldContent; + std::error_code symlinkEc; + std::filesystem::create_symlink(target, link, symlinkEc); + if (symlinkEc) GTEST_SKIP() << "symlink unavailable: " << symlinkEc.message(); + + auto result = publish_compile_commands( + link, newContent, [](const std::filesystem::path&) { return false; }); + + ASSERT_TRUE(result.has_value()) << result.error().message; + EXPECT_TRUE(std::filesystem::is_symlink(link)); + auto published = read_file(target); + EXPECT_NE(published.find("-DNEW"), std::string::npos); + EXPECT_EQ(published.find("-DOLD"), std::string::npos); +} + +TEST(CompileCommandsWriter, ExistingUnreadableDatabaseIsNotOverwritten) { + TempDir temp; + auto path = temp.path / "compile_commands.json"; + std::ofstream(path) << "last-known-good"; + std::error_code permissionEc; + std::filesystem::permissions( + path, std::filesystem::perms::owner_read, + std::filesystem::perm_options::remove, permissionEc); + if (permissionEc) GTEST_SKIP() << "cannot change permissions: " << permissionEc.message(); + std::ifstream probe(path); + if (probe) { + std::filesystem::permissions(path, std::filesystem::perms::owner_all, + std::filesystem::perm_options::replace, permissionEc); + GTEST_SKIP() << "test user can still read restricted file"; + } + + auto result = publish_compile_commands( + path, cdb({entry((temp.path / "new.cpp").string(), "-DNEW")}), + [](const std::filesystem::path&) { return true; }); + + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().message.find("read existing"), std::string::npos); + std::filesystem::permissions(path, std::filesystem::perms::owner_all, + std::filesystem::perm_options::replace, permissionEc); +} From f2838842ee4c497aea2bd0c421284405db956a9a Mon Sep 17 00:00:00 2001 From: wellwei Date: Sat, 8 Aug 2026 23:08:14 +0800 Subject: [PATCH 10/12] fix(build): publish CDB on fresh workspaces and release handle before replace - publish_compile_commands: treat a missing CDB (symlink_status type == not_found) as the normal first-build case instead of a fatal error. is_symlink sets ec on missing paths on every standard library (generic ENOENT on libstdc++/libc++, system ERROR_FILE_NOT_FOUND on MSVC), so no compile_commands.json was ever written on fresh workspaces and all CDB-dependent e2e tests failed. - close the existing-CDB ifstream before the atomic replace so Windows MoveFileExW can replace the destination (open handles cause Access denied / sharing violation). - unit tests: escape Windows backslash paths in the entry() JSON helper, write the mtime fixture in binary mode so on-disk bytes match on Windows, and add a regression test for publishing with no prior CDB. --- src/build/compile_commands.cppm | 53 ++++++++++++++++------------ tests/unit/test_compile_commands.cpp | 30 ++++++++++++++-- 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index 6aaa958d..67a624e5 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -255,12 +255,17 @@ publish_compile_commands( std::filesystem::path publishPath = path; std::error_code statusEc; - const bool isLink = std::filesystem::is_symlink(path, statusEc); - if (statusEc) { + const auto linkStatus = std::filesystem::symlink_status(path, statusEc); + // A first build has no prior CDB to inspect. symlink_status reports the + // missing path with type()==not_found on libstdc++/libc++/MSVC (the error + // code category differs: generic ENOENT vs system ERROR_FILE_NOT_FOUND), + // so that is the normal fresh-workspace case, not an error. + if (statusEc && linkStatus.type() != std::filesystem::file_type::not_found) { return std::unexpected(write_error(std::format( "cannot inspect compile database '{}': {}", path.string(), statusEc.message()))); } + const bool isLink = linkStatus.type() == std::filesystem::file_type::symlink; if (isLink) { auto target = std::filesystem::read_symlink(path, statusEc); if (statusEc) { @@ -272,28 +277,30 @@ publish_compile_commands( } std::optional existing; - std::ifstream input(publishPath, std::ios::binary); - if (input) { - std::stringstream ss; - ss << input.rdbuf(); - if (input.bad()) { - return std::unexpected(write_error(std::format( - "cannot read existing compile database '{}'", publishPath.string()))); - } - existing = ss.str(); - } else { - std::error_code existsEc; - auto exists = std::filesystem::exists(publishPath, existsEc); - if (existsEc) { - return std::unexpected(write_error(std::format( - "cannot inspect existing compile database '{}': {}", - publishPath.string(), existsEc.message()))); - } - if (exists) { - return std::unexpected(write_error(std::format( - "cannot read existing compile database '{}'", publishPath.string()))); + { + std::ifstream input(publishPath, std::ios::binary); + if (input) { + std::stringstream ss; + ss << input.rdbuf(); + if (input.bad()) { + return std::unexpected(write_error(std::format( + "cannot read existing compile database '{}'", publishPath.string()))); + } + existing = ss.str(); + } else { + std::error_code existsEc; + auto exists = std::filesystem::exists(publishPath, existsEc); + if (existsEc) { + return std::unexpected(write_error(std::format( + "cannot inspect existing compile database '{}': {}", + publishPath.string(), existsEc.message()))); + } + if (exists) { + return std::unexpected(write_error(std::format( + "cannot read existing compile database '{}'", publishPath.string()))); + } } - } + } // input closed before the atomic replace: Windows cannot replace an open file. // 完全相同的有效输入不重写文件,避免 clangd 因 mtime 变化重复索引。 if (existing && *existing == fresh) { diff --git a/tests/unit/test_compile_commands.cpp b/tests/unit/test_compile_commands.cpp index 43836d87..6d51deef 100644 --- a/tests/unit/test_compile_commands.cpp +++ b/tests/unit/test_compile_commands.cpp @@ -29,9 +29,18 @@ std::string read_file(const std::filesystem::path& path) { std::string entry(std::string_view file, std::string_view flag) { // Keep the file path out of `arguments` so it appears exactly once (in // "file") — lets tests count entries per file unambiguously. + // The path is embedded in JSON, so backslashes (Windows) must be escaped: + // `C:\Users\...` would otherwise be rejected as invalid JSON (`\U` is + // not an escape) and every CDB-parsing test would fail on Windows. + std::string fileJson; + fileJson.reserve(file.size()); + for (char c : file) { + if (c == '\\') fileJson += "\\\\"; + else fileJson += c; + } return std::format( R"({{"directory":"/p","file":"{}","arguments":["g++","{}","-c","src.cpp"],"output":"o"}})", - file, flag); + fileJson, flag); } std::string cdb(std::initializer_list entries) { @@ -129,11 +138,28 @@ TEST(CompileCommandsWriter, RejectsNonArrayFreshJson) { EXPECT_NE(result.error().message.find("JSON array"), std::string::npos); } +TEST(CompileCommandsWriter, PublishesFreshDatabaseWhenNoneExists) { + TempDir temp; + auto path = temp.path / "compile_commands.json"; // deliberately absent + auto content = cdb({entry("/p/src/main.cpp", "-O2")}); + + auto result = publish_compile_commands( + path, content, [](const std::filesystem::path&) { return true; }); + + ASSERT_TRUE(result.has_value()) << result.error().message; + EXPECT_TRUE(result->changed); + // The file is re-serialised via nlohmann (sorted + 2-space indent), so + // assert on content, not on byte-equality with the raw input. + auto published = read_file(path); + EXPECT_NE(published.find("/p/src/main.cpp"), std::string::npos) << published; + EXPECT_NE(published.find("-O2"), std::string::npos) << published; +} + TEST(CompileCommandsWriter, UnchangedContentKeepsMtime) { TempDir temp; auto path = temp.path / "compile_commands.json"; auto content = cdb({entry((temp.path / "a.cpp").string(), "-DOK")}); - std::ofstream(path) << content; + std::ofstream(path, std::ios::binary) << content; auto before = std::filesystem::last_write_time(path); auto result = publish_compile_commands( From 77fd836a54f4792ca7e4def791e6034c34f31cc3 Mon Sep 17 00:00:00 2001 From: wellwei Date: Sun, 9 Aug 2026 00:04:11 +0800 Subject: [PATCH 11/12] test(e2e): make 202 CDB path greps separator-agnostic Windows CDBs store file paths with backslashes, so `grep 'src/main.cpp'` never matched and the test failed on the Windows e2e shard once the CDB was actually being generated. Match either separator with `[\/]`. --- tests/e2e/202_configure_only_cdb.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/202_configure_only_cdb.sh b/tests/e2e/202_configure_only_cdb.sh index 2f66ede1..38d97306 100755 --- a/tests/e2e/202_configure_only_cdb.sh +++ b/tests/e2e/202_configure_only_cdb.sh @@ -55,12 +55,12 @@ out=$($MCPP build --configure-only 2>&1) || { exit 1 } -grep -q 'src/main.cpp' compile_commands.json || { +grep -q 'src[\/]main.cpp' compile_commands.json || { echo "CDB is missing the ordinary source TU" cat compile_commands.json exit 1 } -grep -q 'tests/test_smoke.cpp' compile_commands.json || { +grep -q 'tests[\/]test_smoke.cpp' compile_commands.json || { echo "CDB is missing the test TU" cat compile_commands.json exit 1 @@ -118,16 +118,16 @@ cd "$TMP/ws" for member in a b; do cdb="$member/compile_commands.json" [[ -s "$cdb" ]] || { echo "missing $cdb"; exit 1; } - grep -q "src/main.cpp" "$cdb" || { cat "$cdb"; exit 1; } - grep -q "tests/main.cpp" "$cdb" || { cat "$cdb"; exit 1; } + grep -q "src[\\/]main.cpp" "$cdb" || { cat "$cdb"; exit 1; } + grep -q "tests[\\/]main.cpp" "$cdb" || { cat "$cdb"; exit 1; } done rm -f a/compile_commands.json "$MCPP" build --configure-only -p a > configure-member.log 2>&1 || { cat configure-member.log exit 1 } -grep -q 'src/main.cpp' a/compile_commands.json || { cat a/compile_commands.json; exit 1; } -if grep -q 'b/src/main.cpp' a/compile_commands.json; then +grep -q 'src[\/]main.cpp' a/compile_commands.json || { cat a/compile_commands.json; exit 1; } +if grep -q 'b[\/]src[\/]main.cpp' a/compile_commands.json; then echo "-p a leaked member b into the CDB" cat a/compile_commands.json exit 1 From 9e6c8f3a8835b19903b2743f5dbb9be1fe7bd46c Mon Sep 17 00:00:00 2001 From: wellwei Date: Sun, 9 Aug 2026 00:13:20 +0800 Subject: [PATCH 12/12] test(e2e): match JSON-escaped backslash separators in 202 CDB greps Windows CDBs store paths with JSON-escaped backslashes (src\\main.cpp), so one-separator patterns still missed. Allow one or more separators. --- tests/e2e/202_configure_only_cdb.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/202_configure_only_cdb.sh b/tests/e2e/202_configure_only_cdb.sh index 38d97306..f6324e29 100755 --- a/tests/e2e/202_configure_only_cdb.sh +++ b/tests/e2e/202_configure_only_cdb.sh @@ -55,12 +55,12 @@ out=$($MCPP build --configure-only 2>&1) || { exit 1 } -grep -q 'src[\/]main.cpp' compile_commands.json || { +grep -q 'src[\/][\/]*main.cpp' compile_commands.json || { echo "CDB is missing the ordinary source TU" cat compile_commands.json exit 1 } -grep -q 'tests[\/]test_smoke.cpp' compile_commands.json || { +grep -q 'tests[\/][\/]*test_smoke.cpp' compile_commands.json || { echo "CDB is missing the test TU" cat compile_commands.json exit 1 @@ -118,16 +118,16 @@ cd "$TMP/ws" for member in a b; do cdb="$member/compile_commands.json" [[ -s "$cdb" ]] || { echo "missing $cdb"; exit 1; } - grep -q "src[\\/]main.cpp" "$cdb" || { cat "$cdb"; exit 1; } - grep -q "tests[\\/]main.cpp" "$cdb" || { cat "$cdb"; exit 1; } + grep -q "src[\\/][\\/]*main.cpp" "$cdb" || { cat "$cdb"; exit 1; } + grep -q "tests[\\/][\\/]*main.cpp" "$cdb" || { cat "$cdb"; exit 1; } done rm -f a/compile_commands.json "$MCPP" build --configure-only -p a > configure-member.log 2>&1 || { cat configure-member.log exit 1 } -grep -q 'src[\/]main.cpp' a/compile_commands.json || { cat a/compile_commands.json; exit 1; } -if grep -q 'b[\/]src[\/]main.cpp' a/compile_commands.json; then +grep -q 'src[\/][\/]*main.cpp' a/compile_commands.json || { cat a/compile_commands.json; exit 1; } +if grep -q 'b[\/][\/]*src[\/][\/]*main.cpp' a/compile_commands.json; then echo "-p a leaked member b into the CDB" cat a/compile_commands.json exit 1