Skip to content

Commit 0cca9e0

Browse files
committed
fix(features): converge feature request derivation onto the dependency edge graph (#242 transitive; #241 naming)
Post-#242 architecture review found the feature subsystem derived a package's requested-feature set in two places that disagreed on transitive edges: resolution (mergeActiveFeatureDeps) read the true per-edge spec, but activation (apply) + the dep build.mcpp env re-derived it by scanning ONLY the root manifest's direct deps. So a transitive dep's requested features and its consumer's `default-features = false` were silently dropped — activation kept seeding a default feature (defining its macro / keeping default-gated sources) that resolution had already skipped, which at best ignores the opt-out and at worst compiles default-gated sources against a dep that was never resolved. - DependencyEdge now carries the per-edge requestedFeatures + defaultFeatures (recorded in recordDependencyEdge from the spec). - New aggregatedRequest(depPkgIndex): union requested features and OR default-features over ALL incoming edges (Cargo diamond semantics), sourced from the authoritative edge graph. Both feature activation and the dep build.mcpp env consume it. Direct-dep behavior is unchanged (root edge carries root spec); transitive edges are now honored, closing the pre-existing "transitive dep->dep feature requests not propagated" gap too. - #241 (review finding): emit MCPP_DEP_<NAME>_DIR under BOTH the dep's canonical name AND its short (namespace-stripped) name, so dep_dir("compat.zlib") and dep_dir("zlib") both resolve (code/doc no longer contradict); + a collision guard in contract_env (two names sanitizing to one var warns instead of silent last-wins). e2e 127 (transitive opt-out honored + control). All feature e2e (67/71/72/79/ 80/81/82/83/100/106/125/126) + unit 35/35 stay green.
1 parent 6918f16 commit 0cca9e0

3 files changed

Lines changed: 193 additions & 34 deletions

File tree

src/build/build_program.cppm

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ struct BuildProgramEnv {
4141
// root-project contract, unchanged). Dependencies point this at OUT_DIR so
4242
// a shared package root is never written to.
4343
std::filesystem::path genBase;
44-
// mcpp#241: this package's resolved dependencies, as (declared-name → dir).
45-
// Emitted as MCPP_DEP_<SANITIZED_NAME>_DIR so a build.mcpp can locate a
46-
// dependency's payload (e.g. a data-asset package) by name instead of
47-
// reverse-engineering the store layout. Same sanitizer as MCPP_FEATURE_.
44+
// mcpp#241: this package's resolved dependencies, as (name → dir) pairs.
45+
// The caller emits each dep under BOTH its canonical package name and its
46+
// short (namespace-stripped) name, so a build.mcpp can locate a dependency's
47+
// payload (e.g. a data-asset package) via either spelling. Emitted as
48+
// MCPP_DEP_<SANITIZED_NAME>_DIR (same sanitizer as MCPP_FEATURE_) instead of
49+
// reverse-engineering the store layout.
4850
std::vector<std::pair<std::string, std::filesystem::path>> depDirs;
4951
};
5052

@@ -369,11 +371,25 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv
369371
e.emplace_back("MCPP_FEATURE_" + sanitize_feature_env(f), "1");
370372
}
371373
e.emplace_back("MCPP_FEATURES", csv);
372-
// mcpp#241: per-dependency payload dir. Sanitize the DECLARED dep name (what
373-
// the author wrote in `deps`) so the var name is predictable from the
374-
// manifest, not from store internals.
375-
for (auto const& [name, dir] : env.depDirs)
376-
e.emplace_back("MCPP_DEP_" + sanitize_feature_env(name) + "_DIR", dir.string());
374+
// mcpp#241: per-dependency payload dir, under MCPP_DEP_<SANITIZED_NAME>_DIR
375+
// (same sanitizer as MCPP_FEATURE_ — predictable from the manifest, not
376+
// store internals). Two distinct dep names can sanitize to the same var
377+
// (e.g. `foo.bar` vs `foo-bar`, or a bare `zlib` vs another dep's short
378+
// `zlib`); guard so a silent last-wins can't hand one dep another's dir —
379+
// keep the first and warn on a conflicting value.
380+
std::map<std::string, std::string> depVarValue;
381+
for (auto const& [name, dir] : env.depDirs) {
382+
auto var = "MCPP_DEP_" + sanitize_feature_env(name) + "_DIR";
383+
auto [it, inserted] = depVarValue.try_emplace(var, dir.string());
384+
if (inserted) {
385+
e.emplace_back(var, dir.string());
386+
} else if (it->second != dir.string()) {
387+
mcpp::ui::warning(std::format(
388+
"build.mcpp: dependency name collides on {} (kept '{}', ignored "
389+
"'{}') — rename one dependency to disambiguate", var,
390+
it->second, dir.string()));
391+
}
392+
}
377393
return e;
378394
}
379395

src/build/prepare.cppm

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1851,6 +1851,14 @@ prepare_build(bool print_fingerprint,
18511851
std::size_t dependencyPackageIndex = 0;
18521852
mcpp::modgraph::DependencyVisibility visibility =
18531853
mcpp::modgraph::DependencyVisibility::Public;
1854+
// #242/#243: the per-edge feature request that THIS consumer made of
1855+
// THIS dependency. Feature activation must consume these off the edge
1856+
// graph (union over all incoming edges) rather than re-scanning only
1857+
// the root manifest's direct deps — otherwise a transitive dep's
1858+
// requested features and its consumer's `default-features = false` are
1859+
// silently dropped (resolution honors them per-edge; activation did not).
1860+
std::vector<std::string> requestedFeatures;
1861+
bool defaultFeatures = true;
18541862
};
18551863
std::vector<DependencyEdge> dependencyEdges;
18561864

@@ -1961,6 +1969,8 @@ prepare_build(bool print_fingerprint,
19611969
.consumerPackageIndex = consumerPackageIndex,
19621970
.dependencyPackageIndex = dependencyPackageIndex,
19631971
.visibility = visibility,
1972+
.requestedFeatures = spec.features,
1973+
.defaultFeatures = spec.defaultFeatures,
19641974
});
19651975
};
19661976

@@ -2836,17 +2846,32 @@ prepare_build(bool print_fingerprint,
28362846
apply(packages[0], rootReq);
28372847
for (auto& f : activate(*m, rootReq)) activeRootFeatures.insert(f);
28382848
}
2849+
// #242/#243: the feature request for a dependency PACKAGE, aggregated
2850+
// over ALL its incoming edges (a package may be depended on by several
2851+
// consumers — diamond — or reached only transitively). Cargo semantics:
2852+
// requested features UNION; default-features stays on unless EVERY
2853+
// consumer opted out. Sourcing this from the authoritative edge graph —
2854+
// rather than scanning only the root manifest's direct deps — makes
2855+
// activation AGREE with resolution (mergeActiveFeatureDeps, which reads
2856+
// the true per-edge spec): a transitive dep's requested features and its
2857+
// consumer's `default-features = false` are no longer silently dropped.
2858+
auto aggregatedRequest = [&](std::size_t depPkgIndex)
2859+
-> std::pair<std::vector<std::string>, bool> {
2860+
std::vector<std::string> feats;
2861+
bool anyEdge = false, anyDefault = false;
2862+
for (auto const& edge : dependencyEdges) {
2863+
if (edge.dependencyPackageIndex != depPkgIndex) continue;
2864+
anyEdge = true;
2865+
if (edge.defaultFeatures) anyDefault = true;
2866+
for (auto const& f : edge.requestedFeatures)
2867+
if (std::find(feats.begin(), feats.end(), f) == feats.end())
2868+
feats.push_back(f);
2869+
}
2870+
return { std::move(feats), anyEdge ? anyDefault : true };
2871+
};
28392872
for (std::size_t i = 1; i < packages.size(); ++i) {
28402873
auto& pname = packages[i].manifest.package.name;
2841-
std::vector<std::string> req;
2842-
bool depDefaultFeatures = true; // #242: consumer opt-out
2843-
for (auto& [dname, dspec] : m->dependencies) {
2844-
if (dname == pname || dspec.shortName == pname) {
2845-
req = dspec.features;
2846-
depDefaultFeatures = dspec.defaultFeatures;
2847-
break;
2848-
}
2849-
}
2874+
auto [req, depDefaultFeatures] = aggregatedRequest(i);
28502875
if (!req.empty() && !packages[i].manifest.featuresMap.empty()) {
28512876
for (auto& f : req) {
28522877
if (packages[i].manifest.featuresMap.contains(f)) continue;
@@ -2879,16 +2904,10 @@ prepare_build(bool print_fingerprint,
28792904
if (!std::filesystem::exists(pkg.root / "build.mcpp", bpEc)) continue;
28802905
auto host = host_tc_for_build_program();
28812906
if (!host) return std::unexpected(host.error());
2882-
std::vector<std::string> req;
2883-
bool depDefaultFeatures = true; // #242: consumer opt-out
2884-
for (auto& [dname, dspec] : m->dependencies) {
2885-
if (dname == pkg.manifest.package.name
2886-
|| dspec.shortName == pkg.manifest.package.name) {
2887-
req = dspec.features;
2888-
depDefaultFeatures = dspec.defaultFeatures;
2889-
break;
2890-
}
2891-
}
2907+
// Same edge-graph aggregation as feature activation above, so a
2908+
// dep build.mcpp sees the SAME active feature set the dep is built
2909+
// with (incl. transitive requests / default-features opt-out).
2910+
auto [req, depDefaultFeatures] = aggregatedRequest(i);
28922911
auto dirSafe = [](std::string s) {
28932912
for (auto& c : s) if (c == '/' || c == '\\' || c == ':') c = '_';
28942913
return s;
@@ -2901,17 +2920,24 @@ prepare_build(bool print_fingerprint,
29012920
/ (dirSafe(pkg.manifest.package.name) + "@" + pkg.manifest.package.version);
29022921
bpEnv.genBase = bpEnv.artifactsDir / "out";
29032922
// mcpp#241: expose this package's resolved dependencies (verdir /
2904-
// payload root) as MCPP_DEP_<NAME>_DIR, keyed by the dependency's
2905-
// canonical package name. Uses the authoritative consumer→dep edge
2906-
// graph (no name-guessing); covers feature-activated deps too, since
2907-
// mergeActiveFeatureDeps folded them into `dependencies` before the
2908-
// edges were recorded. (The ROOT project's own build.mcpp runs
2923+
// payload root) as MCPP_DEP_<NAME>_DIR. Uses the authoritative
2924+
// consumer→dep edge graph (no name-guessing); covers feature-
2925+
// activated deps too (mergeActiveFeatureDeps folded them into
2926+
// `dependencies` before the edges were recorded). A dep is emitted
2927+
// under BOTH its canonical package name AND its namespace-stripped
2928+
// short name, so `mcpp::dep_dir("compat.zlib")` and
2929+
// `mcpp::dep_dir("zlib")` both resolve regardless of which spelling
2930+
// the author used in `deps`. (The ROOT project's own build.mcpp runs
29092931
// before dependency resolution, so it does not yet receive these —
29102932
// tracked as a follow-up in the #230-#243 ledger.)
29112933
for (auto const& edge : dependencyEdges) {
29122934
if (edge.consumerPackageIndex != i) continue;
29132935
auto const& depPkg = packages[edge.dependencyPackageIndex];
2914-
bpEnv.depDirs.emplace_back(depPkg.manifest.package.name, depPkg.root);
2936+
const auto& canon = depPkg.manifest.package.name;
2937+
bpEnv.depDirs.emplace_back(canon, depPkg.root);
2938+
if (auto dot = canon.rfind('.'); dot != std::string::npos
2939+
&& dot + 1 < canon.size())
2940+
bpEnv.depDirs.emplace_back(canon.substr(dot + 1), depPkg.root);
29152941
}
29162942
auto& bcDep = pkg.manifest.buildConfig;
29172943
const auto cN = bcDep.cflags.size(), cxN = bcDep.cxxflags.size(),
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env bash
2+
# requires: elf gcc
3+
# mcpp#242 (transitive edge, found in the 0.0.99 architecture review): a
4+
# consumer's `default-features = false` must be honored even when the consumer
5+
# is itself a DEPENDENCY (transitive edge), not only for the root's direct deps.
6+
#
7+
# Before the edge-graph convergence, feature *resolution* honored the per-edge
8+
# opt-out (mergeActiveFeatureDeps) but feature *activation* re-derived the flag
9+
# by scanning only the ROOT manifest's direct deps — so a transitive dep's
10+
# opt-out was silently dropped: activation still seeded the dep's default
11+
# feature (defining its macro / keeping its default-gated sources) that
12+
# resolution had already skipped. Now both consume the authoritative
13+
# consumer→dep edge graph, so they agree.
14+
#
15+
# Layout: root(bin) -> A(lib) -> B(lib, `default = ["heavy"]`, heavy defines
16+
# B_HEAVY). A depends on B with `default-features = false`. B's heavy macro must
17+
# therefore be OFF in the transitive build.
18+
set -e
19+
20+
TMP=$(mktemp -d)
21+
trap "rm -rf $TMP" EXIT
22+
cd "$TMP"
23+
24+
# B: a library with a default feature `heavy` that defines B_HEAVY.
25+
mkdir -p B/src
26+
cat > B/mcpp.toml <<'EOF'
27+
[package]
28+
name = "B"
29+
version = "0.1.0"
30+
[modules]
31+
sources = ["src/**/*.cpp"]
32+
[features]
33+
default = ["heavy"]
34+
heavy = { defines = ["B_HEAVY=1"] }
35+
[targets.B]
36+
kind = "lib"
37+
EOF
38+
cat > B/src/b.cpp <<'EOF'
39+
int b_heavy() {
40+
#ifdef B_HEAVY
41+
return 1;
42+
#else
43+
return 0;
44+
#endif
45+
}
46+
EOF
47+
48+
# A: depends on B, opting OUT of B's default features.
49+
mkdir -p A/src
50+
cat > A/mcpp.toml <<'EOF'
51+
[package]
52+
name = "A"
53+
version = "0.1.0"
54+
[modules]
55+
sources = ["src/**/*.cpp"]
56+
[dependencies]
57+
B = { path = "../B", default-features = false }
58+
[targets.A]
59+
kind = "lib"
60+
EOF
61+
cat > A/src/a.cpp <<'EOF'
62+
extern int b_heavy();
63+
int a_val() { return b_heavy(); }
64+
EOF
65+
66+
# root consumer -> A (which transitively pulls B).
67+
mkdir -p app/src
68+
cat > app/mcpp.toml <<'EOF'
69+
[package]
70+
name = "app"
71+
version = "0.1.0"
72+
[modules]
73+
sources = ["src/**/*.cpp"]
74+
[dependencies]
75+
A = { path = "../A" }
76+
[targets.app]
77+
kind = "bin"
78+
main = "src/main.cpp"
79+
EOF
80+
cat > app/src/main.cpp <<'EOF'
81+
import std;
82+
extern int a_val();
83+
int main() { std::println("heavy={}", a_val()); return 0; }
84+
EOF
85+
86+
cd app
87+
"$MCPP" build > build.log 2>&1 || { cat build.log; echo "FAIL: build failed"; exit 1; }
88+
out="$("$MCPP" run 2>&1 | grep '^heavy=' | tail -1)"
89+
[[ "$out" == "heavy=0" ]] || {
90+
echo "FAIL: transitive default-features opt-out not honored (got '$out', want heavy=0)"
91+
exit 1
92+
}
93+
94+
# Control: flip A to KEEP B's defaults -> heavy must come back on. Clean first:
95+
# changing a transitive dep's active feature set across an in-place rebuild is a
96+
# separate fingerprint concern; this test isolates the activation logic with a
97+
# fresh build.
98+
cat > ../A/mcpp.toml <<'EOF'
99+
[package]
100+
name = "A"
101+
version = "0.1.0"
102+
[modules]
103+
sources = ["src/**/*.cpp"]
104+
[dependencies]
105+
B = { path = "../B" }
106+
[targets.A]
107+
kind = "lib"
108+
EOF
109+
"$MCPP" clean > /dev/null 2>&1 || true
110+
"$MCPP" build > build2.log 2>&1 || { cat build2.log; echo "FAIL: control build failed"; exit 1; }
111+
out2="$("$MCPP" run 2>&1 | grep '^heavy=' | tail -1)"
112+
[[ "$out2" == "heavy=1" ]] || {
113+
echo "FAIL: control (defaults kept) expected heavy=1, got '$out2'"
114+
exit 1
115+
}
116+
117+
echo "OK"

0 commit comments

Comments
 (0)