Skip to content

Commit 0f96b82

Browse files
authored
feat: feature-gated dep sources + mcpp add --devfix #168 gtest_main (v0.0.65) (#169)
* feat: feature-gated dependency sources + mcpp add --dev (fix #168 gtest_main) #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.<name>.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 <pkg> → 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 * test(e2e): 79 use portable newest-build.ninja (ls -t), not GNU find -printf 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.
1 parent 34486a6 commit 0f96b82

10 files changed

Lines changed: 229 additions & 18 deletions

File tree

.github/workflows/release.yml

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,17 @@ jobs:
8686
uses: actions/cache@v4
8787
with:
8888
path: ~/.xlings
89-
key: xlings-${{ runner.os }}-release-xl0448-${{ hashFiles('.xlings.json') }}
89+
key: xlings-${{ runner.os }}-release-xl0460-${{ hashFiles('.xlings.json') }}
9090
restore-keys: |
91-
xlings-${{ runner.os }}-release-xl0448-
91+
xlings-${{ runner.os }}-release-xl0460-
9292
9393
- name: Bootstrap mcpp via xlings
9494
env:
9595
XLINGS_NON_INTERACTIVE: '1'
9696
# Pin xlings to a known-good version. The upstream install
9797
# script always grabs `latest` (no version override), so we
9898
# download + self-install manually to avoid broken releases.
99-
XLINGS_VERSION: '0.4.58'
99+
XLINGS_VERSION: '0.4.60'
100100
run: |
101101
if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then
102102
tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz"
@@ -281,7 +281,7 @@ jobs:
281281
- name: Bootstrap mcpp via xlings (latest 0.4.58)
282282
env:
283283
XLINGS_NON_INTERACTIVE: '1'
284-
XLINGS_VERSION: '0.4.58'
284+
XLINGS_VERSION: '0.4.60'
285285
run: |
286286
tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz"
287287
curl -fsSL -o "/tmp/${tarball}" \
@@ -410,14 +410,14 @@ jobs:
410410
uses: actions/cache@v4
411411
with:
412412
path: ~/.xlings
413-
key: xlings-macos15-release-xl0448-${{ hashFiles('.xlings.json') }}
413+
key: xlings-macos15-release-xl0460-${{ hashFiles('.xlings.json') }}
414414
restore-keys: |
415-
xlings-macos15-release-xl0448-
415+
xlings-macos15-release-xl0460-
416416
417417
- name: Bootstrap mcpp via xlings
418418
env:
419419
XLINGS_NON_INTERACTIVE: '1'
420-
XLINGS_VERSION: '0.4.58'
420+
XLINGS_VERSION: '0.4.60'
421421
run: |
422422
if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then
423423
WORK=$(mktemp -d)
@@ -591,15 +591,15 @@ jobs:
591591
uses: actions/cache@v4
592592
with:
593593
path: ~\.xlings
594-
key: xlings-${{ runner.os }}-release-xl0448-${{ hashFiles('.xlings.json') }}
594+
key: xlings-${{ runner.os }}-release-xl0460-${{ hashFiles('.xlings.json') }}
595595
restore-keys: |
596-
xlings-${{ runner.os }}-release-xl0448-
596+
xlings-${{ runner.os }}-release-xl0460-
597597
598598
- name: Bootstrap mcpp via xlings
599599
shell: bash
600600
env:
601601
XLINGS_NON_INTERACTIVE: '1'
602-
XLINGS_VERSION: '0.4.58'
602+
XLINGS_VERSION: '0.4.60'
603603
run: |
604604
WORK=$(mktemp -d)
605605
zipfile="xlings-${XLINGS_VERSION}-windows-x86_64.zip"

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,35 @@
33
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
44
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
55
6+
## [0.0.65] — 2026-06-25
7+
8+
### 修复
9+
10+
- **`mcpp add gtest` + `mcpp build``duplicate symbol: main` / `LNK2005`**(#168):
11+
gtest 作为**常规依赖**时,其 `gtest_main.cc`(自带 main)被链进应用,与应用自身的
12+
main 冲突。修复采用**通用的「feature 门控源」机制**:依赖描述符可声明
13+
`[mcpp].features.<名>.sources`,被某 feature 列出的源**默认不编译/链接**,仅在该
14+
feature 被请求(`dep = { version="…", features=["…"] }`)时纳入。gtest 描述符把
15+
`gtest_main.cc` 归入 `main` feature → **默认只链框架,不再撞 main**;需要 gtest 提供
16+
main 时 `gtest = { version="1.15.2", features=["main"] }` 显式开启。
17+
门控仅作用于 `mcpp build`;`mcpp test` 保持既有的 dev 依赖 main 检测(0.0.64)不变。
18+
详见 `.agents/docs/2026-06-25-gtest-main-feature-and-add-dev-design.md`
19+
20+
### 新增
21+
22+
- **`mcpp add --dev <pkg>`**:把依赖写入 `[dev-dependencies]`(测试专属,如 gtest;
23+
`mcpp test` 消费,不链进 `mcpp build` 的应用)。
24+
25+
### 测试
26+
27+
- 单元 `SynthesizeFromXpkgLua.FeatureGatedSources`(描述符 feature 门控源解析);
28+
e2e `79_gtest_regular_dep_feature_main.sh`(#168 哨兵 + `features=["main"]` opt-in +
29+
`add --dev`)。
30+
31+
### CI
32+
33+
- release workflow 默认 xlings 版本 `0.4.58`**`0.4.60`**(缓存键同步更新)。
34+
635
## [0.0.64] — 2026-06-25
736

837
### 修复

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.64"
3+
version = "0.0.65"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

src/build/prepare.cppm

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2113,13 +2113,44 @@ prepare_build(bool print_fingerprint,
21132113
};
21142114
auto apply = [&](mcpp::modgraph::PackageRoot& pkg,
21152115
const std::vector<std::string>& requested) {
2116-
for (auto& f : activate(pkg.manifest, requested)) {
2116+
auto active = activate(pkg.manifest, requested);
2117+
for (auto& f : active) {
21172118
auto def = "-DMCPP_FEATURE_" + sanitize(f);
21182119
pkg.manifest.buildConfig.cflags.push_back(def);
21192120
pkg.manifest.buildConfig.cxxflags.push_back(def);
21202121
pkg.privateBuild.cflags.push_back(def);
21212122
pkg.privateBuild.cxxflags.push_back(def);
21222123
}
2124+
// Feature-gated sources (e.g. gtest's gtest_main.cc behind "main"):
2125+
// drop EVERY feature-listed glob from the default build, then re-add
2126+
// only the ones whose feature is active. Runs even when no feature is
2127+
// active, so a gated source is excluded by default.
2128+
//
2129+
// ONLY in build mode (!includeDevDeps). `mcpp test` (includeDevDeps)
2130+
// keeps the full surface so the dev-dependency track's per-test main
2131+
// detection (run_tests / make_plan) still sees gtest_main.cc and
2132+
// prunes it per test — the two tracks stay decoupled. Combined with
2133+
// the descriptor keeping gtest_main.cc in base `sources` too, this
2134+
// means test mode is unaffected.
2135+
auto& bc = pkg.manifest.buildConfig;
2136+
if (!includeDevDeps && !bc.featureSources.empty()) {
2137+
std::set<std::string> gated;
2138+
for (auto& [f, globs] : bc.featureSources)
2139+
for (auto& g : globs) gated.insert(g);
2140+
auto drop = [&](std::vector<std::string>& v) {
2141+
std::erase_if(v, [&](const std::string& s) { return gated.contains(s); });
2142+
};
2143+
drop(bc.sources);
2144+
drop(pkg.manifest.modules.sources);
2145+
std::set<std::string> activeSet(active.begin(), active.end());
2146+
for (auto& [f, globs] : bc.featureSources) {
2147+
if (!activeSet.contains(f)) continue;
2148+
for (auto& g : globs) {
2149+
bc.sources.push_back(g);
2150+
pkg.manifest.modules.sources.push_back(g);
2151+
}
2152+
}
2153+
}
21232154
};
21242155
if (!packages.empty()) {
21252156
std::vector<std::string> rootReq;
@@ -2168,8 +2199,9 @@ prepare_build(bool print_fingerprint,
21682199
std::println(stderr, "warning: {}", msg);
21692200
}
21702201
}
2171-
if (!req.empty() || packages[i].manifest.featuresMap.contains("default"))
2172-
apply(packages[i], req);
2202+
// Always apply: even with no requested/default feature, a dep with
2203+
// feature-gated sources must have those sources dropped by default.
2204+
apply(packages[i], req);
21732205
}
21742206
}
21752207

src/cli.cppm

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ int run(int argc, char** argv) {
255255
.subcommand(cl::App("add")
256256
.description("Add a dependency to mcpp.toml")
257257
.arg(cl::Arg("pkg").help("Package spec, e.g. foo@1.0.0").required())
258+
.option(cl::Option("dev").help(
259+
"Add to [dev-dependencies] (test-only, e.g. gtest)"))
258260
.action(wrap_rc(mcpp::pm::commands::cmd_add)))
259261
.subcommand(cl::App("remove")
260262
.description("Remove a dependency from mcpp.toml")

src/manifest.cppm

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ struct Toolchain {
105105
// (new). Defaults are injected by load() after parse if these are empty.
106106
struct BuildConfig {
107107
std::vector<std::string> sources; // glob patterns
108+
// feature name → extra source globs gated by that feature. A glob listed
109+
// here is EXCLUDED from the default build and only compiled/linked when the
110+
// feature is active for this package (resolved in prepare_build). Lets a
111+
// dependency expose an optional component (e.g. gtest's gtest_main.cc behind
112+
// the "main" feature) without it being linked by default — see
113+
// .agents/docs/2026-06-25-gtest-main-feature-and-add-dev-design.md.
114+
std::map<std::string, std::vector<std::string>> featureSources;
108115
std::vector<std::filesystem::path> includeDirs; // relative to package root
109116
std::map<std::filesystem::path, std::string> generatedFiles; // Form B package-owned support files
110117
bool staticStdlib = true;
@@ -1951,6 +1958,55 @@ synthesize_from_xpkg_lua(std::string_view luaContent,
19511958
}
19521959
cur.consume('}');
19531960
}
1961+
else if (key == "features") {
1962+
// `{ ["main"] = { sources = { "*/gtest_main.cc" } }, ... }`
1963+
// Registers the feature (so it's a known feature) and, when it
1964+
// carries `sources`, records them as feature-gated source globs
1965+
// (excluded by default; included only when the feature is active —
1966+
// resolved in prepare_build). A feature with no `sources` is still
1967+
// registered (empty implied set) so it can be requested/validated.
1968+
if (!cur.consume('{')) {
1969+
return std::unexpected(ManifestError{
1970+
"expected '{' after `features =`", m.sourcePath, 0, 0});
1971+
}
1972+
cur.skip_ws_and_comments();
1973+
while (!cur.eof() && cur.peek() != '}') {
1974+
auto fname = cur.read_key();
1975+
if (fname.empty()) { cur.skip_ws_and_comments(); break; }
1976+
cur.skip_ws_and_comments();
1977+
if (!cur.consume('=')) break;
1978+
cur.skip_ws_and_comments();
1979+
if (!cur.consume('{')) break;
1980+
// register the feature (no implied features for now)
1981+
m.featuresMap.try_emplace(fname, std::vector<std::string>{});
1982+
cur.skip_ws_and_comments();
1983+
while (!cur.eof() && cur.peek() != '}') {
1984+
auto sub = cur.read_key();
1985+
cur.skip_ws_and_comments();
1986+
if (!cur.consume('=')) break;
1987+
cur.skip_ws_and_comments();
1988+
if (sub == "sources") {
1989+
if (!cur.consume('{')) break;
1990+
cur.skip_ws_and_comments();
1991+
while (!cur.eof() && cur.peek() != '}') {
1992+
auto s = cur.read_string();
1993+
if (!s.empty())
1994+
m.buildConfig.featureSources[fname].push_back(std::move(s));
1995+
cur.skip_ws_and_comments();
1996+
}
1997+
cur.consume('}');
1998+
} else {
1999+
// unknown subfield — skip its value
2000+
if (cur.peek() == '{') cur.skip_table();
2001+
else (void)cur.read_bareword();
2002+
}
2003+
cur.skip_ws_and_comments();
2004+
}
2005+
cur.consume('}');
2006+
cur.skip_ws_and_comments();
2007+
}
2008+
cur.consume('}');
2009+
}
19542010
else if (key == "deps") {
19552011
// `{ ["name"] = "version", ["ns.name"] = "version", ... }`
19562012
// The mcpp segment uses the flat / dotted form only — namespaced

src/pm/commands.cppm

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,15 @@ inline int cmd_add(const mcpplibs::cmdline::ParsedArgs& parsed) {
7676
// - Default namespace → `[dependencies] ... name = "version"` (no quotes).
7777
// - Other namespace → `[dependencies.<ns>] ... name = "version"`,
7878
// creating the subtable if absent.
79+
// --dev → [dev-dependencies] (test-only deps like gtest; consumed by
80+
// `mcpp test`, never linked into `mcpp build` app binaries).
81+
const bool dev = parsed.is_flag_set("dev");
82+
const std::string table = dev ? "dev-dependencies" : "dependencies";
7983
const bool isDefaultNs = !explicitNamespace
8084
|| ns == mcpp::manifest::kDefaultNamespace;
8185
const std::string section = isDefaultNs
82-
? "[dependencies]"
83-
: std::format("[dependencies.{}]", ns);
86+
? std::format("[{}]", table)
87+
: std::format("[{}.{}]", table, ns);
8488
const std::string key = explicitNamespace ? shortName : nameSpec;
8589
auto pos = text.find(section);
8690
if (pos == std::string::npos) {
@@ -99,7 +103,7 @@ inline int cmd_add(const mcpplibs::cmdline::ParsedArgs& parsed) {
99103
std::string display = explicitNamespace
100104
? (isDefaultNs ? shortName : std::format("{}:{}", ns, shortName))
101105
: nameSpec;
102-
mcpp::ui::status("Adding", std::format("{} v{} to dependencies", display, version));
106+
mcpp::ui::status("Adding", std::format("{} v{} to {}", display, version, table));
103107
std::println("");
104108
std::println("Run `mcpp build` to fetch and build with the new dependency.");
105109
return 0;

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.64";
21+
inline constexpr std::string_view MCPP_VERSION = "0.0.65";
2222

2323
struct FingerprintInputs {
2424
Toolchain toolchain;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env bash
2+
# requires:
3+
# 79_gtest_regular_dep_feature_main.sh — gtest as a REGULAR dependency
4+
# (`[dependencies]`, via `mcpp add gtest`) must NOT inject gtest_main into a
5+
# `mcpp build` app that has its own main. Regression for issue #168
6+
# (`gtest_main.o : error LNK2005: main already defined in main.o`).
7+
#
8+
# gtest_main.cc is gated behind the `main` feature in compat.gtest.lua: off by
9+
# default (framework only) → no main collision; `features = ["main"]` opts in.
10+
#
11+
# No `requires:` capability → runs on all three CI platforms (the original bug
12+
# was Windows/MSVC LNK2005). Depends on the published mcpp-index carrying the
13+
# gtest `main` feature.
14+
set -e
15+
16+
TMP=$(mktemp -d)
17+
trap "rm -rf $TMP" EXIT
18+
19+
cd "$TMP"
20+
"$MCPP" new app > /dev/null
21+
cd app
22+
23+
# (1) #168: gtest in [dependencies] + app's own main → build must succeed, and
24+
# gtest_main must NOT be linked.
25+
"$MCPP" add gtest@1.15.2 > /dev/null
26+
grep -q '^\[dependencies\]' mcpp.toml || { echo "FAIL: add did not write [dependencies]"; cat mcpp.toml; exit 1; }
27+
"$MCPP" build > /dev/null || { echo "FAIL: #168 — build with regular-dep gtest failed"; exit 1; }
28+
nj=$(find target -name build.ninja | xargs ls -t 2>/dev/null | head -1)
29+
if grep -q 'gtest_main' "$nj"; then
30+
echo "FAIL: gtest_main linked into app by default (would collide with main)"; exit 1
31+
fi
32+
grep -q 'gtest-all' "$nj" || { echo "FAIL: gtest framework (gtest-all) not linked"; exit 1; }
33+
34+
# (2) opt-in: features = ["main"] + a TEST-only file (no own main) → gtest_main
35+
# IS linked and provides the entry.
36+
cat > mcpp.toml <<'EOF'
37+
[package]
38+
name = "app"
39+
version = "0.1.0"
40+
41+
[dependencies]
42+
gtest = { version = "1.15.2", features = ["main"] }
43+
EOF
44+
cat > src/main.cpp <<'EOF'
45+
#include <gtest/gtest.h>
46+
TEST(App, ok) { EXPECT_EQ(1 + 1, 2); }
47+
EOF
48+
"$MCPP" build > /dev/null || { echo "FAIL: features=[main] build failed"; exit 1; }
49+
nj=$(find target -name build.ninja | xargs ls -t 2>/dev/null | head -1)
50+
grep -q 'gtest_main' "$nj" || { echo "FAIL: features=[main] did not link gtest_main"; exit 1; }
51+
52+
# (3) `mcpp add --dev` routes to [dev-dependencies].
53+
cd "$TMP"
54+
"$MCPP" new libapp > /dev/null
55+
cd libapp
56+
"$MCPP" add --dev gtest@1.15.2 > /dev/null
57+
grep -q '^\[dev-dependencies\]' mcpp.toml || { echo "FAIL: add --dev did not write [dev-dependencies]"; cat mcpp.toml; exit 1; }
58+
59+
echo "OK"

tests/unit/test_manifest.cpp

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,35 @@ package = {
395395
EXPECT_EQ(m->targets[0].soname, "libtinyshared.so.1");
396396
}
397397

398+
TEST(SynthesizeFromXpkgLua, FeatureGatedSources) {
399+
// gtest-style: gtest_main.cc listed in base `sources` (old-mcpp compat) AND
400+
// under the `main` feature → featureSources records it; the feature is
401+
// registered in featuresMap. prepare_build later gates it (off by default).
402+
constexpr auto src = R"(
403+
package = {
404+
spec = "1",
405+
name = "gtestlike",
406+
xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } },
407+
mcpp = {
408+
sources = { "*/src/all.cc", "*/src/main.cc" },
409+
targets = { ["gtestlike"] = { kind = "lib" } },
410+
features = {
411+
["main"] = { sources = { "*/src/main.cc" } },
412+
},
413+
},
414+
}
415+
)";
416+
auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "gtestlike", "1.0.0");
417+
ASSERT_TRUE(m.has_value()) << m.error().format();
418+
// base sources keep both (old mcpp ignores `features` → no regression)
419+
ASSERT_EQ(m->buildConfig.sources.size(), 2u);
420+
// the `main` feature is registered + carries its gated source
421+
ASSERT_TRUE(m->featuresMap.contains("main"));
422+
ASSERT_TRUE(m->buildConfig.featureSources.contains("main"));
423+
ASSERT_EQ(m->buildConfig.featureSources.at("main").size(), 1u);
424+
EXPECT_EQ(m->buildConfig.featureSources.at("main")[0], "*/src/main.cc");
425+
}
426+
398427
TEST(SynthesizeFromXpkgLua, RuntimeConfig) {
399428
constexpr auto src = R"(
400429
package = {

0 commit comments

Comments
 (0)