From 6594e83904b5ec86ddac3920b156e400cf95bc81 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:42:16 +0800 Subject: [PATCH] fix(deps): staging a cross-major copy drops the headers beside its sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two majors of one package are in a graph the resolver stages one copy under `target/.mangled/` with its module declarations rewritten. Staging moves the sources and, by design, leaves headers where they are: those are reached through `[build].include_dirs`, which is absolutized so the staged copy still finds them. A header written BESIDE a source is a different case and was not handled. `#include "detail.h"` resolves relative to the directory of the file holding the directive, so moving the source moves the search — and no `include_dirs` entry is involved, because a package with its header beside its source never declared a path it never needed. target/.mangled/openkal-opensbi/__self__/src/time.cpp:44:10: fatal error: 'sbi.h' file not found ⚠️ Everything that diagnosis points at is wrong. The path is a staging directory the author never wrote, the header sits exactly where the source expects it, and the build that triggered it asked for nothing unusual: two majors of one dependency is a supported arrangement and this is its most ordinary consequence. It cost two repositories a red CI run each, diagnosed first as a missing include path. Every file in a directory that holds a staged source and is not itself staged is now copied across verbatim — rewriting applies to module declarations and a header has none. Directories with no staged source are not visited. Proved by two binaries rather than by one binary and an assumption: mcpp-unfixed 33_multi_version_mangling.sh → fatal error: libB_detail.h mcpp-fixed 33_multi_version_mangling.sh → ok, staged beside the source The fixture gains a private header and a non-module translation unit that includes it, and asserts the staged copy carries it on disk as well as building — a build that found the header by some other path would otherwise pass while leaving this broken. Green beside it: 32, 119, 167, 188. --- CHANGELOG.md | 24 +++++++++++++ src/build/prepare.cppm | 50 ++++++++++++++++++++++++-- tests/e2e/33_multi_version_mangling.sh | 41 ++++++++++++++++++++- 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad23ec93..626d5fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [未发布] + +### 修复 + +- **暂存跨主版本副本时,与源码相邻的私有头没有跟着走。** + + 当同一个包的两个主版本同时出现在一张图里,解析器会把其中一份改名暂存到 + `target/.mangled/` 下。暂存只搬源码 —— 这对经 `[build].include_dirs` + 找到的头是对的,它们靠绝对化后的路径仍指回原处;但对**写在源码旁边**的私有头 + 不对:`#include "detail.h"` 是相对**包含它的那个文件所在目录**解析的,搬走源码 + 就搬走了搜索起点。包从来没有声明过这条路径,因为它从来不需要。 + + ``` + target/.mangled/openkal-opensbi/__self__/src/time.cpp:44:10: + fatal error: 'sbi.h' file not found + ``` + + ⚠️ 这条诊断指向的东西全是错的:路径是作者没写过的暂存目录,头文件就躺在源码 + 期待的位置,而触发它的构建没有要求任何不寻常的事情 —— 一张图里有两个主版本是 + 受支持的安排,这是它最普通的后果。 + + 现在,凡是包含了被暂存源码的目录,其中未被暂存的文件一并原样带过去。没有被 + 暂存源码的目录不会被访问,所以代价与暂存量成正比。 + ## [2026.8.24.5] — 2026-08-25 ### 改进 diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index cc48dcbc..bac66cd9 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -3912,9 +3912,30 @@ prepare_build(bool print_fingerprint, // version mangling fallback (Level 1) so two cross-major copies of // the same package can coexist with distinct module names. // - // Headers (referenced via `[build].include_dirs`) are NOT staged — - // those keep pointing at the original install dir via absolutized - // include paths. + // Headers reached through `[build].include_dirs` are NOT staged — those + // keep pointing at the original install dir via absolutized include paths. + // + // ⭐ HEADERS BESIDE A SOURCE ARE A DIFFERENT CASE, AND THEY ARE STAGED. + // + // `#include "detail.h"` is resolved relative to the directory of the file + // holding the directive, so moving the source moves the search. No + // `include_dirs` entry is involved and absolutizing one cannot help: the + // package never declared a path because it never needed one. Measured + // before this, on a package whose `src/time.cpp` includes `src/sbi.h`: + // + // target/.mangled/openkal-opensbi/__self__/src/time.cpp:44:10: + // fatal error: 'sbi.h' file not found + // + // ⚠️ THE DIAGNOSIS THIS PRODUCES POINTS AT THE WRONG THING. The path in it + // is a staging directory the author never wrote, for a header sitting + // exactly where the source expects it, and the build that triggered it + // asked for nothing unusual — two majors of one dependency is a supported + // arrangement, and this is its most ordinary consequence. + // + // What is copied is every file in a directory that contains a staged + // source and is not itself staged, verbatim: rewriting applies to module + // declarations, and a header has none. Directories with no staged source + // are not visited, so this stays proportional to what is being staged. auto stage_with_rewrite = [&](const std::filesystem::path& srcRoot, const std::filesystem::path& dstRoot, const mcpp::manifest::Manifest& depManifest, @@ -3948,6 +3969,29 @@ prepare_build(bool print_fingerprint, "stage: cannot write '{}'", dst.string())); os << out; } + + // The files beside those sources, carried across unchanged so a quoted + // include still finds what it named. + std::set sourceDirs; + for (auto const& f : *sources) sourceDirs.insert(f.parent_path()); + for (auto const& dir : sourceDirs) { + for (auto const& entry : std::filesystem::directory_iterator(dir, ec)) { + if (ec) break; + if (!entry.is_regular_file()) continue; + if (sources->contains(entry.path())) continue; + auto rel = std::filesystem::relative(entry.path(), srcRoot, ec); + if (ec) continue; + auto dst = dstRoot / rel; + std::filesystem::create_directories(dst.parent_path(), ec); + std::filesystem::copy_file( + entry.path(), dst, + std::filesystem::copy_options::overwrite_existing, ec); + if (ec) return std::unexpected(std::format( + "stage: cannot copy '{}': {}", + entry.path().string(), ec.message())); + } + ec.clear(); + } return {}; }; diff --git a/tests/e2e/33_multi_version_mangling.sh b/tests/e2e/33_multi_version_mangling.sh index 56537a16..f84495cb 100755 --- a/tests/e2e/33_multi_version_mangling.sh +++ b/tests/e2e/33_multi_version_mangling.sh @@ -62,10 +62,39 @@ mkdir -p "$TMP/libB" && cd "$TMP/libB" cd libB rm -f src/main.cpp cat > src/libB.cppm <<'EOF' +module; +// Declared in the global module fragment, so it has the same linkage as its +// definition in libB_impl.cpp — a non-module translation unit. Declaring it +// after `export module` would attach it to libB and leave the definition +// unattached, which is a mistake in a fixture rather than in what it tests. +int libB_impl_v(); export module libB; import mcpplibs.cmdline; // resolver rewrites this to the mangled secondary import std; -export int libB_v() { return 2; } +export int libB_v() { return libB_impl_v(); } +EOF +# ⭐ A PRIVATE HEADER BESIDE A SOURCE, WHICH IS WHAT STAGING USED TO DROP. +# +# libB is the copy the resolver stages under `target/.mangled/`, and a quoted +# include is resolved relative to the directory of the file holding it — so +# moving the source moves the search. No `include_dirs` entry is involved and +# absolutizing one cannot help: a package with its header beside its source +# never declared a path because it never needed one. +# +# Measured before the fix, on this fixture and on a real package alike: +# +# .mangled/libB/__self__/src/libB_impl.cpp:1:10: +# fatal error: libB_detail.h: No such file or directory +# +# The failure names a staging directory the author never wrote, for a header +# sitting exactly where the source expects it. +cat > src/libB_detail.h <<'EOF' +#pragma once +inline int libB_detail_v() { return 2; } +EOF +cat > src/libB_impl.cpp <<'EOF' +#include "libB_detail.h" +int libB_impl_v() { return libB_detail_v(); } EOF cat > mcpp.toml <<'EOF' [package] @@ -122,6 +151,16 @@ find target -name 'mcpplibs.cmdline.gcm' | grep -q . || { find target -name '*.gcm' | head -10 exit 1; } +# The staged copy carries the header beside the staged source. Asserted on +# disk as well as through the build, because a build that happened to find the +# header through some other path would pass while leaving this broken. +staged="$(find target/.mangled -name 'libB_detail.h' | head -1)" +[ -n "$staged" ] || { + echo "the header beside the staged source was not staged with it" + find target/.mangled -type f | head -20 + exit 1; } +echo " ok the header beside the staged source came with it ($staged)" + out="$("$MCPP" run 2>&1 | tail -1)" [[ "$out" == "a=1 b=2" ]] || { echo "unexpected output: $out"; exit 1; }