From cc5234c5b7cb711dea53a15faa085dfcd3ebf168 Mon Sep 17 00:00:00 2001 From: julixian Date: Mon, 5 Oct 2026 03:39:14 +0800 Subject: [PATCH 1/3] fix: keep workspace executables independent of sibling shared targets Retain a member's own module and implementation objects when it also produces a shared target. Keep declared external shared dependencies and test independent executable linkage with the sibling library removed. --- src/build/plan.cppm | 16 ++- ...executable_keeps_its_own_implementation.sh | 113 ++++++++++++++++++ 2 files changed, 123 insertions(+), 6 deletions(-) create mode 100755 tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 1e068831..ba909683 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -3087,9 +3087,12 @@ make_plan(const mcpp::manifest::Manifest& manifest, "'{}', which target '{}' of this build also produces", t.name, owner, lu.output.generic_string(), other.targetName)); + // A sibling shared target is another image of this member's + // objects, not a dependency of its executable. Only another + // package's shared target supplies its implementation by link. for (auto const& cu : plan.compileUnits) { if (!closure.contains(cu.packageName)) continue; - if (sharedDepPackages.contains(cu.packageName)) continue; + if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue; if (mcpp::links_unconditionally(cu.kind)) lu.objects.push_back(cu.object); } if (!t.main.empty() && lu.kind != LinkUnit::StaticLibrary) { @@ -3147,7 +3150,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, const bool entryDefinesMain = lu.entryMain && source_defines_main(*lu.entryMain); for (auto const& cu : plan.compileUnits) { if (!closure.contains(cu.packageName)) continue; - if (sharedDepPackages.contains(cu.packageName)) continue; + if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue; if (!is_implementation_source(cu.kind)) continue; if (lu.entryMain && cu.source == *lu.entryMain) continue; if (entryFilesAcrossTargets.contains(cu.source)) continue; @@ -3159,11 +3162,12 @@ make_plan(const mcpp::manifest::Manifest& manifest, } if (lu.kind != LinkUnit::StaticLibrary) { const auto before = lu.implicitInputs.size(); - // The shared libraries the closure's packages link, and no - // other member's. + // The member still links its declared shared dependencies + // even when it also produces a shared target of its own. for (auto i : closureIdx) - if (!sharedDepPackages.contains(qualified_package_name(packages[i].manifest)) - && !placedInImage.contains(qualified_package_name(packages[i].manifest))) + if (i == mi + || (!sharedDepPackages.contains(qualified_package_name(packages[i].manifest)) + && !placedInImage.contains(qualified_package_name(packages[i].manifest)))) append_direct_shared_deps(lu, i); // The graph-built shared libraries this unit loads are placed // beside it. diff --git a/tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh b/tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh new file mode 100755 index 00000000..f3dba1ef --- /dev/null +++ b/tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# requires: +# 880 -- mcpp#760: a member with shared and bin targets builds an independent +# executable from its own implementation, not from its sibling shared library. +# +# A module interface, a separate implementation unit and a static dependency +# must all reach the executable. Another member still consumes the shared +# target. Both --workspace and an explicit member selection keep this rule. +set -e +source "$(dirname "$0")/_host_path.sh" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +# Share the installed tool payloads, but keep the build and package caches +# private. The fixture has no registry package dependencies; the default host +# toolchain is resolved through the existing registry, as in the E2E runner. +REGISTRY_HOST=$(host_path "${MCPP_HOME:-$HOME/.mcpp}/registry") +export MCPP_HOME="$TMP/mcpp-home" +mkdir -p "$MCPP_HOME" "$TMP/ws" +cat > "$MCPP_HOME/config.toml" < mcpp.toml <<'EOF' +[workspace] +members = ["dual", "client", "support"] +EOF +mkdir -p common/src dual/src client/src support/src +cat > support/mcpp.toml <<'EOF' +[package] +name = "support" +version = "0.1.0" + +[targets.support] +kind = "shared" +EOF +printf 'export module t880_support;\nexport int delta() { return 1; }\n' > support/src/support.cppm +cat > common/mcpp.toml <<'EOF' +[package] +name = "common" +version = "0.1.0" + +[targets.common] +kind = "lib" +EOF +printf 'export module t880_base;\nexport int base_value() { return 41; }\n' > common/src/base.cppm +cat > dual/mcpp.toml <<'EOF' +[package] +name = "dual" +version = "0.1.0" + +[dependencies] +common = { path = "../common" } +support = { path = "../support" } + +[targets.dual_dll] +kind = "shared" + +[targets.dual] +kind = "bin" +main = "src/main.cpp" +EOF +printf 'export module t880_dual;\nexport int interface_value() { return 1; }\nexport int answer();\n' > dual/src/dual.cppm +printf 'module t880_dual;\nimport t880_base;\nimport t880_support;\nint answer() { return base_value() + delta(); }\n' > dual/src/impl.cpp +printf 'import t880_dual;\nint main() { return answer() == 42 && interface_value() == 1 ? 0 : 1; }\n' > dual/src/main.cpp +cat > client/mcpp.toml <<'EOF' +[package] +name = "client" +version = "0.1.0" + +[dependencies] +dual = { path = "../dual" } + +[targets.client] +kind = "bin" +main = "src/main.cpp" +EOF +cp dual/src/main.cpp client/src/main.cpp + +"$MCPP" build --workspace > workspace.log 2>&1 || fail "the workspace did not build" workspace.log +dual=$(bin_of dual) +client=$(bin_of client) +[ -n "$dual" ] && [ -n "$client" ] || fail "a member's executable is missing" workspace.log +"$dual" || fail "the owner's executable did not run" +"$client" || fail "the shared-library consumer did not run" + +# Remove every placement of the sibling library. A client that imports it must +# fail, while the owner's executable must still run: mere success with both +# products beside it would also accept the import-library workaround for #760. +mkdir hidden +n=0 +while IFS= read -r library; do + n=$((n + 1)) + mv "$library" "hidden/$n" +done < <(find target -path '*/bin/*' \( -name '*dual_dll.dll' -o -name '*dual_dll.so*' -o -name '*dual_dll.dylib' \)) +[ "$n" -gt 0 ] || fail "the sibling shared library was not built" workspace.log +"$dual" || fail "the owner's executable depends on its sibling shared library" +if "$client" > client.log 2>&1; then + fail "the external consumer did not use the shared library" client.log +fi + +"$MCPP" build -p dual > selected.log 2>&1 || fail "the selected member did not build" selected.log +"$(bin_of dual)" || fail "the selected member's executable did not run" +echo "PASS: 880_a_members_executable_keeps_its_own_implementation" From 569ba835a1913c00269da6e8314c1e22a583edd6 Mon Sep 17 00:00:00 2001 From: julixian Date: Mon, 5 Oct 2026 04:51:35 +0800 Subject: [PATCH 2/3] fix: preserve independent executables requested as artifacts or tools --- src/build/plan.cppm | 18 ++- src/build/prepare/features.cpp | 1 + src/modgraph/scanner.cppm | 3 + ...executable_keeps_its_own_implementation.sh | 105 +++++++++++++++++- 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/src/build/plan.cppm b/src/build/plan.cppm index ba909683..329c912f 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -2247,6 +2247,9 @@ make_plan(const mcpp::manifest::Manifest& manifest, for (std::size_t i = 1; i < packages.size(); ++i) { auto const& p = packages[i]; auto qname = qualified_package_name(p.manifest); + // Host-only packages are built by their own sub-build. Clearing their + // source globs does not remove the declared shared targets. + if (p.buildTimeOnly) continue; // A DISTRIBUTION PACKAGE's shared target is already built — that is what // the package IS. Creating a link unit for it made ninja fail outright // with `multiple rules generate bin/libmathkit.dll`: this loop declared @@ -2870,9 +2873,11 @@ make_plan(const mcpp::manifest::Manifest& manifest, } } } + // An artifact is an independent program of its package, even when + // that package also provides a shared image to other consumers. for (auto const& cu : plan.compileUnits) { if (!closure.contains(cu.packageName)) continue; - if (sharedDepPackages.contains(cu.packageName)) continue; + if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue; if (mcpp::links_unconditionally(cu.kind)) lu.objects.push_back(cu.object); } if (!r.target.main.empty()) { @@ -2920,13 +2925,20 @@ make_plan(const mcpp::manifest::Manifest& manifest, } for (auto const& cu : plan.compileUnits) { if (!closure.contains(cu.packageName)) continue; - if (sharedDepPackages.contains(cu.packageName)) continue; + if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue; if (!is_implementation_source(cu.kind)) continue; if (lu.entryMain && cu.source == *lu.entryMain) continue; if (entryFilesAcrossTargets.contains(cu.source)) continue; lu.objects.push_back(cu.object); } - append_shared_deps_for_linked_objects(lu); + // Only this program's closure contributes shared links. Walking every + // compile unit also picked up consumers of its sibling shared image + // and made the artifact depend on that image instead of its own code. + for (auto i : seen) + if (i == r.packageIndex + || (!sharedDepPackages.contains(qualified_package_name(packages[i].manifest)) + && !placedInImage.contains(qualified_package_name(packages[i].manifest)))) + append_direct_shared_deps(lu, i); // In a workspace plan the plan's own line pools the dependencies' // flags and not a member's, so the program links with its closure's // line, as a member's program does, in a group that places nothing. diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index 74b147d3..36821e99 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -1319,6 +1319,7 @@ step6_host_module_registration(PrepareState& state) { // its sources would turn that gap into an undefined // reference a long way from here. if (viaTarget[d] || !viaAny[d]) continue; + state.packages[d].buildTimeOnly = true; auto& dm = state.packages[d].manifest; dm.buildConfig.sources.clear(); dm.buildConfig.featureSources.clear(); diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 275f6863..5cad6fe9 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -148,6 +148,9 @@ struct PackageRoot { UsageRequirements publicUsage; UsageRequirements linkUsage; bool usageResolved = false; + // Reached only through build-time edges: resolved for host tools/rules, + // but neither its sources nor its products belong to the target plan. + bool buildTimeOnly = false; // A selected workspace member that the plan builds (workspace design // 2026-09-29 §15), and the directory below `bin/` its products are placed // in (empty: `bin/` itself). False on the plan's root and on every diff --git a/tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh b/tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh index f3dba1ef..7e79fb25 100755 --- a/tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh +++ b/tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh @@ -5,7 +5,7 @@ # # A module interface, a separate implementation unit and a static dependency # must all reach the executable. Another member still consumes the shared -# target. Both --workspace and an explicit member selection keep this rule. +# target. Workspace selection, shipped artifacts and host tools keep this rule. set -e source "$(dirname "$0")/_host_path.sh" @@ -110,4 +110,107 @@ fi "$MCPP" build -p dual > selected.log 2>&1 || fail "the selected member did not build" selected.log "$(bin_of dual)" || fail "the selected member's executable did not run" + +# Request the same executable as an artifact, rather than selecting its owner. +# The feature gate must be activated by the dependency that requests it. +cat >> dual/mcpp.toml <<'EOF' +required_features = ["exe"] + +[features] +exe = [] +EOF +mkdir -p shipped/src +cat > shipped/mcpp.toml <<'EOF' +[package] +name = "shipped" +version = "0.1.0" + +[dependencies] +dual = { path = "../dual", artifacts = ["dual"], features = ["exe"] } +support = { path = "../support" } + +[targets.shipped] +kind = "bin" +main = "src/main.cpp" +EOF +printf 'import t880_support;\nint main() { return delta() == 1 ? 0 : 1; }\n' > shipped/src/main.cpp +cat > mcpp.toml <<'EOF' +[workspace] +members = ["dual", "client", "support", "shipped"] +EOF +"$MCPP" build -p shipped > artifact.log 2>&1 || fail "the requested artifact did not build" artifact.log +artifact=$(find target -path "*/bin/shipped/dual$EXE" -type f | head -1) +[ -n "$artifact" ] || fail "the requested artifact was not placed beside its consumer" artifact.log +"$artifact" || fail "the requested artifact did not run" +"$(bin_of shipped)" || fail "the artifact's consumer did not run" +"$MCPP" build --workspace > artifact-workspace.log 2>&1 || fail "the workspace with an artifact request did not build" artifact-workspace.log +"$artifact" || fail "the workspace's requested artifact did not run" +"$dual" || fail "the workspace's owner executable did not run" +n=0 +while IFS= read -r library; do + n=$((n + 1)) + mv "$library" "hidden/artifact-$n" +done < <(find target -path '*/bin/*' \( -name '*dual_dll.dll' -o -name '*dual_dll.so*' -o -name '*dual_dll.dylib' \)) +"$artifact" || fail "the requested artifact depends on its sibling shared library" + +# A host tool is rooted at its own package and published separately. Use a +# static dependency here: external shared-tool runtime deployment is a separate +# contract, while this test checks independence from the tool's own sibling. +mkdir -p "$TMP/tooldual/src" "$TMP/toolapp/src" +cp dual/src/dual.cppm "$TMP/tooldual/src/dual.cppm" +cp dual/src/main.cpp "$TMP/tooldual/src/main.cpp" +printf 'module t880_dual;\nimport t880_base;\nint answer() { return base_value() + 1; }\n' > "$TMP/tooldual/src/impl.cpp" +cat > "$TMP/tooldual/mcpp.toml" <<'EOF' +[package] +name = "tooldual" +version = "0.1.0" + +[dependencies] +common = { path = "../ws/common" } + +[features] +exe = [] + +[targets.dual_dll] +kind = "shared" + +[targets.dual] +kind = "bin" +main = "src/main.cpp" +required_features = ["exe"] +EOF +cat > "$TMP/toolapp/mcpp.toml" <<'EOF' +[package] +name = "toolapp" +version = "0.1.0" + +[build-dependencies] +tooldual = { path = "../tooldual", tools = ["dual"], features = ["exe"] } +EOF +printf 'int main() { return 0; }\n' > "$TMP/toolapp/src/main.cpp" +cat > "$TMP/toolapp/build.mcpp" <<'EOF' +#include +#include +import mcpp; +int main() { + const char* tool = mcpp::dep_bin("tooldual", "dual"); + if (!tool || !*tool) return 1; + return std::system((std::string("\"") + tool + "\"").c_str()) == 0 ? 0 : 1; +} +EOF +cd "$TMP/toolapp" +"$MCPP" build > tool.log 2>&1 || fail "the host tool did not build and run" tool.log +"$(bin_of toolapp)" || fail "the host tool's consumer did not run" +# A target-side edge must retain the same provider's shared product even when +# it is also requested as a build-time tool. +cat >> mcpp.toml <<'EOF' + +[dependencies] +tooldual = { path = "../tooldual" } +EOF +cp "$TMP/tooldual/src/main.cpp" src/main.cpp +"$MCPP" build > dual-role.log 2>&1 || fail "the dual-role provider did not build" dual-role.log +"$(bin_of toolapp)" || fail "the dual-role provider's consumer did not run" +[ -n "$(find target -path '*/bin/*' \( -name '*dual_dll.dll' -o -name '*dual_dll.so*' -o -name '*dual_dll.dylib' \) -type f | head -1)" ] || fail "the dual-role provider's shared library is missing" dual-role.log + echo "PASS: 880_a_members_executable_keeps_its_own_implementation" From 19637260bcf6ae0c74711a9559d9ffd075cf80a7 Mon Sep 17 00:00:00 2001 From: julixian Date: Mon, 5 Oct 2026 06:08:21 +0800 Subject: [PATCH 3/3] fix: normalize native Windows paths in CI fixtures --- README.zh-CN.md | 2 +- tests/e2e/00_fixture_path_hygiene.sh | 14 ++++++++++++++ tests/e2e/_host_path.sh | 3 ++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index 5c0fdb1f..e44c3ae7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,11 +8,11 @@ [![C++23](https://img.shields.io/badge/C%2B%2B-23-blue.svg)](https://en.cppreference.com/w/cpp/23) [![Module](https://img.shields.io/badge/module-ok-green.svg)](https://en.cppreference.com/w/cpp/language/modules) [![License](https://img.shields.io/badge/license-Apache_2.0-blue.svg)](LICENSE) +[![ci](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml) | [文档](docs/zh/) · [快速开始](docs/zh/01-getting-started.md) · [mcpp.toml 指南](docs/zh/04-mcpp-toml.md) · [示例项目](docs/zh/03-examples.md) · [工具链管理](docs/zh/20-toolchains.md) | |:---:| | [包索引 mcpp-index](https://mcpplibs.github.io/mcpp-index/) · [模块化库 mcpplibs](https://github.com/mcpplibs) · [社区论坛](https://forum.d2learn.org/category/20) · [Issues](https://github.com/mcpp-community/mcpp/issues) · [Releases](https://github.com/mcpp-community/mcpp/releases) | -| [![ci](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml) | | 支持的插件 · [mcpp-language-server(mcppls)](https://github.com/Sunrisepeak/mcpp-language-server) —— C++20/23 模块语言服务器,面向 VS Code、Zed、CLion、Neovim、AI Agent(MCP)与 CI |

diff --git a/tests/e2e/00_fixture_path_hygiene.sh b/tests/e2e/00_fixture_path_hygiene.sh index 1795f2f5..d1351b08 100755 --- a/tests/e2e/00_fixture_path_hygiene.sh +++ b/tests/e2e/00_fixture_path_hygiene.sh @@ -25,6 +25,20 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_host_path.sh" + +# CI passes MCPP_HOME in native Windows spelling, not just Git Bash spelling. +# Both must produce paths safe to interpolate into TOML basic strings. +case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + [[ "$(host_path 'C:\Users\runner admin\.mcpp/registry')" == 'C:/Users/runner admin/.mcpp/registry' ]] || { + echo "FAIL: host_path did not normalize a native Windows absolute path" + exit 1 + } + [[ "$(host_path 'C:/Users/runner admin/.mcpp/registry')" == 'C:/Users/runner admin/.mcpp/registry' ]] || exit 1 + ;; +esac +[[ "$(host_path '../relative directory')" == '../relative directory' ]] || exit 1 # A TOML `path` key: either at the start of a line, or inside an inline table # (`{ path = "..." }`). Anchoring this way is what keeps shell assignments such diff --git a/tests/e2e/_host_path.sh b/tests/e2e/_host_path.sh index 165cb865..aff28632 100644 --- a/tests/e2e/_host_path.sh +++ b/tests/e2e/_host_path.sh @@ -36,8 +36,9 @@ host_path() { # agree and Windows accepts `/` as a separator. Passing one to `cygpath -m` # would silently ANCHOR it at the current directory — which is not where the # manifest being written lives — so relative input is returned untouched. + # MCPP_HOME can arrive from CI as C:\Users\...; it is absolute too. case "$1" in - /*) ;; + /* | [A-Za-z]:[\\/]*) ;; *) printf '%s' "$1"; return 0 ;; esac case "$(uname -s)" in