diff --git a/crates/manager-core/src/config_resolver.rs b/crates/manager-core/src/config_resolver.rs index e595005..7ddd74c 100644 --- a/crates/manager-core/src/config_resolver.rs +++ b/crates/manager-core/src/config_resolver.rs @@ -115,6 +115,14 @@ fn resolve_one(mut rows: Vec) -> Option<(Value, Provenance)> { .then(env_priority(&b.scope).cmp(&env_priority(&a.scope))) }); + // §3 step 1: env is eligibility, not a merge tier — within a single level + // `@E` *suppresses* `*` (it does not layer on top of it). Each level is one + // owner (single-parent tree) with at most one `@E` and one `*` row per key + // after env-filtering; keep only the level's winner (the `@E` row sorts + // first). Without this, a level holding both an `@E` map and a `*` map would + // deep-merge them instead of the `@E` shadowing the `*`. + rows.dedup_by_key(|r| r.depth); + // Collect the contiguous run of map-valued rows from the nearest. A // tombstone or scalar/array boundary stops the run (and, if it's the // nearest row, decides the result outright). @@ -323,6 +331,35 @@ mod tests { assert_eq!(p["ui"].merged_from.len(), 2); } + #[test] + fn same_level_env_map_suppresses_agnostic_map_no_merge() { + // §3 step 1: within ONE level, `@E` is not a merge layer over `*` — it + // shadows it. A level holding both an `@staging` map and a `*` map must + // resolve to the `@staging` map alone, never a deep-merge of the two. + let (v, p) = resolve(vec![ + cand(1, "*", "cfg", json!({"a": 1, "shared": "default"})), + cand(1, "staging", "cfg", json!({"shared": "staging"})), + ]); + assert_eq!(v["cfg"], json!({"shared": "staging"})); + assert_eq!(p["cfg"].scope, "staging"); + // Provenance must not list the suppressed `*` layer. + assert_eq!(p["cfg"].merged_from, vec![(1, "staging".to_string())]); + } + + #[test] + fn same_level_env_map_then_ancestor_map_merges_without_agnostic_sibling() { + // The suppressed same-level `*` must also be invisible to cross-level + // merge: leaf `@staging` map merges onto the ancestor map, but the + // leaf's own `*` map (shadowed at its level) never contributes. + let (v, _) = resolve(vec![ + cand(2, "*", "cfg", json!({"region": "eu", "tier": "base"})), + cand(0, "*", "cfg", json!({"leak": "should-not-appear"})), + cand(0, "staging", "cfg", json!({"tier": "leaf"})), + ]); + assert_eq!(v["cfg"], json!({"region": "eu", "tier": "leaf"})); + assert!(!v["cfg"].as_object().unwrap().contains_key("leak")); + } + #[test] fn nearer_scalar_replaces_whole_inherited_map() { let (v, _) = resolve(vec![