Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions src/build/prepare.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -4711,9 +4711,20 @@ prepare_build(bool print_fingerprint,
}
// A first sync is a refresh of an index that has no local
// copy yet, and `[index] auto_refresh = false` means that no
// refresh happens implicitly (docs/05). The policy's answer is
// taken rather than restated (#648 A5); offline, the sync is a
// no-op as before and resolution reports what is missing.
// refresh happens implicitly (docs/05). The opt-outs are the
// policy's (#648 A5); offline, the sync is a no-op as before
// and resolution reports what is missing.
//
// WHY THIS ONE DOES NOT GO THROUGH `decide_for_miss`/`apply`.
// Those answer "may this run refresh the index that would
// resolve a dependency", and their debounce and one-sync-per-
// process guard are about that one index. This sync creates a
// local copy that does not exist yet, of a DIFFERENT set of
// repositories, and nothing else will create it: taking the
// guard would let a refresh of the builtin index earlier in
// the same run suppress a clone the build cannot proceed
// without. Only the opt-outs are shared, and they are read
// from the same `policy_for`.
const auto refreshPolicy = mcpp::pm::policy_for(**cfg2);
if (needsRemoteUpdate && !refreshPolicy.offline && !refreshPolicy.autoRefresh) {
return std::unexpected(std::string(
Expand Down Expand Up @@ -6782,6 +6793,14 @@ prepare_build(bool print_fingerprint,
// while the manifest said it came from somewhere else. The comparison runs
// against `dependencies` after the conditional fold, so a row's replacement
// (#634 A1) is the declaration a restatement is held to.
//
// TWO SPELLINGS OF ONE SOURCE ARE ONE SOURCE. The comparison below decides
// whether a restatement names something else, so it has to be made on what
// the two declarations MEAN, not on their bytes: a path is normalised, and
// a version constraint is compared with its whitespace removed, because
// `">= 1.2.0"` and `">=1.2.0"` are one constraint and the manifest that
// spells them differently built on 2026.9.15.2. A gate added for #647 E4.2
// must refuse a restatement that names another source, and nothing else.
auto dependencySourceOf = [](const mcpp::manifest::DependencySpec& s) {
if (s.inheritWorkspace) return std::string("workspace = true");
if (s.isPath()) {
Expand All @@ -6794,6 +6813,15 @@ prepare_build(bool print_fingerprint,
s.gitRefKind.empty() ? "rev" : s.gitRefKind, s.gitRev);
return std::format("version = \"{}\"", s.version);
};
// What the comparison is made on. The message shows the declaration as it
// was written; the judgement drops the whitespace inside a constraint, so
// the two declarations are compared on what they mean.
auto dependencySourceKey = [&](const mcpp::manifest::DependencySpec& s) {
auto spelled = dependencySourceOf(s);
if (!s.inheritWorkspace && !s.isPath() && !s.isGit())
std::erase_if(spelled, [](char c) { return c == ' ' || c == '\t'; });
return spelled;
};
auto mergeActiveFeatureDeps = [&](mcpp::manifest::Manifest& pm,
const std::vector<std::string>& requested,
bool seedDefault = true)
Expand All @@ -6808,7 +6836,7 @@ prepare_build(bool print_fingerprint,
if (!pos->second.inheritWorkspace && !spec.inheritWorkspace) {
const auto inEffect = dependencySourceOf(pos->second);
const auto restated = dependencySourceOf(spec);
if (inEffect != restated)
if (dependencySourceKey(pos->second) != dependencySourceKey(spec))
return std::unexpected(std::format(
"[feature-deps.{}] of '{}' restates the dependency '{}' "
"with {}, while the declaration in effect on this row "
Expand Down
54 changes: 53 additions & 1 deletion tests/e2e/711_a_feature_deps_restatement_names_one_source.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
# Readings: the source-less entry is refused and names the remedy; the
# restatement with the same source builds the tool only under the feature, and
# the build program sees both spellings; a restatement naming another path is
# refused under `--strict` naming both sources.
# refused under `--strict` naming both sources; and two spellings of one version
# constraint, differing only in whitespace, are one source, while a genuinely
# different constraint is still refused.
set -e

TMP=$(mktemp -d)
Expand Down Expand Up @@ -109,4 +111,54 @@ fi
grep -q '../other' b4.log && grep -q '../installer' b4.log \
|| fail "the refusal does not name both sources" b4.log

# ── 4. two spellings of one constraint are one source ────────────────────
# The judgement is on what the declarations mean. `">= 9.9.9"` and `">=9.9.9"`
# are one constraint, and a manifest spelling them differently built on
# 2026.9.15.2, so refusing it would be an upgrade cliff. The package is never
# resolved here: the merge runs before resolution, so the reading is whether the
# refusal appears at all, and the leg below with a genuinely different
# constraint is what shows the gate still closes.
mkdir -p "$TMP/spell/src"
echo 'int main() { return 0; }' > "$TMP/spell/src/main.cpp"
write_spell() { # write_spell <restated constraint>
cat > "$TMP/spell/mcpp.toml" <<EOF
[package]
name = "spellprobe"
version = "0.1.0"
description = "two spellings of one constraint"
license = "Apache-2.0"
authors = ["mcpp"]

[language]
standard = "c++23"

[features]
extra = []

[dependencies]
mcpplibs.nonexistent-spell-probe = ">=9.9.9"

[feature-deps.extra]
mcpplibs.nonexistent-spell-probe = { version = "$1", reexport = true }

[targets.spellprobe]
kind = "bin"
main = "src/main.cpp"
EOF
}

cd "$TMP/spell"
write_spell ">= 9.9.9"
MCPP_OFFLINE=1 "$MCPP" build --features extra > s1.log 2>&1 || true
grep -q 'restates the dependency' s1.log \
&& fail "a restatement differing from the declaration only in whitespace was refused" s1.log

write_spell ">=9.9.8"
rm -rf target
if MCPP_OFFLINE=1 "$MCPP" build --features extra > s2.log 2>&1; then
fail "a restatement naming another constraint was accepted" s2.log
fi
grep -q 'restates the dependency' s2.log \
|| fail "a restatement naming another constraint was not refused" s2.log

echo "PASS: 711 a feature-deps restatement names one source; dep_bin answers both spellings"
Loading