From 1aaac85b461ee4f423002e07c64c7351fde0d23c Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Thu, 25 Jun 2026 13:29:18 +0800 Subject: [PATCH 1/2] feat: feature-gated dependency sources + mcpp add --dev (fix #168 gtest_main) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #168: gtest as a regular dependency injected gtest_main.o (its own main) into a `mcpp build` app, colliding with the app's main (LNK2005 / duplicate symbol). Generic fix — feature-gated sources: - A dependency descriptor may declare `[mcpp].features..sources`; a source listed there is EXCLUDED from the default build and only compiled/linked when that feature is active for the dep (`dep = { version, features=["name"] }`). - gtest descriptor (mcpp-index) puts gtest_main.cc behind the `main` feature → default `gtest = "1.15.2"` links framework only (no main collision); opt in with `features=["main"]`. Reuses the existing feature activation; new effect is "feature → sources" (manifest.cppm synthesize + prepare.cppm). - Gating applies only in build mode (!includeDevDeps); `mcpp test` keeps the dev-dependency main-detection track (0.0.64) unchanged — the two tracks stay decoupled. Descriptor keeps gtest_main.cc in base `sources` too, so OLD mcpp (ignores `features`) is unaffected (verified). mcpp add --dev → writes [dev-dependencies] (was missing). - unit: SynthesizeFromXpkgLua.FeatureGatedSources - e2e: 79_gtest_regular_dep_feature_main.sh (#168 sentinel + opt-in + add --dev) - release.yml: default xlings 0.4.58 -> 0.4.60 (cache key bumped) - bump 0.0.64 -> 0.0.65 - design: .agents/docs/2026-06-25-gtest-main-feature-and-add-dev-design.md --- .github/workflows/release.yml | 20 +++---- CHANGELOG.md | 29 +++++++++ mcpp.toml | 2 +- src/build/prepare.cppm | 38 +++++++++++- src/cli.cppm | 2 + src/manifest.cppm | 56 ++++++++++++++++++ src/pm/commands.cppm | 10 +++- src/toolchain/fingerprint.cppm | 2 +- .../e2e/79_gtest_regular_dep_feature_main.sh | 59 +++++++++++++++++++ tests/unit/test_manifest.cpp | 29 +++++++++ 10 files changed, 229 insertions(+), 18 deletions(-) create mode 100755 tests/e2e/79_gtest_regular_dep_feature_main.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d93dcb..1522c86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,9 +86,9 @@ jobs: uses: actions/cache@v4 with: path: ~/.xlings - key: xlings-${{ runner.os }}-release-xl0448-${{ hashFiles('.xlings.json') }} + key: xlings-${{ runner.os }}-release-xl0460-${{ hashFiles('.xlings.json') }} restore-keys: | - xlings-${{ runner.os }}-release-xl0448- + xlings-${{ runner.os }}-release-xl0460- - name: Bootstrap mcpp via xlings env: @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '0.4.58' + XLINGS_VERSION: '0.4.60' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -281,7 +281,7 @@ jobs: - name: Bootstrap mcpp via xlings (latest 0.4.58) env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.58' + XLINGS_VERSION: '0.4.60' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -410,14 +410,14 @@ jobs: uses: actions/cache@v4 with: path: ~/.xlings - key: xlings-macos15-release-xl0448-${{ hashFiles('.xlings.json') }} + key: xlings-macos15-release-xl0460-${{ hashFiles('.xlings.json') }} restore-keys: | - xlings-macos15-release-xl0448- + xlings-macos15-release-xl0460- - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.58' + XLINGS_VERSION: '0.4.60' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -591,15 +591,15 @@ jobs: uses: actions/cache@v4 with: path: ~\.xlings - key: xlings-${{ runner.os }}-release-xl0448-${{ hashFiles('.xlings.json') }} + key: xlings-${{ runner.os }}-release-xl0460-${{ hashFiles('.xlings.json') }} restore-keys: | - xlings-${{ runner.os }}-release-xl0448- + xlings-${{ runner.os }}-release-xl0460- - name: Bootstrap mcpp via xlings shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.58' + XLINGS_VERSION: '0.4.60' run: | WORK=$(mktemp -d) zipfile="xlings-${XLINGS_VERSION}-windows-x86_64.zip" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b647f3..0b5fcb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,35 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [0.0.65] — 2026-06-25 + +### 修复 + +- **`mcpp add gtest` + `mcpp build` 报 `duplicate symbol: main` / `LNK2005`**(#168): + gtest 作为**常规依赖**时,其 `gtest_main.cc`(自带 main)被链进应用,与应用自身的 + main 冲突。修复采用**通用的「feature 门控源」机制**:依赖描述符可声明 + `[mcpp].features.<名>.sources`,被某 feature 列出的源**默认不编译/链接**,仅在该 + feature 被请求(`dep = { version="…", features=["…"] }`)时纳入。gtest 描述符把 + `gtest_main.cc` 归入 `main` feature → **默认只链框架,不再撞 main**;需要 gtest 提供 + main 时 `gtest = { version="1.15.2", features=["main"] }` 显式开启。 + 门控仅作用于 `mcpp build`;`mcpp test` 保持既有的 dev 依赖 main 检测(0.0.64)不变。 + 详见 `.agents/docs/2026-06-25-gtest-main-feature-and-add-dev-design.md`。 + +### 新增 + +- **`mcpp add --dev `**:把依赖写入 `[dev-dependencies]`(测试专属,如 gtest; + 由 `mcpp test` 消费,不链进 `mcpp build` 的应用)。 + +### 测试 + +- 单元 `SynthesizeFromXpkgLua.FeatureGatedSources`(描述符 feature 门控源解析); + e2e `79_gtest_regular_dep_feature_main.sh`(#168 哨兵 + `features=["main"]` opt-in + + `add --dev`)。 + +### CI + +- release workflow 默认 xlings 版本 `0.4.58` → **`0.4.60`**(缓存键同步更新)。 + ## [0.0.64] — 2026-06-25 ### 修复 diff --git a/mcpp.toml b/mcpp.toml index d384544..0a9643b 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.64" +version = "0.0.65" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 6db7db6..6a1bf41 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -2113,13 +2113,44 @@ prepare_build(bool print_fingerprint, }; auto apply = [&](mcpp::modgraph::PackageRoot& pkg, const std::vector& requested) { - for (auto& f : activate(pkg.manifest, requested)) { + auto active = activate(pkg.manifest, requested); + for (auto& f : active) { auto def = "-DMCPP_FEATURE_" + sanitize(f); pkg.manifest.buildConfig.cflags.push_back(def); pkg.manifest.buildConfig.cxxflags.push_back(def); pkg.privateBuild.cflags.push_back(def); pkg.privateBuild.cxxflags.push_back(def); } + // Feature-gated sources (e.g. gtest's gtest_main.cc behind "main"): + // drop EVERY feature-listed glob from the default build, then re-add + // only the ones whose feature is active. Runs even when no feature is + // active, so a gated source is excluded by default. + // + // ONLY in build mode (!includeDevDeps). `mcpp test` (includeDevDeps) + // keeps the full surface so the dev-dependency track's per-test main + // detection (run_tests / make_plan) still sees gtest_main.cc and + // prunes it per test — the two tracks stay decoupled. Combined with + // the descriptor keeping gtest_main.cc in base `sources` too, this + // means test mode is unaffected. + auto& bc = pkg.manifest.buildConfig; + if (!includeDevDeps && !bc.featureSources.empty()) { + std::set gated; + for (auto& [f, globs] : bc.featureSources) + for (auto& g : globs) gated.insert(g); + auto drop = [&](std::vector& v) { + std::erase_if(v, [&](const std::string& s) { return gated.contains(s); }); + }; + drop(bc.sources); + drop(pkg.manifest.modules.sources); + std::set activeSet(active.begin(), active.end()); + for (auto& [f, globs] : bc.featureSources) { + if (!activeSet.contains(f)) continue; + for (auto& g : globs) { + bc.sources.push_back(g); + pkg.manifest.modules.sources.push_back(g); + } + } + } }; if (!packages.empty()) { std::vector rootReq; @@ -2168,8 +2199,9 @@ prepare_build(bool print_fingerprint, std::println(stderr, "warning: {}", msg); } } - if (!req.empty() || packages[i].manifest.featuresMap.contains("default")) - apply(packages[i], req); + // Always apply: even with no requested/default feature, a dep with + // feature-gated sources must have those sources dropped by default. + apply(packages[i], req); } } diff --git a/src/cli.cppm b/src/cli.cppm index e456278..2681e44 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -255,6 +255,8 @@ int run(int argc, char** argv) { .subcommand(cl::App("add") .description("Add a dependency to mcpp.toml") .arg(cl::Arg("pkg").help("Package spec, e.g. foo@1.0.0").required()) + .option(cl::Option("dev").help( + "Add to [dev-dependencies] (test-only, e.g. gtest)")) .action(wrap_rc(mcpp::pm::commands::cmd_add))) .subcommand(cl::App("remove") .description("Remove a dependency from mcpp.toml") diff --git a/src/manifest.cppm b/src/manifest.cppm index 010c393..eee266f 100644 --- a/src/manifest.cppm +++ b/src/manifest.cppm @@ -105,6 +105,13 @@ struct Toolchain { // (new). Defaults are injected by load() after parse if these are empty. struct BuildConfig { std::vector sources; // glob patterns + // feature name → extra source globs gated by that feature. A glob listed + // here is EXCLUDED from the default build and only compiled/linked when the + // feature is active for this package (resolved in prepare_build). Lets a + // dependency expose an optional component (e.g. gtest's gtest_main.cc behind + // the "main" feature) without it being linked by default — see + // .agents/docs/2026-06-25-gtest-main-feature-and-add-dev-design.md. + std::map> featureSources; std::vector includeDirs; // relative to package root std::map generatedFiles; // Form B package-owned support files bool staticStdlib = true; @@ -1951,6 +1958,55 @@ synthesize_from_xpkg_lua(std::string_view luaContent, } cur.consume('}'); } + else if (key == "features") { + // `{ ["main"] = { sources = { "*/gtest_main.cc" } }, ... }` + // Registers the feature (so it's a known feature) and, when it + // carries `sources`, records them as feature-gated source globs + // (excluded by default; included only when the feature is active — + // resolved in prepare_build). A feature with no `sources` is still + // registered (empty implied set) so it can be requested/validated. + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `features =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto fname = cur.read_key(); + if (fname.empty()) { cur.skip_ws_and_comments(); break; } + cur.skip_ws_and_comments(); + if (!cur.consume('=')) break; + cur.skip_ws_and_comments(); + if (!cur.consume('{')) break; + // register the feature (no implied features for now) + m.featuresMap.try_emplace(fname, std::vector{}); + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto sub = cur.read_key(); + cur.skip_ws_and_comments(); + if (!cur.consume('=')) break; + cur.skip_ws_and_comments(); + if (sub == "sources") { + if (!cur.consume('{')) break; + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto s = cur.read_string(); + if (!s.empty()) + m.buildConfig.featureSources[fname].push_back(std::move(s)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } else { + // unknown subfield — skip its value + if (cur.peek() == '{') cur.skip_table(); + else (void)cur.read_bareword(); + } + cur.skip_ws_and_comments(); + } + cur.consume('}'); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } else if (key == "deps") { // `{ ["name"] = "version", ["ns.name"] = "version", ... }` // The mcpp segment uses the flat / dotted form only — namespaced diff --git a/src/pm/commands.cppm b/src/pm/commands.cppm index 57ad8ef..b3e5af9 100644 --- a/src/pm/commands.cppm +++ b/src/pm/commands.cppm @@ -76,11 +76,15 @@ inline int cmd_add(const mcpplibs::cmdline::ParsedArgs& parsed) { // - Default namespace → `[dependencies] ... name = "version"` (no quotes). // - Other namespace → `[dependencies.] ... name = "version"`, // creating the subtable if absent. + // --dev → [dev-dependencies] (test-only deps like gtest; consumed by + // `mcpp test`, never linked into `mcpp build` app binaries). + const bool dev = parsed.is_flag_set("dev"); + const std::string table = dev ? "dev-dependencies" : "dependencies"; const bool isDefaultNs = !explicitNamespace || ns == mcpp::manifest::kDefaultNamespace; const std::string section = isDefaultNs - ? "[dependencies]" - : std::format("[dependencies.{}]", ns); + ? std::format("[{}]", table) + : std::format("[{}.{}]", table, ns); const std::string key = explicitNamespace ? shortName : nameSpec; auto pos = text.find(section); if (pos == std::string::npos) { @@ -99,7 +103,7 @@ inline int cmd_add(const mcpplibs::cmdline::ParsedArgs& parsed) { std::string display = explicitNamespace ? (isDefaultNs ? shortName : std::format("{}:{}", ns, shortName)) : nameSpec; - mcpp::ui::status("Adding", std::format("{} v{} to dependencies", display, version)); + mcpp::ui::status("Adding", std::format("{} v{} to {}", display, version, table)); std::println(""); std::println("Run `mcpp build` to fetch and build with the new dependency."); return 0; diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 8073d53..055931b 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "0.0.64"; +inline constexpr std::string_view MCPP_VERSION = "0.0.65"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/79_gtest_regular_dep_feature_main.sh b/tests/e2e/79_gtest_regular_dep_feature_main.sh new file mode 100755 index 0000000..2d30e40 --- /dev/null +++ b/tests/e2e/79_gtest_regular_dep_feature_main.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# requires: +# 79_gtest_regular_dep_feature_main.sh — gtest as a REGULAR dependency +# (`[dependencies]`, via `mcpp add gtest`) must NOT inject gtest_main into a +# `mcpp build` app that has its own main. Regression for issue #168 +# (`gtest_main.o : error LNK2005: main already defined in main.o`). +# +# gtest_main.cc is gated behind the `main` feature in compat.gtest.lua: off by +# default (framework only) → no main collision; `features = ["main"]` opts in. +# +# No `requires:` capability → runs on all three CI platforms (the original bug +# was Windows/MSVC LNK2005). Depends on the published mcpp-index carrying the +# gtest `main` feature. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +"$MCPP" new app > /dev/null +cd app + +# (1) #168: gtest in [dependencies] + app's own main → build must succeed, and +# gtest_main must NOT be linked. +"$MCPP" add gtest@1.15.2 > /dev/null +grep -q '^\[dependencies\]' mcpp.toml || { echo "FAIL: add did not write [dependencies]"; cat mcpp.toml; exit 1; } +"$MCPP" build > /dev/null || { echo "FAIL: #168 — build with regular-dep gtest failed"; exit 1; } +nj=$(find target -name build.ninja -printf "%T@ %p\n" | sort -rn | head -1 | cut -d" " -f2) +if grep -q 'gtest_main' "$nj"; then + echo "FAIL: gtest_main linked into app by default (would collide with main)"; exit 1 +fi +grep -q 'gtest-all' "$nj" || { echo "FAIL: gtest framework (gtest-all) not linked"; exit 1; } + +# (2) opt-in: features = ["main"] + a TEST-only file (no own main) → gtest_main +# IS linked and provides the entry. +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +gtest = { version = "1.15.2", features = ["main"] } +EOF +cat > src/main.cpp <<'EOF' +#include +TEST(App, ok) { EXPECT_EQ(1 + 1, 2); } +EOF +"$MCPP" build > /dev/null || { echo "FAIL: features=[main] build failed"; exit 1; } +nj=$(find target -name build.ninja -printf "%T@ %p\n" | sort -rn | head -1 | cut -d" " -f2) +grep -q 'gtest_main' "$nj" || { echo "FAIL: features=[main] did not link gtest_main"; exit 1; } + +# (3) `mcpp add --dev` routes to [dev-dependencies]. +cd "$TMP" +"$MCPP" new libapp > /dev/null +cd libapp +"$MCPP" add --dev gtest@1.15.2 > /dev/null +grep -q '^\[dev-dependencies\]' mcpp.toml || { echo "FAIL: add --dev did not write [dev-dependencies]"; cat mcpp.toml; exit 1; } + +echo "OK" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 1588137..0d45c7d 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -395,6 +395,35 @@ package = { EXPECT_EQ(m->targets[0].soname, "libtinyshared.so.1"); } +TEST(SynthesizeFromXpkgLua, FeatureGatedSources) { + // gtest-style: gtest_main.cc listed in base `sources` (old-mcpp compat) AND + // under the `main` feature → featureSources records it; the feature is + // registered in featuresMap. prepare_build later gates it (off by default). + constexpr auto src = R"( +package = { + spec = "1", + name = "gtestlike", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + sources = { "*/src/all.cc", "*/src/main.cc" }, + targets = { ["gtestlike"] = { kind = "lib" } }, + features = { + ["main"] = { sources = { "*/src/main.cc" } }, + }, + }, +} +)"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "gtestlike", "1.0.0"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + // base sources keep both (old mcpp ignores `features` → no regression) + ASSERT_EQ(m->buildConfig.sources.size(), 2u); + // the `main` feature is registered + carries its gated source + ASSERT_TRUE(m->featuresMap.contains("main")); + ASSERT_TRUE(m->buildConfig.featureSources.contains("main")); + ASSERT_EQ(m->buildConfig.featureSources.at("main").size(), 1u); + EXPECT_EQ(m->buildConfig.featureSources.at("main")[0], "*/src/main.cc"); +} + TEST(SynthesizeFromXpkgLua, RuntimeConfig) { constexpr auto src = R"( package = { From ce55d843ac8b1bff17c5304648b64e792278f291 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Thu, 25 Jun 2026 14:07:30 +0800 Subject: [PATCH 2/2] test(e2e): 79 use portable newest-build.ninja (ls -t), not GNU find -printf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS BSD find lacks -printf → nj came back empty → false 'gtest-all not linked'. Functionality was fine (78 passed, build succeeded). Use find | xargs ls -t. --- tests/e2e/79_gtest_regular_dep_feature_main.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/79_gtest_regular_dep_feature_main.sh b/tests/e2e/79_gtest_regular_dep_feature_main.sh index 2d30e40..f8c9c1e 100755 --- a/tests/e2e/79_gtest_regular_dep_feature_main.sh +++ b/tests/e2e/79_gtest_regular_dep_feature_main.sh @@ -25,7 +25,7 @@ cd app "$MCPP" add gtest@1.15.2 > /dev/null grep -q '^\[dependencies\]' mcpp.toml || { echo "FAIL: add did not write [dependencies]"; cat mcpp.toml; exit 1; } "$MCPP" build > /dev/null || { echo "FAIL: #168 — build with regular-dep gtest failed"; exit 1; } -nj=$(find target -name build.ninja -printf "%T@ %p\n" | sort -rn | head -1 | cut -d" " -f2) +nj=$(find target -name build.ninja | xargs ls -t 2>/dev/null | head -1) if grep -q 'gtest_main' "$nj"; then echo "FAIL: gtest_main linked into app by default (would collide with main)"; exit 1 fi @@ -46,7 +46,7 @@ cat > src/main.cpp <<'EOF' TEST(App, ok) { EXPECT_EQ(1 + 1, 2); } EOF "$MCPP" build > /dev/null || { echo "FAIL: features=[main] build failed"; exit 1; } -nj=$(find target -name build.ninja -printf "%T@ %p\n" | sort -rn | head -1 | cut -d" " -f2) +nj=$(find target -name build.ninja | xargs ls -t 2>/dev/null | head -1) grep -q 'gtest_main' "$nj" || { echo "FAIL: features=[main] did not link gtest_main"; exit 1; } # (3) `mcpp add --dev` routes to [dev-dependencies].