Skip to content

Commit 2df3b85

Browse files
committed
fix(cdb): preserve tests/ entries across mcpp build (#cdb-test-coverage)
`compile_commands.json` mirrors the current build plan. `mcpp build`'s plan excludes tests/**/*.cpp and dev-deps, yet it writes the SAME cdb as `mcpp test` — so in the edit→build loop the test entries get overwritten and clangd loses all completion in tests/ (gtest, import std, import mcpplibs.*). Fix: `write_compile_commands` now MERGES instead of overwriting — it preserves still-valid prior entries the current plan doesn't cover (the tests/ entries a previous `mcpp test` wrote) and prunes entries whose file no longer exists. `mcpp build` itself is unchanged: it resolves/downloads NO dev-deps, leaving build-only users and the build graph untouched (offline-first). Run `mcpp test` once and test completion persists across every subsequent `mcpp build`. - new pure helper merge_compile_commands(fresh, existing, fileExists) - unit: tests/unit/test_compile_commands.cpp (merge/prune/dedup/bad-json) - e2e: tests/e2e/77_cdb_preserves_test_entries.sh (build keeps test entries) - design: .agents/docs/2026-06-25-cdb-test-coverage-design.md - bump 0.0.62 -> 0.0.63
1 parent 3908add commit 2df3b85

7 files changed

Lines changed: 391 additions & 4 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# compile_commands.json 测试覆盖缺失:分析报告与设计方案
2+
3+
> mcpp 0.0.62/0.0.63 — tests/ 目录无代码提示根因分析 + 修复方案
4+
>
5+
> 关联:[2026-05-12-compile-commands-design.md](2026-05-12-compile-commands-design.md)(cdb 初始设计)
6+
>
7+
> **状态:已定稿并实现**(采用「合并保留」方案,见 §6 / §8 决策备注)。
8+
9+
## 1. 现象
10+
11+
在一个含 `[dev-dependencies] gtest` 的项目里(如 `helloworld`),编辑
12+
`tests/test_smoke.cpp` 时 clangd **无任何代码提示**`gtest::InitGoogleTest()`
13+
`import mcpplibs.cmdline``import std;` 全部报红、无补全、无跳转。
14+
15+
用户初判:「是不是生成的 `compile_commands.json` 没有包含 dev-dependencies 下的库?」
16+
17+
## 2. 结论(TL;DR)
18+
19+
`compile_commands.json` 只反映**当前这一次构建命令的 `BuildPlan`**
20+
21+
- `mcpp build` / `mcpp run` 的 plan **不含** `tests/**/*.cpp`,也**不解析
22+
dev-dependencies**
23+
- 只有 `mcpp test` 的 plan 才把 tests + dev-deps 纳入。
24+
- 两个命令写**同一个** `<projectRoot>/compile_commands.json`**后写覆盖前写**
25+
26+
日常「编辑 → `mcpp build`」循环里,cdb 几乎总是被 `mcpp build` 写成「无 tests」
27+
版本 → clangd 找不到测试文件的编译条目 → 退化为无标志猜测 → tests/ 全无提示。
28+
29+
用户的判断**基本正确**:缺的直接原因是 cdb 里没有该测试文件的条目;而这个条目
30+
恰恰是 gtest 等 dev-dependency `-I` 头目录的**唯一载体**——「缺测试文件」与
31+
「缺 dev-deps」是同一个根因。
32+
33+
## 3. 复现证据
34+
35+
`/home/speak/test/mcpp/helloworld` 实测,cdb 中出现的 `file`
36+
37+
| 命令 | cdb 中的文件 |
38+
|---|---|
39+
| `mcpp build` | 3 个依赖 cppm + `src/main.cpp`**无 tests**|
40+
| `mcpp test` | 上述 + `tests/test_smoke.cpp` + `gtest-all.cc` / `gtest_main.cc`**完整**|
41+
| `mcpp build`(改 `src/main.cpp` 后真实重建)| 又回退成**无 tests** |
42+
43+
并确认:`mcpp test` 写出的 `test_smoke.cpp` 条目**带全了**
44+
`-I.../gtest/include``-std=c++23``std.pcm``-fprebuilt-module-path` ——
45+
只要这条条目在,补全就完全正常。
46+
47+
一个反直觉但已查清的细节:连续多次 `mcpp build`(全缓存、0.00s)**不会**回退
48+
cdb —— 因为 P0 fast-path(`try_fast_build``execute.cppm:269`)在 `build.ninja`
49+
新鲜时**整体跳过后端**,根本不重写 cdb。但只要发生一次真实重建,`mcpp build`
50+
就用无 tests 的 plan 覆盖它。这正是「有时有提示、改完代码后又没了」的来源。
51+
52+
## 4. 根因链(代码定位)
53+
54+
1. `src/build/compile_commands.cppm` — cdb 完全由
55+
`for (auto& cu : plan.compileUnits)` 生成,**等于当次 plan 的镜像**
56+
2. `src/build/ninja_backend.cppm:693``backend->build()` 内部调用
57+
`write_compile_commands(plan, flags)`,写到 `plan.projectRoot/compile_commands.json`
58+
3. `src/build/execute.cppm:377``mcpp build`
59+
`prepare_build(includeDevDeps=false, extraTargets={})` → plan 无 tests、无 dev-deps。
60+
4. `src/build/execute.cppm:456``mcpp test`
61+
`prepare_build(includeDevDeps=true, testTargets=...)` → plan 含 tests + dev-deps。
62+
5. 两条路径写**同一个文件** → 互相覆盖;`mcpp build` 频率远高 → cdb 长期处于
63+
「无 tests」态。
64+
6. `write_compile_commands` 现为**全量覆盖**(仅做 content 相等短路),不保留既有条目。
65+
66+
## 5. 两条关键架构约束(决定了方案走向)
67+
68+
实现过程中发现两点,直接否决了「让 `mcpp build` 主动生成测试 cdb」的朴素思路:
69+
70+
### 5.1 `inTestMode` 互斥(`plan.cppm:460`
71+
存在 `TestBinary` 目标时,整张 plan **互斥地只构建测试二进制**,跳过常规
72+
`Binary`/`Library`——否则把 gtest 的 `main``gtest_main.cc`)拉进项目常规 bin
73+
`multiple definition of 'main'`。即「一张 plan 同时构建普通 bin + 可链接的
74+
测试单元」在当前架构下不成立。
75+
76+
### 5.2 offline-first 是本仓库的硬架构约束
77+
`prepare_build(includeDevDeps=true)` 注释明确「dev-deps are also **fetched** +
78+
scanned」。让 `mcpp build` 为了 IDE 去解析 dev-deps,会在 gtest 未安装时**触发
79+
下载****违背 offline-first**(仓库多处 offline-first 注释),并坑了「只想
80+
build、不想 test」的用户——既改了 build-only 行为,又凭空拉包。
81+
82+
### 5.3 一个使纯方案可行又使其无必要的事实:build/test 共用输出目录
83+
`fingerprint.dependencyLockHash = ""`(M2 未实现,`prepare.cppm:2234`),且
84+
`compileFlags` 不含 dev-deps → **dev-deps 不影响 fingerprint**`mcpp build`
85+
`mcpp test` 必然落在**同一个** `target/<triple>/<fp>/` 目录。
86+
87+
推论:测试文件补全本就**必须**先跑一次 `mcpp test`/install——`#include
88+
<gtest/gtest.h>` 需要 gtest **已安装**(装包只能由 test/install 做),
89+
`import std` / `import mcpplibs.cmdline` 需要 BMI **已构建**。三者只有 `mcpp test`
90+
能一次备齐,且它们都落在共享目录里。所以「无网络、没跑过 test 就白嫖测试补全」
91+
物理上不可能——这恰恰说明把「拉依赖」塞进 `mcpp build` 既不对也没必要。
92+
93+
## 6. 方案:offline-first 的 cdb「合并保留」(已实现)
94+
95+
**核心**`mcpp test` 已经能写出完整且正确的 cdb(测试条目 flag 有效、其引用的
96+
BMI 因共用目录而真实存在)。bug 的本质是 **`mcpp build` 会摧毁它**。因此最小且
97+
合理的修复是:让 `mcpp build` **停止摧毁**,而不是让它去重新生成。
98+
99+
`write_compile_commands` 从「全量覆盖」改为「**合并**」:
100+
101+
- 当前 plan 覆盖的文件 → 用本次条目(权威、最新 flag);
102+
- 当前 plan ****覆盖、但**文件仍存在于磁盘**的旧条目(即上次 `mcpp test`
103+
写入的 `tests/*.cpp`、gtest 源)→ **保留**
104+
- 文件已不存在的旧条目 → **剪除**(cdb 不积累死引用)。
105+
- 保留 fresh 顺序,旧条目追加其后(确定性、最小 churn);内容不变则不重写
106+
(不触发 clangd 重索引)。
107+
- 旧 cdb 解析失败 → 回退为纯 fresh(永不因坏文件破坏生成)。
108+
109+
`mcpp build` 自身**零改动**:不解析、不下载任何 dev-deps,不动构建图。对
110+
build-only 用户行为与今天**完全一致**;只是不再把 `mcpp test` 的成果擦掉。
111+
112+
### 6.1 实现位置
113+
- `src/build/compile_commands.cppm`
114+
- 新增纯函数 `merge_compile_commands(fresh, existing, fileExists)`:可注入
115+
`fileExists` 谓词,无需真实 FS,完全可单测;用 nlohmann 非抛出解析。
116+
- `write_compile_commands`:读现有 cdb → `merge_compile_commands(...)`
117+
内容变化才写。
118+
- 其余文件(`plan.cppm` / `prepare.cppm` / `ninja_backend.cppm` /
119+
`execute.cppm`**均无改动**。构建图 100% 不变 → 跨平台零回归。
120+
121+
### 6.2 测试
122+
- 单测 `tests/unit/test_compile_commands.cpp`(TDD,先 RED 后 GREEN):
123+
保留未覆盖条目 / 剪除已删文件 / fresh 胜出且不重复 / 坏 JSON 回退。
124+
- e2e `tests/e2e/77_cdb_preserves_test_entries.sh``mcpp test`**真实重建**
125+
`mcpp build`(改 main.cpp 破 fast-path),断言测试条目存活 + src 仍在 +
126+
删除测试文件后条目被剪除。无 `requires:`,三平台均跑(对标 15/16/17)。
127+
128+
### 6.3 用户使用语义
129+
- 新建/已有项目:跑一次 `mcpp test`(你本来就要跑来测试,且它会装 gtest +
130+
构建 BMI)→ cdb 完整、tests/ 有补全;
131+
- 此后任意 `mcpp build`(含真实重建)**保留**该补全,不再回退;
132+
- 只想 build 的用户:完全不受影响,`mcpp build` 不拉任何 test 依赖。
133+
134+
## 7. 被否决的备选方案
135+
136+
| 方案 | 否决理由 |
137+
|---|---|
138+
| **纯方案 B**`mcpp build` 主动解析+拉 dev-deps 写测试条目 | 违背 offline-first;改 build-only 行为;凭空拉包;且因 §5.3 本就需先 `mcpp test`,收益为零。用户明确反对。 |
139+
| **plan 携带 ideOnly 测试单元 + ninja default 排除** | 受 §5.1 `inTestMode` 互斥牵连,需深改 `make_plan`/解析器(2400 行),破坏构建图的风险高,违背「不破坏架构合理性」。 |
140+
| **双趟 `prepare_build`(build 后再算一遍测试 plan 喂 cdb)** | 仍要解析 dev-deps(offline-first 问题不变);且 build 规划成本翻倍。 |
141+
| **独立 `mcpp cdb` 命令** | 多一个命令面;仍需 dev-deps 已装;合并方案已用更小代价覆盖同一目标。 |
142+
143+
## 8. 决策备注(为什么最终选「合并保留」)
144+
145+
1. **尊重既有架构而非对抗它**:offline-first(§5.2)与 `inTestMode` 互斥
146+
(§5.1)是仓库既定架构。合并方案不触碰二者;其余方案都要么违背 offline-first,
147+
要么深改构建核心。用户指令「方案 B 必须综合考虑架构,不要只为完成目标破坏
148+
合理性」——遵循该指令的正解就是**放弃纯 B 的实现形态、保留 B 的目标**
149+
(cdb 覆盖完整编译面),用 offline-first 的合并达成。
150+
2. **诚实的投影语义**:合并后的 cdb 仍只由**真实 plan**构成(本次 build plan ∪
151+
上次 test plan 的存活条目),不凭空捏造条目,不指向不存在的 BMI(§5.3 保证
152+
存活条目的 BMI 真实在共享目录里)。
153+
3. **「先跑一次 test」不是缺陷而是内在事实**(§5.3):任何方案都绕不开它,所以
154+
合并方案相比纯 B **无实际功能损失**,却换来 offline-first + build-only 零影响 +
155+
构建图零风险 + 极小改动面。
156+
4. **可逆与可演进**:若将来 M2 lockfile 让 build/test 目录分叉(§5.3 前提失效),
157+
届时存活条目的 BMI 路径可能失效;那是 follow-up,应在 lockfile 设计里保证
158+
「非测试构建的 fingerprint 不被 dev-deps 扰动」,与本方案正交。

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@
33
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
44
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
55
6+
## [0.0.63] — 2026-06-25
7+
8+
### 修复
9+
10+
- **`tests/` 目录无代码提示**:clangd 在测试文件里对 `gtest::InitGoogleTest()`
11+
`import std` / `import mcpplibs.*` 全无补全。根因:`compile_commands.json` 是当次构建
12+
plan 的镜像,`mcpp build` 的 plan 不含 `tests/**/*.cpp` 与 dev-deps,而它与 `mcpp test`
13+
写同一个 cdb——后写覆盖前写,日常「编辑→build」循环里测试条目几乎总被擦掉。修复:
14+
`write_compile_commands` 由「全量覆盖」改为「**合并保留**」——保留当前 plan 未覆盖但
15+
文件仍存在的旧条目(上次 `mcpp test` 写入的测试条目),剪除已删文件。`mcpp build` 自身
16+
**零改动**:不解析、不下载任何 dev-deps,build-only 用户与构建图均不受影响(offline-first)。
17+
跑一次 `mcpp test` 后,测试补全在后续所有 `mcpp build` 中持久生效。
18+
详见 `.agents/docs/2026-06-25-cdb-test-coverage-design.md`
19+
20+
### 测试
21+
22+
- 新增单测 `tests/unit/test_compile_commands.cpp`(合并/剪除/去重/坏 JSON 回退)与跨平台
23+
e2e `77_cdb_preserves_test_entries.sh`(`mcpp test` 后真实重建 `mcpp build` 仍保留测试条目)。
24+
625
## [0.0.62] — 2026-06-24
726

827
### 修复

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "0.0.62"
3+
version = "0.0.63"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

src/build/compile_commands.cppm

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ export namespace mcpp::build {
2424
// Generate compile_commands.json content as a string.
2525
std::string emit_compile_commands(const BuildPlan& plan, const CompileFlags& flags);
2626

27+
// Merge freshly-emitted CDB text (`fresh`, from the current build plan) with a
28+
// prior CDB on disk (`existing`). A prior entry is preserved ONLY when its
29+
// `file` is absent from `fresh` AND still exists on disk (per `fileExists`);
30+
// everything else comes from `fresh`. Result is sorted by `file` for stable
31+
// output. A malformed `existing` is ignored (falls back to `fresh`).
32+
//
33+
// Rationale: `mcpp build` regenerates the CDB from a plan that lacks test files
34+
// / dev-deps, while `mcpp test` writes them in. Without merging, whichever ran
35+
// last wins and clangd loses coverage for tests/ (no completion). Merging makes
36+
// the CDB the union of every command's real plan — offline-safe, no extra
37+
// dependency resolution. See .agents/docs/2026-06-25-cdb-test-coverage-design.md.
38+
std::string merge_compile_commands(
39+
std::string_view fresh,
40+
std::string_view existing,
41+
const std::function<bool(const std::filesystem::path&)>& fileExists);
42+
2743
// Write compile_commands.json to the project root.
2844
void write_compile_commands(const BuildPlan& plan, const CompileFlags& flags);
2945

@@ -140,16 +156,58 @@ std::string emit_compile_commands(const BuildPlan& plan, const CompileFlags& fla
140156
return entries.dump(2) + "\n";
141157
}
142158

159+
std::string merge_compile_commands(
160+
std::string_view fresh,
161+
std::string_view existing,
162+
const std::function<bool(const std::filesystem::path&)>& fileExists) {
163+
auto freshJ = nlohmann::json::parse(fresh, nullptr, /*allow_exceptions=*/false);
164+
if (freshJ.is_discarded() || !freshJ.is_array())
165+
return std::string(fresh);
166+
167+
// Files the current plan already covers — those entries are authoritative.
168+
std::set<std::string> freshFiles;
169+
for (auto const& e : freshJ) {
170+
if (e.contains("file") && e["file"].is_string())
171+
freshFiles.insert(e["file"].get<std::string>());
172+
}
173+
174+
// Keep fresh order, then append still-valid prior entries the plan doesn't
175+
// cover (e.g. tests/ from a previous `mcpp test`). Drop entries for files
176+
// that no longer exist so the CDB never accrues dead references.
177+
nlohmann::json merged = freshJ;
178+
auto existingJ = nlohmann::json::parse(existing, nullptr, /*allow_exceptions=*/false);
179+
if (!existingJ.is_discarded() && existingJ.is_array()) {
180+
for (auto const& e : existingJ) {
181+
if (!e.contains("file") || !e["file"].is_string()) continue;
182+
auto f = e["file"].get<std::string>();
183+
if (freshFiles.contains(f)) continue; // fresh wins
184+
if (!fileExists(std::filesystem::path(f))) continue; // pruned
185+
merged.push_back(e);
186+
}
187+
}
188+
189+
return merged.dump(2) + "\n";
190+
}
191+
143192
void write_compile_commands(const BuildPlan& plan, const CompileFlags& flags) {
144193
auto content = emit_compile_commands(plan, flags);
145194
auto path = plan.projectRoot / "compile_commands.json";
146195

147-
// Only write if content changed (avoid triggering clangd re-index).
148196
if (std::filesystem::exists(path)) {
149197
std::ifstream is(path);
150198
std::stringstream ss;
151199
ss << is.rdbuf();
152-
if (ss.str() == content)
200+
auto existing = ss.str();
201+
202+
// Preserve still-valid prior entries this plan doesn't cover — chiefly
203+
// tests/ entries a previous `mcpp test` wrote — so a plain `mcpp build`
204+
// doesn't wipe clangd's coverage of test files. Offline-safe: no extra
205+
// dependency resolution, just a merge of real prior plans.
206+
content = merge_compile_commands(content, existing,
207+
[](const std::filesystem::path& p) { return std::filesystem::exists(p); });
208+
209+
// Only write if content changed (avoid triggering clangd re-index).
210+
if (existing == content)
153211
return;
154212
}
155213

src/toolchain/fingerprint.cppm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import mcpp.toolchain.detect;
1818

1919
export namespace mcpp::toolchain {
2020

21-
inline constexpr std::string_view MCPP_VERSION = "0.0.62";
21+
inline constexpr std::string_view MCPP_VERSION = "0.0.63";
2222

2323
struct FingerprintInputs {
2424
Toolchain toolchain;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env bash
2+
# requires:
3+
# 77_cdb_preserves_test_entries.sh — a plain `mcpp build` must NOT wipe the
4+
# tests/ entries that a prior `mcpp test` wrote into compile_commands.json.
5+
#
6+
# Regression for the "no completion in tests/" bug: `mcpp build` regenerates the
7+
# CDB from a plan that excludes test files + dev-deps, so before the merge fix it
8+
# overwrote the complete CDB `mcpp test` produced, and clangd lost all coverage
9+
# of tests/*.cpp (gtest, import std, ...). The fix preserves still-valid prior
10+
# entries. `mcpp build` itself still does ZERO dev-dep resolution — build-only
11+
# users are unaffected; coverage simply survives across the edit→build loop.
12+
#
13+
# No `requires:` capability → runs on all three CI platforms, mirroring the
14+
# other `mcpp test` e2e tests (15/16/17).
15+
set -e
16+
17+
TMP=$(mktemp -d)
18+
trap "rm -rf $TMP" EXIT
19+
20+
cd "$TMP"
21+
"$MCPP" new app > /dev/null
22+
cd app
23+
24+
cdb=compile_commands.json
25+
test_file='test_smoke\.cpp'
26+
27+
# 1. `mcpp test` produces a complete CDB that includes the test file.
28+
"$MCPP" test > /dev/null
29+
grep -q "$test_file" "$cdb" || {
30+
echo "FAIL: after 'mcpp test', $cdb has no entry for tests/test_smoke.cpp"
31+
cat "$cdb"; exit 1
32+
}
33+
34+
# 2. Force a real `mcpp build` rebuild (defeat the build.ninja fast-path) so the
35+
# CDB is actually regenerated — otherwise the merge path isn't exercised.
36+
printf '\n// touch to force rebuild\n' >> src/main.cpp
37+
"$MCPP" build > /dev/null
38+
39+
# 3. The test entry must survive, and the regular source must still be present.
40+
grep -q "$test_file" "$cdb" || {
41+
echo "FAIL: 'mcpp build' wiped the tests/test_smoke.cpp entry from $cdb"
42+
cat "$cdb"; exit 1
43+
}
44+
grep -q 'main\.cpp' "$cdb" || {
45+
echo "FAIL: $cdb lost the src/main.cpp entry after build"
46+
cat "$cdb"; exit 1
47+
}
48+
49+
# 4. Deleting a test file must prune its stale entry on the next build.
50+
rm tests/test_smoke.cpp
51+
printf '\n// touch again\n' >> src/main.cpp
52+
"$MCPP" build > /dev/null
53+
if grep -q "$test_file" "$cdb"; then
54+
echo "FAIL: $cdb kept a stale entry for the deleted tests/test_smoke.cpp"
55+
cat "$cdb"; exit 1
56+
fi
57+
58+
echo "OK"

0 commit comments

Comments
 (0)