Skip to main content

ossctl_core/contract/
normalize.rs

1//! Normalization pipeline: validate every field/enum/cross-field floor,
2//! materialize all defaults, and expand `targets` from `ecosystems`.
3//!
4//! A faithful port of `check-oss-release.py`'s `normalize`. `contract show`
5//! emits the canonical (normalized) form; `contract validate` runs the identical
6//! pipeline and discards the document, emitting only pass/fail (ADR-0001 §1).
7//!
8//! Every field is validated independently and **all** problems are collected —
9//! an invalid enum records an error *and* substitutes a default so the pass
10//! continues to surface every other problem (mirroring the Python `Problems`
11//! collector). The built [`Contract`] is only meaningful when
12//! [`Normalized::is_valid`] holds; callers gate on that (or, in the CLI, on the
13//! process exit code), never on parsing a document that failed.
14
15use std::io;
16use std::path::{Component, Path, PathBuf};
17
18use serde_yaml::{Mapping, Value};
19
20use crate::contract::schema::{
21    Adapter, Changelog, ChangelogMode, ChangelogSource, Contract, ContributionProvenance,
22    DependencyBot, Distribution, DistributionAdapter, DocsSite, Ecosystem, HealthBadge, Installer,
23    Maturity, ProvenanceLevel, Registry, Release, ReleaseLayout, ReleaseModel, Status, Target,
24    VersioningBase, DEFAULT_CROSS_PLATFORM_TARGETS, DEFAULT_FRAGMENT_DIR, KNOWN_SCHEMA_VERSION,
25};
26use crate::contract::spdx::spdx_valid;
27use crate::ports::Fs;
28
29/// The contract file the normalizer reads, relative to the repo root.
30pub const CONTRACT_FILENAME: &str = "OSS-RELEASE.md";
31
32/// Canonical ecosystem order — used to de-duplicate and stably order the
33/// `ecosystems` list (mirrors the Python `VALID_ECOSYSTEMS` ordered list).
34const ECOSYSTEM_ORDER: [Ecosystem; 5] = [
35    Ecosystem::Rust,
36    Ecosystem::Node,
37    Ecosystem::Python,
38    Ecosystem::Go,
39    Ecosystem::Binary,
40];
41
42/// Known top-level frontmatter keys; anything else is preserved under
43/// [`Contract::extra_fields`] (forward-compat).
44///
45/// **Invariant:** this list MUST stay in sync with the [`Contract`] struct
46/// fields — every parsed field has its source key here. A field added to
47/// [`Contract`] without its key here would be captured as an "unknown" field on
48/// input; the `all_known_keys_*` tests guard against that drift.
49///
50/// The two trailing entries — `extra_fields` and `warnings` — are the canonical
51/// *output* metadata keys, reserved here so canonical JSON (which carries them)
52/// re-fed to the normalizer as YAML does NOT re-capture them into a nested
53/// `extra_fields.extra_fields` on each pass. They are handled asymmetrically:
54/// `extra_fields`'s mapping contents are merged back into the captured map (see
55/// [`capture_unknown_fields`]) so the block round-trips losslessly; `warnings` is
56/// derived diagnostic output, regenerated every pass, so any input value under it
57/// is intentionally ignored (not preserved — it is not user contract data).
58const KNOWN_KEYS: &[&str] = &[
59    "schema_version",
60    "status",
61    "maturity",
62    "ecosystems",
63    "targets",
64    // Both distribution input keys are known: `distribution` (a single mapping,
65    // v1 back-compat) and `distributions` (a sequence, the monorepo shape). See
66    // [`parse_distributions`]; declaring both is an error, not an unknown-field.
67    "distribution",
68    "distributions",
69    "versioning",
70    "changelog",
71    "conventional_commits",
72    "release",
73    "contribution_provenance",
74    "provenance_level",
75    "dependency_bot",
76    "health_badges",
77    "license",
78    "docs_site",
79    // Reserved canonical-output metadata keys (not parsed) — see doc above.
80    "extra_fields",
81    "warnings",
82];
83
84/// Collected fatal errors and non-fatal warnings from a normalization pass.
85#[derive(Debug, Default)]
86pub struct Problems {
87    /// Fatal validation errors; a non-empty list means the config would not
88    /// normalize (the CLI exits non-zero with the §10 error envelope).
89    pub errors: Vec<String>,
90    /// Non-fatal notes (aspirational draft producers, the unknown-field report).
91    pub warnings: Vec<String>,
92}
93
94impl Problems {
95    fn err(&mut self, msg: String) {
96        self.errors.push(msg);
97    }
98
99    fn warn(&mut self, msg: String) {
100        self.warnings.push(msg);
101    }
102}
103
104/// The result of a normalization pass: the canonical [`Contract`] plus the
105/// [`Problems`] gathered while building it.
106#[derive(Debug)]
107pub struct Normalized {
108    /// The canonical contract. Only meaningful when [`Self::is_valid`] holds.
109    pub contract: Contract,
110    /// Errors and warnings gathered during normalization.
111    pub problems: Problems,
112}
113
114impl Normalized {
115    /// Whether the config normalized cleanly (no fatal errors).
116    #[must_use]
117    pub fn is_valid(&self) -> bool {
118        self.problems.errors.is_empty()
119    }
120}
121
122/// Why the contract file could not be loaded (distinct from a *validation*
123/// failure, which is carried by [`Problems`]). Maps to a §2 exit-2 system error.
124#[derive(Debug)]
125pub enum LoadError {
126    /// No `OSS-RELEASE.md` at the expected path.
127    NotFound(PathBuf),
128    /// The file exists but could not be read.
129    Io(PathBuf, io::Error),
130    /// The file is not valid UTF-8.
131    Utf8(PathBuf),
132}
133
134impl std::fmt::Display for LoadError {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Self::NotFound(p) => write!(
138                f,
139                "no {CONTRACT_FILENAME} at {} (run /oss-init to generate one)",
140                p.display()
141            ),
142            Self::Io(p, e) => write!(f, "cannot read {}: {e}", p.display()),
143            Self::Utf8(p) => write!(f, "{} is not valid UTF-8", p.display()),
144        }
145    }
146}
147
148/// Read `<repo_root>/OSS-RELEASE.md` through the [`Fs`] port and normalize it.
149///
150/// # Errors
151/// Returns [`LoadError`] when the file is missing, unreadable, or not UTF-8. A
152/// *validation* failure is not an error here — it is carried in the returned
153/// [`Normalized::problems`]; check [`Normalized::is_valid`].
154pub fn normalize(repo_root: &Path, fs: &dyn Fs) -> Result<Normalized, LoadError> {
155    let path = repo_root.join(CONTRACT_FILENAME);
156    let bytes = fs.read(&path).map_err(|e| match e.kind() {
157        io::ErrorKind::NotFound => LoadError::NotFound(path.clone()),
158        _ => LoadError::Io(path.clone(), e),
159    })?;
160    let text = String::from_utf8(bytes).map_err(|_| LoadError::Utf8(path.clone()))?;
161    Ok(normalize_str(&text, repo_root, fs))
162}
163
164/// Normalize the full text of an `OSS-RELEASE.md` (frontmatter + body).
165///
166/// Split from [`normalize`] so tests can exercise the pipeline on a string
167/// without a real file. `repo_root` and `fs` are still needed for the
168/// filesystem-dependent floors (the fragment-dir path floor and its advisory
169/// existence check).
170#[must_use]
171pub fn normalize_str(text: &str, repo_root: &Path, fs: &dyn Fs) -> Normalized {
172    let mut p = Problems::default();
173    let map = match split_frontmatter(text, &mut p) {
174        Some(fm) => parse_frontmatter(&fm, &mut p),
175        None => Mapping::new(),
176    };
177    let contract = build(&map, &mut p, repo_root, fs);
178    Normalized {
179        contract,
180        problems: p,
181    }
182}
183
184/// Read a required enum field, or record an error and fall back to `default`.
185/// Absent → `default` silently; present-but-invalid → error + `default`
186/// (matching the Python default-substitution behavior).
187macro_rules! enum_field {
188    ($map:expr, $key:expr, $ty:ty, $default:expr, $p:expr) => {{
189        match $map.get($key) {
190            None => $default,
191            Some(v) => match v.as_str().and_then(<$ty>::parse) {
192                Some(x) => x,
193                None => {
194                    $p.err(format!(
195                        "{} {} invalid — must be one of {:?}",
196                        $key,
197                        yaml_display(v),
198                        <$ty>::VALID
199                    ));
200                    $default
201                }
202            },
203        }
204    }};
205}
206
207#[allow(clippy::too_many_lines)]
208fn build(map: &Mapping, p: &mut Problems, repo_root: &Path, fs: &dyn Fs) -> Contract {
209    // schema_version — validate the DECLARED version (a too-new config is a hard
210    // stop, a sub-1 or non-integer is an error), but do NOT echo it: the canonical
211    // output is ALWAYS the current shape, so the emitted `schema_version` is
212    // KNOWN_SCHEMA_VERSION regardless of what the (older, still-readable) document
213    // declared. Echoing the declared version would stamp a canonical v2 body with a
214    // v1 number — a mislabeled, self-inconsistent shape a strict consumer cannot
215    // trust. The tool reads a v1 `distribution:` mapping and emits the v2
216    // `distributions: [...]` shape under `schema_version: 2`.
217    match map.get("schema_version") {
218        None => {}
219        Some(v) => match v.as_i64() {
220            Some(n) if n > i64::from(KNOWN_SCHEMA_VERSION) => p.err(format!(
221                "schema_version {n} exceeds what this tool knows ({KNOWN_SCHEMA_VERSION}); \
222                 upgrade the OSS-release skills before reading this config (refusing rather \
223                 than guessing)."
224            )),
225            Some(n) if n < 1 => p.err(format!("schema_version {n} is invalid (must be >= 1)")),
226            Some(_) => {}
227            None => p.err(format!(
228                "schema_version must be an integer, got {}",
229                yaml_display(v)
230            )),
231        },
232    }
233    let schema_version = KNOWN_SCHEMA_VERSION;
234
235    let status = enum_field!(map, "status", Status, Status::Draft, p);
236
237    // maturity — required (inference is /oss-init's job, not the normalizer's).
238    let maturity = match map.get("maturity") {
239        None => {
240            p.err("maturity is required (spike|mvp|production) — /oss-init infers it".to_string());
241            Maturity::Mvp
242        }
243        Some(v) => {
244            if let Some(m) = v.as_str().and_then(Maturity::parse) {
245                m
246            } else {
247                p.err(format!(
248                    "maturity {} invalid — must be one of {:?}",
249                    yaml_display(v),
250                    Maturity::VALID
251                ));
252                Maturity::Mvp
253            }
254        }
255    };
256
257    // ecosystems — validate, then de-dup into canonical order.
258    let mut parsed_ecos: Vec<Ecosystem> = Vec::new();
259    for item in as_list(map.get("ecosystems")) {
260        match item.as_str().and_then(Ecosystem::parse) {
261            Some(e) => parsed_ecos.push(e),
262            None => p.err(format!(
263                "ecosystems: {} invalid — must be one of {:?}",
264                yaml_display(&item),
265                Ecosystem::VALID
266            )),
267        }
268    }
269    let ecosystems: Vec<Ecosystem> = ECOSYSTEM_ORDER
270        .into_iter()
271        .filter(|e| parsed_ecos.contains(e))
272        .collect();
273
274    // versioning — split the base enum from the calver pattern.
275    let (versioning, versioning_pattern) = parse_versioning(map.get("versioning"), p);
276
277    // release (model + layout).
278    let (model, layout) = match map.get("release") {
279        None | Some(Value::Null) => (ReleaseModel::Gated, ReleaseLayout::Single),
280        Some(Value::Mapping(m)) => (
281            enum_field!(m, "model", ReleaseModel, ReleaseModel::Gated, p),
282            enum_field!(m, "layout", ReleaseLayout, ReleaseLayout::Single, p),
283        ),
284        Some(_) => {
285            p.err("release must be a mapping with model/layout".to_string());
286            (ReleaseModel::Gated, ReleaseLayout::Single)
287        }
288    };
289
290    // targets — expand from ecosystems when the key is OMITTED; but an explicit
291    // empty list is the author's authoritative "never publish anywhere" and is
292    // honored as-is (not re-expanded). Distinguishing *absent* from *explicit
293    // empty* is the whole point: a version-tracked/changelogged repo with no
294    // registry publish (a private service deployed by its own script) must be
295    // expressible. An empty target set is a valid, honored state — every floor
296    // and downstream consumer already treats "no targets" gracefully (no
297    // registry-license floor, no `registry` health badge, "nothing to publish"
298    // in the release engine).
299    let targets = match map.get("targets") {
300        None | Some(Value::Null) => expand_targets(&ecosystems, layout),
301        Some(Value::Sequence(seq)) if seq.is_empty() => Vec::new(),
302        Some(Value::Sequence(seq)) => validate_targets(seq, &ecosystems, layout, p),
303        Some(_) => {
304            p.err(
305                "targets must be a list of {ecosystem, package?, registry, adapter?} maps"
306                    .to_string(),
307            );
308            Vec::new()
309        }
310    };
311
312    // distributions — the binary-distribution blocks (cargo-dist/goreleaser); a
313    // registry-only repo has none (→ empty list), leaving its contract shape
314    // unchanged. The homebrew cross-field truth table (tap ↔ installer-producer ↔
315    // target-producer) is enforced afterwards by [`check_homebrew_configuration`],
316    // once both `targets` and `distributions` are resolved.
317    let distributions = parse_distributions(map, &targets, schema_version, p);
318
319    // changelog (mode + source + fragment_dir).
320    let changelog = match map.get("changelog") {
321        None | Some(Value::Null) => Changelog {
322            mode: ChangelogMode::Curated,
323            source: ChangelogSource::Manual,
324            fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
325        },
326        Some(Value::Mapping(m)) => {
327            let mode = enum_field!(m, "mode", ChangelogMode, ChangelogMode::Curated, p);
328            let source = enum_field!(m, "source", ChangelogSource, ChangelogSource::Manual, p);
329            let fragment_dir = match m.get("fragment_dir") {
330                None => DEFAULT_FRAGMENT_DIR.to_string(),
331                Some(v) => {
332                    if let Some(s) = v.as_str() {
333                        s.to_string()
334                    } else {
335                        p.err("changelog.fragment_dir must be a string path".to_string());
336                        DEFAULT_FRAGMENT_DIR.to_string()
337                    }
338                }
339            };
340            Changelog {
341                mode,
342                source,
343                fragment_dir,
344            }
345        }
346        Some(_) => {
347            p.err("changelog must be a mapping with mode/source".to_string());
348            Changelog {
349                mode: ChangelogMode::Curated,
350                source: ChangelogSource::Manual,
351                fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
352            }
353        }
354    };
355    // fragment_dir must be a relative path inside the repo (floor 6).
356    if !path_inside_repo(&changelog.fragment_dir) {
357        p.err(format!(
358            "floor: changelog.fragment_dir {} must be a relative path inside the repo (an \
359             absolute or '../'-escaping path is refused)",
360            quote_for_diagnostic(&changelog.fragment_dir)
361        ));
362    }
363
364    // conventional_commits.
365    let conventional_commits = match map.get("conventional_commits") {
366        None => false,
367        Some(Value::Bool(b)) => *b,
368        Some(v) => {
369            p.err(format!(
370                "conventional_commits must be true|false, got {}",
371                yaml_display(v)
372            ));
373            false
374        }
375    };
376
377    let contribution_provenance = enum_field!(
378        map,
379        "contribution_provenance",
380        ContributionProvenance,
381        ContributionProvenance::None,
382        p
383    );
384    let provenance_level = enum_field!(
385        map,
386        "provenance_level",
387        ProvenanceLevel,
388        ProvenanceLevel::None,
389        p
390    );
391
392    let dep_default = if maturity == Maturity::Spike {
393        DependencyBot::None
394    } else {
395        DependencyBot::Dependabot
396    };
397    let dependency_bot = enum_field!(map, "dependency_bot", DependencyBot, dep_default, p);
398
399    // license — a valid SPDX expression when set (default MIT).
400    let license = match map.get("license") {
401        None => "MIT".to_string(),
402        Some(v) => match v.as_str() {
403            Some(s) if !s.trim().is_empty() => {
404                if !spdx_valid(s) {
405                    p.err(format!(
406                        "license {} is not a valid SPDX expression (unknown id or malformed \
407                         AND/OR/WITH grammar)",
408                        quote_for_diagnostic(s)
409                    ));
410                }
411                s.to_string()
412            }
413            _ => {
414                p.err("license must be a non-empty SPDX id/expression (default MIT)".to_string());
415                "MIT".to_string()
416            }
417        },
418    };
419
420    let docs_site = enum_field!(map, "docs_site", DocsSite, DocsSite::None, p);
421
422    // health_badges — validate when present (key-presence, per Python), else
423    // materialize a floor-clean default (maturity/target aware).
424    let health_badges = if map.contains_key("health_badges") {
425        let mut out = Vec::new();
426        for item in as_list(map.get("health_badges")) {
427            match item.as_str().and_then(HealthBadge::parse) {
428                Some(hb) => out.push(hb),
429                None => p.err(format!(
430                    "health_badges: {} invalid — must be one of {:?}",
431                    yaml_display(&item),
432                    HealthBadge::VALID
433                )),
434            }
435        }
436        out
437    } else {
438        default_health_badges(maturity, &targets)
439    };
440
441    // ── Cross-field floors (§2) — config-internal, ALWAYS hard errors ────────
442    if model == ReleaseModel::Auto && maturity == Maturity::Spike {
443        p.err(
444            "floor: release.model 'auto' is not allowed on maturity 'spike' — a spike is not \
445             being published; raise maturity or set release.model: gated"
446                .to_string(),
447        );
448    }
449    if provenance_level == ProvenanceLevel::SlsaL3 && maturity != Maturity::Production {
450        p.err(format!(
451            "floor: provenance_level 'slsa-l3' is production-only — current maturity is '{}'",
452            maturity.as_str()
453        ));
454    }
455    // A target with a registry requires a valid SPDX license. Every expanded
456    // target carries a registry, so "any registry" reduces to "any target".
457    if !targets.is_empty() && !spdx_valid(&license) {
458        p.err(format!(
459            "floor: a target has a registry (crates.io/npm/PyPI/… require a license) but license \
460             {} is not a valid SPDX expression",
461            quote_for_diagnostic(&license)
462        ));
463    }
464    check_badge_producers(&health_badges, maturity, &targets, p);
465    // Homebrew cross-field consistency: missing-tap (either producer), the
466    // double-publish collision, and the dead-tap advisory — the full truth table.
467    check_homebrew_configuration(&targets, &distributions, p);
468    // A distribution block ships public binaries (GH-Release artifacts, a curl-pipe
469    // installer, a Homebrew tap PR) — that is publishing, and a spike is not being
470    // published. Mirrors the `release.model: auto` floor: raise maturity or drop the
471    // block. (No blocks → no constraint; registry-only spikes are unaffected.)
472    if !distributions.is_empty() && maturity == Maturity::Spike {
473        p.err(
474            "floor: a distribution block ships public binaries (installer + tap) — not allowed on \
475             maturity 'spike' (a spike is not being published); raise maturity or drop distribution"
476                .to_string(),
477        );
478    }
479
480    // ── Filesystem/producer-existence semantic check — ADVISORY, never fatal ─
481    if changelog.mode == ChangelogMode::Fragment
482        && path_inside_repo(&changelog.fragment_dir)
483        && !fs.is_dir(&repo_root.join(&changelog.fragment_dir))
484    {
485        p.warn(format!(
486            "changelog.mode 'fragment' but the fragment dir {} does not exist yet under {} — \
487             /oss-changelog creates it; /oss-readiness reports it as a gap until then",
488            quote_for_diagnostic(&changelog.fragment_dir),
489            repo_root.display()
490        ));
491    }
492
493    // ── Forward-compat: preserve unknown fields, report once ─────────────────
494    let extra_fields =
495        capture_unknown_fields(map, KNOWN_KEYS, CaptureScope::TopLevel, schema_version, p);
496
497    let warnings = p.warnings.clone();
498    Contract {
499        schema_version,
500        status,
501        maturity,
502        ecosystems,
503        targets,
504        distributions,
505        versioning,
506        versioning_pattern,
507        changelog,
508        conventional_commits,
509        release: Release { model, layout },
510        contribution_provenance,
511        provenance_level,
512        dependency_bot,
513        health_badges,
514        license,
515        docs_site,
516        extra_fields,
517        warnings,
518    }
519}
520
521fn parse_versioning(value: Option<&Value>, p: &mut Problems) -> (VersioningBase, Option<String>) {
522    let Some(v) = value else {
523        return (VersioningBase::Semver, None);
524    };
525    let Some(s) = v.as_str() else {
526        p.err(format!(
527            "versioning {} invalid — must be semver | calver:<pattern> | zerover",
528            yaml_display(v)
529        ));
530        return (VersioningBase::Semver, None);
531    };
532    if let Some(rest) = s.strip_prefix("calver:") {
533        let pattern = rest.trim();
534        if pattern.is_empty() {
535            p.err(
536                "versioning 'calver:' carries no pattern — e.g. calver:YYYY.MM.MICRO".to_string(),
537            );
538        }
539        (VersioningBase::Calver, Some(pattern.to_string()))
540    } else if s == "calver" {
541        p.err(
542            "versioning 'calver' must carry its pattern (calver:YYYY.MM.MICRO), not a bare label"
543                .to_string(),
544        );
545        (VersioningBase::Calver, None)
546    } else if let Some(base) = VersioningBase::parse(s) {
547        (base, None)
548    } else {
549        p.err(format!(
550            "versioning {} invalid — must be semver | calver:<pattern> | zerover",
551            quote_for_diagnostic(s)
552        ));
553        (VersioningBase::Semver, None)
554    }
555}
556
557/// Derive one target per ecosystem with default registry + adapter.
558fn expand_targets(ecosystems: &[Ecosystem], layout: ReleaseLayout) -> Vec<Target> {
559    ecosystems
560        .iter()
561        .map(|&e| Target {
562            ecosystem: e,
563            package: None,
564            registry: e.default_registry(),
565            adapter: e.default_adapter(layout),
566        })
567        .collect()
568}
569
570fn validate_targets(
571    seq: &[Value],
572    ecosystems: &[Ecosystem],
573    layout: ReleaseLayout,
574    p: &mut Problems,
575) -> Vec<Target> {
576    let mut out = Vec::new();
577    for (idx, item) in seq.iter().enumerate() {
578        let Value::Mapping(m) = item else {
579            p.err(format!(
580                "targets[{idx}] must be a map with at least {{ecosystem, registry}}"
581            ));
582            continue;
583        };
584
585        let ecosystem = if let Some(s) = m.get("ecosystem").and_then(Value::as_str) {
586            if let Some(e) = Ecosystem::parse(s) {
587                if !ecosystems.is_empty() && !ecosystems.contains(&e) {
588                    p.err(format!(
589                        "targets[{idx}].ecosystem {} is not in ecosystems {:?}",
590                        quote_for_diagnostic(s),
591                        ecosystems.iter().map(|e| e.as_str()).collect::<Vec<_>>()
592                    ));
593                }
594                Some(e)
595            } else {
596                p.err(format!(
597                    "targets[{idx}].ecosystem {} invalid — one of {:?}",
598                    quote_for_diagnostic(s),
599                    Ecosystem::VALID
600                ));
601                None
602            }
603        } else {
604            p.err(format!(
605                "targets[{idx}].ecosystem invalid — one of {:?}",
606                Ecosystem::VALID
607            ));
608            None
609        };
610
611        let registry = match m.get("registry").and_then(Value::as_str) {
612            None => {
613                p.err(format!(
614                    "targets[{idx}] has no registry (required — the publish destination)"
615                ));
616                None
617            }
618            Some(s) => {
619                if let Some(r) = Registry::parse(s) {
620                    Some(r)
621                } else {
622                    p.err(format!(
623                        "targets[{idx}].registry {} invalid — one of {:?}",
624                        quote_for_diagnostic(s),
625                        Registry::VALID
626                    ));
627                    None
628                }
629            }
630        };
631
632        let adapter = match m.get("adapter") {
633            None => ecosystem.map(|e| e.default_adapter(layout)),
634            Some(v) => {
635                if let Some(a) = v.as_str().and_then(Adapter::parse) {
636                    Some(a)
637                } else {
638                    p.err(format!(
639                        "targets[{idx}].adapter {} invalid — one of {:?}",
640                        yaml_display(v),
641                        Adapter::VALID
642                    ));
643                    None
644                }
645            }
646        };
647
648        // Floor: registry/adapter compatibility. A `homebrew`-registry target is
649        // served only by a homebrew adapter — `homebrew-tap` (push a formula to a
650        // personal tap) or `homebrew-core` (bump the central formula). Any other
651        // adapter (e.g. the ecosystem default `cargo-publish`, or `manual`) has no
652        // homebrew formula path, so the target would silently do nothing at cut
653        // time. Reject it here rather than at release time. Only checked once both
654        // are well-formed (a parse error already reported its own problem).
655        if let (Some(Registry::Homebrew), Some(a)) = (registry, adapter) {
656            if !matches!(a, Adapter::HomebrewTap | Adapter::HomebrewCore) {
657                p.err(format!(
658                    "floor: targets[{idx}] has registry 'homebrew' but adapter {} — a \
659                     homebrew-registry target requires adapter 'homebrew-tap' (personal tap) \
660                     or 'homebrew-core' (central formula)",
661                    quote_for_diagnostic(a.as_str())
662                ));
663            }
664        }
665
666        // On the error path, placeholders keep the strong type; the document is
667        // never emitted when problems.errors is non-empty.
668        out.push(Target {
669            ecosystem: ecosystem.unwrap_or(Ecosystem::Binary),
670            package: m.get("package").and_then(Value::as_str).map(str::to_string),
671            registry: registry.unwrap_or(Registry::GhReleases),
672            adapter: adapter.unwrap_or(Adapter::Manual),
673        });
674    }
675    out
676}
677
678/// Known `distribution`-block keys; anything else is preserved under
679/// [`Distribution::extra_fields`] (forward-compat), the nested analogue of
680/// [`KNOWN_KEYS`].
681///
682/// **Invariant:** this list MUST stay in sync with the [`Distribution`] struct
683/// fields (see the [`KNOWN_KEYS`] note). The trailing `extra_fields` entry is the
684/// reserved canonical-output metadata key — its contents are merged back rather
685/// than nested (see [`capture_unknown_fields`]). A [`Distribution`] carries no
686/// `warnings` (those live only at the top level), so only `extra_fields` needs
687/// reserving here.
688const KNOWN_DISTRIBUTION_KEYS: &[&str] = &[
689    "package",
690    "adapter",
691    "gh_releases",
692    "installers",
693    "homebrew_tap",
694    "platforms",
695    // Reserved canonical-output metadata key (not parsed) — see doc above.
696    "extra_fields",
697];
698
699/// Canonical installer order — used to de-duplicate and stably order the
700/// `distribution.installers` list (mirrors [`ECOSYSTEM_ORDER`]'s role).
701const INSTALLER_ORDER: [Installer; 5] = [
702    Installer::Shell,
703    Installer::Powershell,
704    Installer::Homebrew,
705    Installer::Msi,
706    Installer::Npm,
707];
708
709/// Parse the optional distribution layer, accepting BOTH input spellings:
710/// `distribution:` (a single mapping — v1 back-compat, the overwhelmingly common
711/// case) and `distributions:` (a sequence of mappings — a monorepo shipping
712/// several independently-distributed binaries). A registry-only repo declares
713/// neither (or a bare/null key) and gets an empty list, leaving its contract
714/// shape unchanged. Declaring BOTH keys at once is ambiguous and is an error.
715///
716/// Each element is parsed by [`parse_one_distribution`]; the collection-level
717/// floor (a monorepo's `package` must be present and unique) lives here.
718fn parse_distributions(
719    map: &Mapping,
720    targets: &[Target],
721    schema_version: u32,
722    p: &mut Problems,
723) -> Vec<Distribution> {
724    let single = map.get("distribution");
725    let many = map.get("distributions");
726    // Distinguish "absent" from "present-but-null": a bare `distribution:` /
727    // `distributions:` (null value) reads as absent, exactly like the sibling keys.
728    let single_present = matches!(single, Some(v) if !v.is_null());
729    let many_present = matches!(many, Some(v) if !v.is_null());
730    if single_present && many_present {
731        p.err(
732            "declare either `distribution` (one block) or `distributions` (a list), not both — \
733             they are the singular and plural spellings of the same field"
734                .to_string(),
735        );
736        // Fall through parsing the plural so the rest of the pass still surfaces
737        // problems; the document is never emitted while `errors` is non-empty.
738    }
739
740    let distributions = match (single, many) {
741        // `distributions:` — a sequence of mappings (the monorepo shape). Wins
742        // over a stray singular key (already flagged above).
743        (_, Some(Value::Sequence(seq))) => {
744            let mut out = Vec::with_capacity(seq.len());
745            for (idx, item) in seq.iter().enumerate() {
746                match item {
747                    Value::Mapping(m) => {
748                        out.push(parse_one_distribution(m, schema_version, p));
749                    }
750                    _ => p.err(format!(
751                        "distributions[{idx}] must be a mapping with {{package, adapter, \
752                         gh_releases?, installers?, homebrew_tap?, platforms?}}"
753                    )),
754                }
755            }
756            out
757        }
758        (_, Some(v)) if !v.is_null() => {
759            p.err(format!(
760                "distributions must be a list of distribution mappings, got {}",
761                yaml_display(v)
762            ));
763            Vec::new()
764        }
765        // `distribution:` — a single mapping (v1 back-compat) → a one-element list.
766        (Some(Value::Mapping(m)), _) => {
767            vec![parse_one_distribution(m, schema_version, p)]
768        }
769        (Some(v), _) if !v.is_null() => {
770            p.err(
771                "distribution must be a mapping with {adapter?, gh_releases?, installers?, \
772                 homebrew_tap?, platforms?} (or use `distributions:` for a list)"
773                    .to_string(),
774            );
775            Vec::new()
776        }
777        // Neither key (or both null) → a registry-only repo.
778        _ => Vec::new(),
779    };
780
781    // Collection floor: a monorepo (≥2 distributions) must tag each entry with a
782    // non-null, UNIQUE `package` — otherwise its distributions are
783    // indistinguishable and the association is meaningless. A single distribution
784    // may leave `package` null (the bare `distribution:` back-compat case).
785    if distributions.len() >= 2 {
786        let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
787        for (idx, d) in distributions.iter().enumerate() {
788            match d.package.as_deref() {
789                None => p.err(format!(
790                    "floor: distributions[{idx}] has no `package` — with two or more \
791                     distributions each must name the package it builds (the monorepo \
792                     association key), so they can be told apart"
793                )),
794                Some(pkg) if !seen.insert(pkg) => p.err(format!(
795                    "floor: distributions[{idx}].package {} is used by more than one \
796                     distribution — each distribution must name a distinct package",
797                    quote_for_diagnostic(pkg)
798                )),
799                Some(_) => {}
800            }
801        }
802
803        // Typo guard (advisory): once a monorepo names packages, a distribution
804        // whose `package` matches NO `targets[].package` is very likely a typo — a
805        // distribution should build a package the contract also tracks as a target.
806        // A warning, not a floor: a binary-only package legitimately need not appear
807        // in the registry `targets`, and `targets` may be empty (a version-tracked,
808        // unpublished repo) — so it fires only when there ARE named target packages
809        // to compare against.
810        let target_pkgs: std::collections::BTreeSet<&str> = targets
811            .iter()
812            .filter_map(|t| t.package.as_deref())
813            .collect();
814        if !target_pkgs.is_empty() {
815            for (idx, d) in distributions.iter().enumerate() {
816                if let Some(pkg) = d.package.as_deref() {
817                    if !target_pkgs.contains(pkg) {
818                        p.warn(format!(
819                            "distributions[{idx}].package {} matches no targets[].package \
820                             ({target_pkgs:?}) — likely a typo; a distribution should build a \
821                             package the contract also lists as a target",
822                            quote_for_diagnostic(pkg)
823                        ));
824                    }
825                }
826            }
827        }
828    }
829
830    distributions
831}
832
833/// Parse ONE distribution mapping (an element of `distributions`, or the sole
834/// `distribution:` block) into a [`Distribution`]. On the error path it records
835/// problems and returns a placeholder — the document is never emitted while
836/// `problems.errors` is non-empty.
837#[allow(clippy::too_many_lines)]
838fn parse_one_distribution(m: &Mapping, schema_version: u32, p: &mut Problems) -> Distribution {
839    // package — the monorepo association key. Optional (null for the sole/bare
840    // distribution); the collection-level floor in `parse_distributions` requires
841    // it once there are two or more distributions.
842    let package = match m.get("package") {
843        None | Some(Value::Null) => None,
844        Some(v) => match v.as_str() {
845            // Store the TRIMMED value: surrounding whitespace would otherwise make
846            // `" alpha"` and `"alpha"` distinct to the uniqueness floor and to the
847            // per-package association/audit keying, silently breaking both.
848            Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
849            _ => {
850                p.err(
851                    "distribution.package must be a non-empty string (the package this \
852                     distribution builds)"
853                        .to_string(),
854                );
855                None
856            }
857        },
858    };
859
860    // adapter — required when the block is present. Which tool OWNS the existing
861    // tag-triggered release workflow is not the normalizer's to guess (it renames
862    // release semantics and picks a Rust-specific default); inference is
863    // /oss-init's job, exactly as for `maturity`. A bare `distribution: {}` is
864    // therefore an error, not a silent "cargo-dist owns this repo".
865    let adapter = match m.get("adapter") {
866        None => {
867            p.err(
868                "distribution.adapter is required when a distribution block is present \
869                 (cargo-dist|goreleaser|manual) — /oss-init infers it"
870                    .to_string(),
871            );
872            DistributionAdapter::CargoDist
873        }
874        Some(v) => {
875            if let Some(a) = v.as_str().and_then(DistributionAdapter::parse) {
876                a
877            } else {
878                p.err(format!(
879                    "distribution.adapter {} invalid — must be one of {:?}",
880                    yaml_display(v),
881                    DistributionAdapter::VALID
882                ));
883                DistributionAdapter::CargoDist
884            }
885        }
886    };
887
888    let gh_releases = match m.get("gh_releases") {
889        // cargo-dist/goreleaser attach per-platform binaries by default.
890        None => true,
891        Some(Value::Bool(b)) => *b,
892        Some(v) => {
893            p.err(format!(
894                "distribution.gh_releases must be true|false, got {}",
895                yaml_display(v)
896            ));
897            true
898        }
899    };
900
901    // installers — validate each, then de-dup into canonical order.
902    let mut parsed_installers: Vec<Installer> = Vec::new();
903    for item in as_list(m.get("installers")) {
904        match item.as_str().and_then(Installer::parse) {
905            Some(i) => parsed_installers.push(i),
906            None => p.err(format!(
907                "distribution.installers: {} invalid — must be one of {:?}",
908                yaml_display(&item),
909                Installer::VALID
910            )),
911        }
912    }
913    let installers: Vec<Installer> = INSTALLER_ORDER
914        .into_iter()
915        .filter(|i| parsed_installers.contains(i))
916        .collect();
917
918    let homebrew_tap = match m.get("homebrew_tap") {
919        None | Some(Value::Null) => None,
920        Some(v) => match v.as_str() {
921            Some(s) if is_tap_slug(s) => Some(s.to_string()),
922            // An invalid slug substitutes `None` (not the bad value) so the
923            // built `Distribution` never carries a malformed tap — matching the
924            // "placeholders keep the strong type" error-path rule the rest of the
925            // normalizer follows, and letting the homebrew-needs-tap floor below
926            // still fire (a present-but-invalid tap is no tap).
927            Some(s) => {
928                p.err(format!(
929                    "distribution.homebrew_tap {} invalid — must be an 'owner/repo' slug",
930                    quote_for_diagnostic(s)
931                ));
932                None
933            }
934            None => {
935                p.err("distribution.homebrew_tap must be an 'owner/repo' string".to_string());
936                None
937            }
938        },
939    };
940
941    let wants_homebrew = installers.contains(&Installer::Homebrew);
942    // Floor: a `homebrew` installer needs a tap to push the generated formula to.
943    // This is a PER-BLOCK check — cargo-dist pushes the formula to the tap
944    // configured in this same distribution, so the tap must live here, not in a
945    // sibling distribution. (The target-side missing-tap floor, the double-publish
946    // collision, and the dead-tap advisory are cross-field and aggregate over all
947    // distributions + targets — they live in [`check_homebrew_configuration`].)
948    if wants_homebrew && homebrew_tap.is_none() {
949        p.err(
950            "floor: distribution.installers includes 'homebrew' but no distribution.homebrew_tap \
951             is set — the generated formula has nowhere to be pushed"
952                .to_string(),
953        );
954    }
955
956    // platforms — the binary target-triple set. Omitted/null → the cross-platform
957    // default (macOS + Linux musl), so a distribution that doesn't specify
958    // platforms covers Linux by default (the cross-platform install requirement).
959    // An explicit list is validated per triple and de-duplicated, preserving the
960    // author's order (like the sibling `targets` list — there is no canonical
961    // triple ordering to impose). An explicit *empty* list is NOT the same as
962    // omitted: it is a mistake, and silently defaulting it would surprise the
963    // author with targets they never listed and erase the intent the downstream
964    // cross-platform audit needs — so it is a hard error.
965    let platforms = match m.get("platforms") {
966        None | Some(Value::Null) => default_cross_platform_targets(),
967        Some(Value::Sequence(seq)) if seq.is_empty() => {
968            // Default fallback keeps error-collection going; the contract is never
969            // emitted while `problems.errors` is non-empty.
970            p.err(
971                "distribution.platforms is an empty list — omit the key to accept the \
972                 cross-platform default (macOS + Linux) or list explicit target-triples; a \
973                 distribution with no platforms builds nothing"
974                    .to_string(),
975            );
976            default_cross_platform_targets()
977        }
978        Some(Value::Sequence(seq)) => {
979            let mut out: Vec<String> = Vec::new();
980            for item in seq {
981                match item.as_str() {
982                    Some(s) if looks_like_target_triple(s) => {
983                        let triple = s.to_string();
984                        if !out.contains(&triple) {
985                            out.push(triple);
986                        }
987                    }
988                    Some(s) => p.err(format!(
989                        "distribution.platforms: {} is not a well-formed target-triple \
990                         (e.g. x86_64-unknown-linux-musl, aarch64-apple-darwin) — structural \
991                         check only; the toolchain is the final authority on what builds",
992                        quote_for_diagnostic(s)
993                    )),
994                    None => p.err(format!(
995                        "distribution.platforms: {} invalid — each entry must be a \
996                         target-triple string",
997                        yaml_display(item)
998                    )),
999                }
1000            }
1001            out
1002        }
1003        Some(v) => {
1004            p.err(format!(
1005                "distribution.platforms must be a list of target-triple strings, got {}",
1006                yaml_display(v)
1007            ));
1008            default_cross_platform_targets()
1009        }
1010    };
1011
1012    // Cross-check: an OS-specific installer whose target OS is absent from the
1013    // resolved `platforms` set is dead config — the generated installer points at
1014    // a binary the release never builds ("the installer has nothing to install").
1015    // A warning, not a floor (mirrors the `homebrew_tap`-without-consumer advisory
1016    // above): the contract is internally consistent, just wasteful. Only the
1017    // OS-specific installers constrain the set — see [`installer_os_need`] for the
1018    // full installer→OS table; npm/shell/powershell are not cross-checked.
1019    //
1020    // Gated on a clean parse: this is a cross-field semantic advisory, so it must
1021    // read only well-formed triples. A malformed triple (rejected above) that
1022    // happens to contain an OS keyword must neither satisfy nor spuriously fail
1023    // the coverage check — otherwise the warning would flip as the author fixes an
1024    // unrelated error. Errors already block emission, so gating here loses nothing.
1025    if p.errors.is_empty() {
1026        let has_windows = platforms.iter().any(|t| is_windows_triple(t));
1027        let has_macos = platforms.iter().any(|t| is_macos_triple(t));
1028        let has_linux = platforms.iter().any(|t| is_linux_triple(t));
1029        for &installer in &installers {
1030            let unmet = match installer_os_need(installer) {
1031                OsNeed::Unchecked => None,
1032                OsNeed::Windows => (!has_windows).then_some(
1033                    "distribution.installers includes 'msi' but the resolved \
1034                     distribution.platforms set has no Windows (*-windows-*) target — the MSI \
1035                     installer has nothing to install",
1036                ),
1037                // Homebrew serves macOS natively AND Linux via Linuxbrew, so a
1038                // single Linux triple satisfies it just as a darwin triple does;
1039                // the warning fires only when NEITHER is present (the issue's stated
1040                // intent when the darwin-vs-linux question is ambiguous).
1041                OsNeed::MacosOrLinux => (!has_macos && !has_linux).then_some(
1042                    "distribution.installers includes 'homebrew' but the resolved \
1043                     distribution.platforms set has no macOS (*-apple-darwin) or Linux \
1044                     (*-linux-*) target — the Homebrew formula has nothing to install",
1045                ),
1046            };
1047            if let Some(msg) = unmet {
1048                p.warn(msg.to_string());
1049            }
1050        }
1051    }
1052
1053    // Forward-compat: preserve unknown distribution sub-keys (the nested analogue
1054    // of the top-level `extra_fields` scan), so an older reader round-trips a
1055    // newer contract's distribution keys rather than dropping them. Reported once,
1056    // scoped to the block via the `Distribution` scope, mirroring the top-level
1057    // unknown-field warning — the shared helper keeps the two from drifting.
1058    let extra_fields = capture_unknown_fields(
1059        m,
1060        KNOWN_DISTRIBUTION_KEYS,
1061        CaptureScope::Distribution,
1062        schema_version,
1063        p,
1064    );
1065
1066    Distribution {
1067        package,
1068        adapter,
1069        gh_releases,
1070        installers,
1071        homebrew_tap,
1072        platforms,
1073        extra_fields,
1074    }
1075}
1076
1077/// The cross-platform default `distribution.platforms` set as owned strings —
1078/// materialized when the block omits `platforms` (or gives an empty list). Always
1079/// contains at least one Linux triple (the cross-platform install requirement).
1080fn default_cross_platform_targets() -> Vec<String> {
1081    DEFAULT_CROSS_PLATFORM_TARGETS
1082        .iter()
1083        .map(|&s| s.to_string())
1084        .collect()
1085}
1086
1087/// The OS coverage an installer needs from `distribution.platforms` to install
1088/// anything — the small installer→OS spec behind the installer↔platform
1089/// cross-check warning. Kept as one table (see [`installer_os_need`]) rather than
1090/// scattered conditionals so the mapping stays inspectable in one place.
1091enum OsNeed {
1092    /// Not cross-checked — this installer never constrains `platforms`.
1093    Unchecked,
1094    /// Needs at least one Windows triple.
1095    Windows,
1096    /// Needs at least one macOS OR Linux triple.
1097    MacosOrLinux,
1098}
1099
1100/// The OS an installer's generated artifact can actually install onto — the spec
1101/// that lets the normalizer flag an installer whose target OS is absent from
1102/// `platforms`. Only `msi` and `homebrew` are OS-gated; the rest are deliberately
1103/// left `Unchecked` (a scoping choice, not a claim that they run everywhere):
1104///
1105/// | installer    | need              | rationale                                          |
1106/// |--------------|-------------------|----------------------------------------------------|
1107/// | `msi`        | Windows           | an `.msi` installs only on Windows                 |
1108/// | `homebrew`   | macOS **or** Linux| Homebrew serves macOS natively and Linux (Linuxbrew) |
1109/// | `shell`      | — (not checked)   | a POSIX script; only msi/homebrew are gated for now |
1110/// | `powershell` | — (not checked)   | Windows-oriented; only msi/homebrew are gated for now |
1111/// | `npm`        | — (not checked)   | published to a registry, not tied to one OS's artifact |
1112fn installer_os_need(i: Installer) -> OsNeed {
1113    match i {
1114        Installer::Msi => OsNeed::Windows,
1115        Installer::Homebrew => OsNeed::MacosOrLinux,
1116        Installer::Shell | Installer::Powershell | Installer::Npm => OsNeed::Unchecked,
1117    }
1118}
1119
1120/// The OS ("system") component of a target-triple — the 3rd `-`-separated field
1121/// in the `<arch>-<vendor>-<os>[-<env>]` shape the shipped desktop triples use
1122/// (`x86_64-pc-windows-msvc`, `aarch64-apple-darwin`, `x86_64-unknown-linux-musl`).
1123/// `None` for a 2-component triple that names no vendor (`wasm32-wasip1`). Matching
1124/// the OS *positionally* (rather than "any component equals …") is what keeps
1125/// `aarch64-linux-android` out of the Linux bucket: its `linux` sits in the vendor
1126/// slot and the real OS component is `android`.
1127fn triple_os(s: &str) -> Option<&str> {
1128    s.split('-').nth(2)
1129}
1130
1131/// Whether a target-triple targets Windows — OS component `windows` (covering
1132/// both `-windows-msvc` and `-windows-gnu`).
1133fn is_windows_triple(s: &str) -> bool {
1134    triple_os(s) == Some("windows")
1135}
1136
1137/// Whether a target-triple targets macOS — OS component `darwin` (e.g.
1138/// `aarch64-apple-darwin`). Apple's non-macOS triples (`*-apple-ios`, `-tvos`, …)
1139/// carry a different OS component and are correctly excluded.
1140fn is_macos_triple(s: &str) -> bool {
1141    triple_os(s) == Some("darwin")
1142}
1143
1144/// Whether a target-triple targets Linux — OS component `linux` (e.g.
1145/// `x86_64-unknown-linux-musl`), covering the Linuxbrew case for `homebrew`.
1146/// Android (`aarch64-linux-android`) has `android` as its OS component and does
1147/// not count.
1148fn is_linux_triple(s: &str) -> bool {
1149    triple_os(s) == Some("linux")
1150}
1151
1152/// Whether `s` is a *structurally* plausible target-triple — 2–4 `-`-separated
1153/// components, each a non-empty run of `[a-z0-9_.]`. Deliberately LEXICAL, not
1154/// semantic: the real triple set is open and rustc-defined, so this is a
1155/// well-formedness gate, not a whitelist. It rejects what could never be a triple
1156/// (empty parts, uppercase, whitespace, punctuation, injection chars, wrong shape)
1157/// and accepts real triples including dotted arch names like
1158/// `thumbv8m.main-none-eabi` — but it also accepts structurally-valid nonsense like
1159/// `aa-bb`, because the toolchain is the final authority on whether a triple
1160/// actually builds. The OS component stays intact and inspectable so the
1161/// cross-platform `audit` can classify a set downstream.
1162fn looks_like_target_triple(s: &str) -> bool {
1163    let parts: Vec<&str> = s.split('-').collect();
1164    (2..=4).contains(&parts.len())
1165        && parts.iter().all(|part| {
1166            !part.is_empty()
1167                && part.bytes().all(|b| {
1168                    b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.')
1169                })
1170        })
1171}
1172
1173/// Whether `s` is a plausible `owner/repo` tap slug — exactly one `/`, and each
1174/// part a non-empty run of the GitHub-name character set (ASCII alphanumeric plus
1175/// `-`, `_`, `.`), with `.`/`..` rejected. Lexical only — existence is not
1176/// checked. Deliberately strict: this value flows into `brew tap` and repo URLs
1177/// downstream, so arbitrary punctuation, whitespace, or path traversal
1178/// (`owner/..`) must not pass.
1179fn is_tap_slug(s: &str) -> bool {
1180    fn valid_part(part: &str) -> bool {
1181        !part.is_empty()
1182            && part != "."
1183            && part != ".."
1184            && part
1185                .bytes()
1186                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
1187    }
1188    match s.split_once('/') {
1189        Some((owner, repo)) => valid_part(owner) && valid_part(repo) && !repo.contains('/'),
1190        None => false,
1191    }
1192}
1193
1194/// A floor-clean default badge set: `ci` at mvp+, `registry` when a publishable
1195/// target exists, `license` always.
1196fn default_health_badges(maturity: Maturity, targets: &[Target]) -> Vec<HealthBadge> {
1197    let mut badges = Vec::new();
1198    if matches!(maturity, Maturity::Mvp | Maturity::Production) {
1199        badges.push(HealthBadge::Ci);
1200    }
1201    if !targets.is_empty() {
1202        badges.push(HealthBadge::Registry);
1203    }
1204    badges.push(HealthBadge::License);
1205    badges
1206}
1207
1208/// Every enabled badge must have its producer enabled (floor 4).
1209fn check_badge_producers(
1210    badges: &[HealthBadge],
1211    maturity: Maturity,
1212    targets: &[Target],
1213    p: &mut Problems,
1214) {
1215    let has_registry_target = !targets.is_empty();
1216    for b in badges {
1217        match b {
1218            HealthBadge::Ci if maturity == Maturity::Spike => p.err(
1219                "floor: health_badge 'ci' has no producer at maturity 'spike' (no CI until mvp) — \
1220                 drop it or raise maturity"
1221                    .to_string(),
1222            ),
1223            HealthBadge::Registry if !has_registry_target => p.err(
1224                "floor: health_badge 'registry' has no producer — no target has a registry to \
1225                 publish to"
1226                    .to_string(),
1227            ),
1228            HealthBadge::Coverage if maturity != Maturity::Production => p.err(format!(
1229                "floor: health_badge 'coverage' has no producer — the coverage gate is a \
1230                 production-tier /oss-ci output; current maturity is '{}'",
1231                maturity.as_str()
1232            )),
1233            HealthBadge::Scorecard if maturity != Maturity::Production => p.err(format!(
1234                "floor: health_badge 'scorecard' has no producer — the Scorecard action is a \
1235                 production-tier output; current maturity is '{}'",
1236                maturity.as_str()
1237            )),
1238            _ => {}
1239        }
1240    }
1241}
1242
1243/// Cross-field Homebrew consistency — the full truth table over three aggregate
1244/// signals: a **tap** configured on any distribution, an installer-side formula
1245/// **producer** (a `homebrew` installer on any distribution — cargo-dist), and a
1246/// target-side formula **producer** (a `homebrew`-registry target whose adapter is
1247/// `homebrew-tap`, i.e. the release engine pushes a formula to the personal tap).
1248///
1249/// A `homebrew-core` target is deliberately NOT a tap-producer: it bumps the
1250/// central formula via a PR and needs no personal tap, so it neither requires a
1251/// `homebrew_tap` nor collides with the installer's tap push.
1252///
1253/// The floors (all hard errors, per the AI-first fail-fast contract):
1254/// - **missing-tap (target side):** a `homebrew-tap` target with no `homebrew_tap`
1255///   anywhere — the engine's `dist` phase has nowhere to push the formula. (The
1256///   installer side is floored per-block in [`parse_one_distribution`].)
1257/// - **double-publish:** a `homebrew` installer AND a `homebrew-tap` target both
1258///   generate + push a formula to the personal tap — a guaranteed collision.
1259///
1260/// Plus one **advisory** (warning, not a floor): a configured tap with no producer
1261/// of either kind is dead config — nothing ever writes to it.
1262///
1263/// **Why aggregate, not per-package (deliberate).** The three signals are OR-ed
1264/// across all distributions/targets rather than grouped by the monorepo `package`
1265/// key. This matches the release engine's actual homebrew model: a cut carries a
1266/// SINGLE tap (`ReleasePlan::homebrew_tap` is the first-found tap, see
1267/// `release::plan`), and the CLI's `ensure_single_distribution` rejects a
1268/// multi-distribution monorepo BEFORE it can be planned — a per-package multi-tap
1269/// monorepo is an explicit deferred follow-up, not a shape the engine can cut. For
1270/// everything the engine supports (≤1 distribution), aggregate == per-package, and
1271/// crucially the aggregate view is what lets a bare `package: null` distribution's
1272/// tap serve a named-package `homebrew-tap` target (ossctl's OWN contract shape) —
1273/// a strict `target.package == distribution.package` grouping would wrongly reject
1274/// it. Revisit this only alongside the engine's per-package-tap follow-up.
1275fn check_homebrew_configuration(
1276    targets: &[Target],
1277    distributions: &[Distribution],
1278    p: &mut Problems,
1279) {
1280    let has_tap = distributions.iter().any(|d| d.homebrew_tap.is_some());
1281    let installer_producer = distributions
1282        .iter()
1283        .any(|d| d.installers.contains(&Installer::Homebrew));
1284    // Narrowed to `homebrew-tap` (not merely `registry == homebrew`): only the tap
1285    // adapter pushes a formula to the personal tap. `validate_targets` already
1286    // floors any other adapter on a `homebrew` registry, so a `homebrew-core`
1287    // target is the only other well-formed case, and it is not a tap-producer.
1288    let tap_target_producer = targets
1289        .iter()
1290        .any(|t| t.registry == Registry::Homebrew && t.adapter == Adapter::HomebrewTap);
1291
1292    // Floor: a homebrew-tap target needs a tap destination for its formula.
1293    if tap_target_producer && !has_tap {
1294        p.err(
1295            "floor: a 'homebrew'-registry target with adapter 'homebrew-tap' generates a formula \
1296             but no distribution sets homebrew_tap — the formula has nowhere to be pushed (set \
1297             distribution.homebrew_tap to the 'owner/repo' tap)"
1298                .to_string(),
1299        );
1300    }
1301
1302    // Floor: the double-publish collision — two mechanisms push a formula to the
1303    // personal tap (cargo-dist's installer AND the engine's homebrew-tap adapter).
1304    if installer_producer && tap_target_producer {
1305        p.err(
1306            "floor: both a 'homebrew' installer (distribution.installers) and a 'homebrew'-registry \
1307             target with adapter 'homebrew-tap' generate + push a formula to the tap — they would \
1308             collide; keep exactly one homebrew formula producer, not both"
1309                .to_string(),
1310        );
1311    }
1312
1313    // Advisory: a tap nobody writes to (no installer producer, no tap target).
1314    if has_tap && !installer_producer && !tap_target_producer {
1315        p.warn(
1316            "distribution.homebrew_tap is set but there is neither a 'homebrew' installer in \
1317             distribution.installers nor a 'homebrew'-registry target with adapter 'homebrew-tap' \
1318             — no formula is generated, so the tap will never be updated"
1319                .to_string(),
1320        );
1321    }
1322}
1323
1324// ── Frontmatter extraction + parse ───────────────────────────────────────────
1325
1326/// A `---` fence line (exactly three dashes plus optional trailing whitespace).
1327fn is_fence(line: &str) -> bool {
1328    let t = line.trim_end();
1329    t == "---" || (t.starts_with("---") && t[3..].chars().all(char::is_whitespace))
1330}
1331
1332/// Split the YAML frontmatter block out of the document. Returns the frontmatter
1333/// text (body discarded — the normalizer never reads it), or `None` on a
1334/// structural error (recorded on `p`).
1335fn split_frontmatter(text: &str, p: &mut Problems) -> Option<String> {
1336    let mut lines = text.lines();
1337    match lines.next() {
1338        Some(first) if is_fence(first) => {}
1339        _ => {
1340            p.err("frontmatter missing: file must begin with a '---' YAML block".to_string());
1341            return None;
1342        }
1343    }
1344    let mut fm = String::new();
1345    for line in lines {
1346        if is_fence(line) {
1347            return Some(fm);
1348        }
1349        fm.push_str(line);
1350        fm.push('\n');
1351    }
1352    p.err("frontmatter not closed: no terminating '---' line found".to_string());
1353    None
1354}
1355
1356/// Parse the frontmatter into a YAML mapping. `serde_yaml` rejects duplicate
1357/// keys natively; a non-mapping top level or any YAML error is recorded on `p`.
1358fn parse_frontmatter(fm: &str, p: &mut Problems) -> Mapping {
1359    if fm.trim().is_empty() {
1360        return Mapping::new();
1361    }
1362    match serde_yaml::from_str::<Value>(fm) {
1363        Ok(Value::Null) => Mapping::new(),
1364        Ok(Value::Mapping(m)) => m,
1365        Ok(_) => {
1366            p.err("frontmatter: top level must be a mapping".to_string());
1367            Mapping::new()
1368        }
1369        Err(e) => {
1370            p.err(format!("frontmatter: invalid YAML — {e}"));
1371            Mapping::new()
1372        }
1373    }
1374}
1375
1376// ── Helpers ──────────────────────────────────────────────────────────────────
1377
1378/// Coerce a value to a list: a sequence stays; absent/null → empty; a scalar
1379/// becomes a one-element list (mirrors the Python `_as_list`).
1380fn as_list(v: Option<&Value>) -> Vec<Value> {
1381    match v {
1382        None | Some(Value::Null) => Vec::new(),
1383        Some(Value::Sequence(seq)) => seq.clone(),
1384        Some(other) => vec![other.clone()],
1385    }
1386}
1387
1388/// A compact display of a YAML scalar for error messages (strings are quoted).
1389fn yaml_display(v: &Value) -> String {
1390    match v {
1391        Value::String(s) => quote_for_diagnostic(s),
1392        Value::Bool(b) => b.to_string(),
1393        Value::Number(n) => n.to_string(),
1394        Value::Null => "null".to_string(),
1395        Value::Sequence(_) => "<list>".to_string(),
1396        Value::Mapping(_) => "<map>".to_string(),
1397        Value::Tagged(t) => yaml_display(&t.value),
1398    }
1399}
1400
1401/// Quote a user-controlled string for safe embedding in a warning/error message.
1402///
1403/// Diagnostics interleave user-controlled text (unknown field keys, rejected enum
1404/// values, package/tap/path strings) into a single line that lands in the §10
1405/// error envelope and the JSONL log. Wrapping such a value in bare single quotes
1406/// (`'{s}'`) lets a value carrying a quote, newline, or control character forge a
1407/// second diagnostic line or corrupt the log — a log-injection vector. JSON string
1408/// encoding escapes `"`, `\`, newlines, and C0 control characters (and leaves
1409/// ordinary text readable), so `foo` renders as `"foo"` and a hostile
1410/// `a"\ninjected` renders as `"a\"\ninjected"` on one intact line. Infallible:
1411/// serializing a string to JSON never fails.
1412fn quote_for_diagnostic(s: &str) -> String {
1413    serde_json::Value::String(s.to_owned()).to_string()
1414}
1415
1416/// Whether `rel` is a relative path that stays inside the repo — no absolute
1417/// path, no `../` escape — the fragment-dir floor. Lexical, so the path need not
1418/// exist. The check is purely on `rel`'s own component depth, so it holds
1419/// whether the repo root is absolute or relative (notably `--repo-root .`,
1420/// where `repo_root` normalizes to an empty path): a `..` is an escape the
1421/// moment it would pop above the repo root, exactly the Python
1422/// `_path_inside_repo` verdict (which rejects any `rel` that normalizes to an
1423/// escaping path). Joining `rel` onto a relative root and testing containment —
1424/// the previous approach — silently accepted `../etc` under a `.` root, because
1425/// an empty normalized root is a prefix of every path.
1426fn path_inside_repo(rel: &str) -> bool {
1427    let mut depth: usize = 0;
1428    for comp in Path::new(rel).components() {
1429        match comp {
1430            Component::CurDir => {}
1431            Component::Normal(_) => depth += 1,
1432            Component::ParentDir => {
1433                // An escape above the repo root the instant depth would go < 0.
1434                if depth == 0 {
1435                    return false;
1436                }
1437                depth -= 1;
1438            }
1439            // An absolute path (or a Windows drive prefix) never stays inside a
1440            // relative repo root.
1441            Component::RootDir | Component::Prefix(_) => return false,
1442        }
1443    }
1444    true
1445}
1446
1447/// Which mapping the [`capture_unknown_fields`] scan is running over — scopes the
1448/// forward-compat warning text and error messages. An enum (rather than a bare
1449/// string prefix) so a new call site cannot silently pass a mis-spaced label and
1450/// produce `unknown distributionfield(s)`.
1451#[derive(Clone, Copy)]
1452enum CaptureScope {
1453    /// The top-level frontmatter mapping ([`KNOWN_KEYS`]).
1454    TopLevel,
1455    /// The nested `distribution` block ([`KNOWN_DISTRIBUTION_KEYS`]).
1456    Distribution,
1457}
1458
1459impl CaptureScope {
1460    /// The infix woven into the warning/error text — `""` for the top level,
1461    /// `"distribution "` for the block — so a message reads `unknown field(s) …`
1462    /// vs `unknown distribution field(s) …`.
1463    fn label(self) -> &'static str {
1464        match self {
1465            Self::TopLevel => "",
1466            Self::Distribution => "distribution ",
1467        }
1468    }
1469}
1470
1471/// Scan a YAML mapping for keys outside `known` and preserve them under a
1472/// forward-compat `extra_fields` map, warning once when any were captured. The
1473/// single implementation behind BOTH the top-level ([`KNOWN_KEYS`]) and nested
1474/// `distribution` ([`KNOWN_DISTRIBUTION_KEYS`]) scans, so the two cannot drift
1475/// (the nested warning once silently omitted `schema_version`).
1476///
1477/// The guarantee is that an unknown **string** key is never dropped, never
1478/// double-captured, and round-trips predictably:
1479/// - A string key not in `known` is captured verbatim.
1480/// - A known string key is skipped (parsed as its field, not double-captured).
1481/// - The reserved canonical-output key `extra_fields` (in `known`) is not
1482///   re-captured into a nested `extra_fields.extra_fields`; instead its mapping
1483///   contents are **merged back** into the returned map (see
1484///   [`merge_reserved_extra_fields`]) so a hand-authored — or, defensively, a
1485///   re-fed canonical — `extra_fields` block round-trips losslessly rather than
1486///   being silently dropped. A key present both in that block and as a sibling
1487///   unknown field is an ambiguity error, not a silent overwrite.
1488///
1489/// Non-string keys (`42:`, `true:`, a list/map key — legal YAML) are a **structural
1490/// error**, not silently coerced: they can never be a forward-compatible schema
1491/// field (canonical JSON object keys are strings), and coercing them through the
1492/// display formatter would collapse distinct keys onto the same string (`42` and
1493/// `"42"`; every list key onto `<list>`) and silently drop a value — the opposite
1494/// of the never-drop intent. Rejecting keeps the invariant vacuously (an invalid
1495/// contract's output is never consumed) and matches the normalizer's
1496/// error-collection style.
1497fn capture_unknown_fields(
1498    m: &Mapping,
1499    known: &[&str],
1500    scope: CaptureScope,
1501    schema_version: u32,
1502    p: &mut Problems,
1503) -> serde_json::Map<String, serde_json::Value> {
1504    let label = scope.label();
1505    let mut extra_fields = serde_json::Map::new();
1506    // Merge an explicit `extra_fields` block first (reserved metadata key), so a
1507    // sibling unknown key colliding with it is detected below rather than
1508    // silently overwriting it.
1509    if let Some(v) = m.get("extra_fields") {
1510        merge_reserved_extra_fields(v, scope, &mut extra_fields, p);
1511    }
1512    for (k, v) in m {
1513        match k {
1514            Value::String(key) => {
1515                if known.contains(&key.as_str()) {
1516                    continue;
1517                }
1518                if extra_fields.contains_key(key) {
1519                    p.err(format!(
1520                        "{label}field '{key}' appears both as an unknown top-level key and inside \
1521                         the reserved '{label}extra_fields' block — refusing to drop either value; \
1522                         remove one"
1523                    ));
1524                } else {
1525                    extra_fields.insert(key.clone(), yaml_to_json(v));
1526                }
1527            }
1528            other => p.err(format!(
1529                "{label}field key {} must be a string — a non-string key is not a \
1530                 forward-compatible schema shape and cannot be preserved losslessly (distinct \
1531                 non-string keys collapse onto the same JSON key)",
1532                yaml_display(other)
1533            )),
1534        }
1535    }
1536    if !extra_fields.is_empty() {
1537        // serde_json::Map is ordered (BTreeMap, no `preserve_order`) → keys already
1538        // sorted. Each key is a user-controlled map key, so JSON-encode it (rather
1539        // than bare single-quoting) to keep a hostile key from forging a diagnostic
1540        // line — see [`quote_for_diagnostic`].
1541        let keys = extra_fields
1542            .keys()
1543            .map(|k| quote_for_diagnostic(k))
1544            .collect::<Vec<_>>()
1545            .join(", ");
1546        p.warn(format!(
1547            "unknown {label}field(s) preserved under schema_version {schema_version} \
1548             (forward-compat): [{keys}]"
1549        ));
1550    }
1551    extra_fields
1552}
1553
1554/// Merge the contents of a reserved `extra_fields` block (a hand-authored, or
1555/// defensively a re-fed canonical, mapping under the reserved `extra_fields` key)
1556/// into `out`, upholding the never-drop invariant for that block rather than
1557/// silently discarding it now that the key is reserved in `known`. A non-mapping
1558/// value, or a non-string key inside it, is a structural error (same rationale as
1559/// the sibling scan in [`capture_unknown_fields`]). Sibling-key collisions are
1560/// detected back in the caller, after this has seeded `out`.
1561fn merge_reserved_extra_fields(
1562    v: &Value,
1563    scope: CaptureScope,
1564    out: &mut serde_json::Map<String, serde_json::Value>,
1565    p: &mut Problems,
1566) {
1567    let label = scope.label();
1568    match v {
1569        Value::Null => {}
1570        Value::Mapping(inner) => {
1571            for (k, val) in inner {
1572                match k {
1573                    Value::String(key) => {
1574                        out.insert(key.clone(), yaml_to_json(val));
1575                    }
1576                    other => p.err(format!(
1577                        "reserved '{label}extra_fields' block has a non-string key {} — its keys \
1578                         must be strings",
1579                        yaml_display(other)
1580                    )),
1581                }
1582            }
1583        }
1584        other => p.err(format!(
1585            "reserved '{label}extra_fields' must be a mapping when present, got {}",
1586            yaml_display(other)
1587        )),
1588    }
1589}
1590
1591/// Convert an arbitrary YAML value to JSON, for `extra_fields` preservation.
1592fn yaml_to_json(v: &Value) -> serde_json::Value {
1593    use serde_json::Value as J;
1594    match v {
1595        Value::Null => J::Null,
1596        Value::Bool(b) => J::Bool(*b),
1597        Value::Number(n) => {
1598            if let Some(i) = n.as_i64() {
1599                J::from(i)
1600            } else if let Some(u) = n.as_u64() {
1601                J::from(u)
1602            } else if let Some(f) = n.as_f64() {
1603                serde_json::Number::from_f64(f).map_or(J::Null, J::Number)
1604            } else {
1605                J::Null
1606            }
1607        }
1608        Value::String(s) => J::String(s.clone()),
1609        Value::Sequence(seq) => J::Array(seq.iter().map(yaml_to_json).collect()),
1610        Value::Mapping(m) => {
1611            let mut obj = serde_json::Map::new();
1612            for (k, val) in m {
1613                let key = match k {
1614                    Value::String(s) => s.clone(),
1615                    other => yaml_display(other),
1616                };
1617                obj.insert(key, yaml_to_json(val));
1618            }
1619            J::Object(obj)
1620        }
1621        Value::Tagged(t) => yaml_to_json(&t.value),
1622    }
1623}
1624
1625#[cfg(test)]
1626mod tests {
1627    use super::*;
1628    use std::collections::HashSet;
1629
1630    /// A fake `Fs`: `normalize_str` never `read`s, so only the directory set
1631    /// matters (for the fragment-dir advisory check).
1632    struct FakeFs {
1633        dirs: HashSet<PathBuf>,
1634    }
1635
1636    impl FakeFs {
1637        fn empty() -> Self {
1638            Self {
1639                dirs: HashSet::new(),
1640            }
1641        }
1642
1643        fn with_dirs<const N: usize>(dirs: [&str; N]) -> Self {
1644            Self {
1645                dirs: dirs.iter().map(PathBuf::from).collect(),
1646            }
1647        }
1648    }
1649
1650    impl Fs for FakeFs {
1651        fn read(&self, _path: &Path) -> io::Result<Vec<u8>> {
1652            Err(io::Error::from(io::ErrorKind::NotFound))
1653        }
1654        fn exists(&self, path: &Path) -> bool {
1655            self.dirs.contains(path)
1656        }
1657        fn is_dir(&self, path: &Path) -> bool {
1658            self.dirs.contains(path)
1659        }
1660        fn is_file(&self, _path: &Path) -> bool {
1661            // The contract normalizer models only directories (fragment-dir).
1662            false
1663        }
1664        fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
1665            // The contract normalizer never lists directories.
1666            Ok(Vec::new())
1667        }
1668    }
1669
1670    fn repo() -> &'static Path {
1671        Path::new("/repo")
1672    }
1673
1674    fn norm(text: &str) -> Normalized {
1675        normalize_str(text, repo(), &FakeFs::empty())
1676    }
1677
1678    fn norm_with(text: &str, fs: &dyn Fs) -> Normalized {
1679        normalize_str(text, repo(), fs)
1680    }
1681
1682    fn assert_error_contains(n: &Normalized, needle: &str) {
1683        assert!(
1684            !n.is_valid(),
1685            "expected invalid, got clean normalize: {:?}",
1686            n.contract
1687        );
1688        assert!(
1689            n.problems.errors.iter().any(|e| e.contains(needle)),
1690            "no error contained {needle:?}; errors were {:?}",
1691            n.problems.errors
1692        );
1693    }
1694
1695    const MINIMAL: &str = "---\nstatus: approved\nmaturity: mvp\n---\n";
1696
1697    #[test]
1698    fn materializes_all_defaults() {
1699        let c = norm(MINIMAL).contract;
1700        // Pinned to the literal (not KNOWN_SCHEMA_VERSION) so a future bump is an
1701        // explicit, visible test change rather than silently tracking the constant.
1702        assert_eq!(c.schema_version, 2);
1703        assert_eq!(c.status, Status::Approved);
1704        assert_eq!(c.maturity, Maturity::Mvp);
1705        assert!(c.ecosystems.is_empty());
1706        assert!(c.targets.is_empty());
1707        assert_eq!(c.versioning, VersioningBase::Semver);
1708        assert_eq!(c.versioning_pattern, None);
1709        assert_eq!(c.changelog.mode, ChangelogMode::Curated);
1710        assert_eq!(c.changelog.source, ChangelogSource::Manual);
1711        assert_eq!(c.changelog.fragment_dir, DEFAULT_FRAGMENT_DIR);
1712        assert!(!c.conventional_commits);
1713        assert_eq!(c.release.model, ReleaseModel::Gated);
1714        assert_eq!(c.release.layout, ReleaseLayout::Single);
1715        assert_eq!(c.contribution_provenance, ContributionProvenance::None);
1716        assert_eq!(c.provenance_level, ProvenanceLevel::None);
1717        assert_eq!(c.dependency_bot, DependencyBot::Dependabot); // mvp default
1718        assert_eq!(c.license, "MIT");
1719        assert_eq!(c.docs_site, DocsSite::None);
1720        // mvp, no publishable target → [ci, license].
1721        assert_eq!(c.health_badges, vec![HealthBadge::Ci, HealthBadge::License]);
1722        assert!(c.extra_fields.is_empty());
1723    }
1724
1725    #[test]
1726    fn spike_defaults_no_bot_no_ci_badge() {
1727        let c = norm("---\nstatus: approved\nmaturity: spike\n---\n").contract;
1728        assert_eq!(c.dependency_bot, DependencyBot::None);
1729        assert_eq!(c.health_badges, vec![HealthBadge::License]);
1730    }
1731
1732    #[test]
1733    fn maturity_is_required() {
1734        assert_error_contains(
1735            &norm("---\nstatus: approved\n---\n"),
1736            "maturity is required",
1737        );
1738    }
1739
1740    #[test]
1741    fn expands_targets_from_ecosystems() {
1742        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n---\n").contract;
1743        assert_eq!(c.targets.len(), 1);
1744        assert_eq!(c.targets[0].ecosystem, Ecosystem::Python);
1745        assert_eq!(c.targets[0].package, None);
1746        assert_eq!(c.targets[0].registry, Registry::Pypi);
1747        assert_eq!(c.targets[0].adapter, Adapter::GhActionPypiPublish);
1748    }
1749
1750    /// Option B (publish-target-none): an explicit empty `targets: []` is the
1751    /// author's authoritative "never publish" and is honored as an empty set —
1752    /// NOT re-expanded into the ecosystem default. This is the whole fix: a
1753    /// version-tracked repo with a registry ecosystem but no publish must be
1754    /// expressible.
1755    #[test]
1756    fn explicit_empty_targets_is_honored_not_expanded() {
1757        let n =
1758            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
1759        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1760        let c = n.contract;
1761        // The rust ecosystem is still recorded …
1762        assert_eq!(c.ecosystems, vec![Ecosystem::Rust]);
1763        // … but NO crates.io target is force-expanded: the empty set is honored.
1764        assert!(
1765            c.targets.is_empty(),
1766            "explicit targets:[] must stay empty, got {:?}",
1767            c.targets
1768        );
1769    }
1770
1771    /// The counterpart to the above: OMITTING `targets` keeps the unchanged
1772    /// ecosystem-default expansion. Absent ≠ explicit-empty.
1773    #[test]
1774    fn omitted_targets_still_expands_to_ecosystem_default() {
1775        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
1776        assert_eq!(c.targets.len(), 1);
1777        assert_eq!(c.targets[0].ecosystem, Ecosystem::Rust);
1778        assert_eq!(c.targets[0].registry, Registry::CratesIo);
1779        assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
1780    }
1781
1782    /// A YAML `targets:` with a null value (not a list) is treated as *absent*,
1783    /// not as an explicit empty set — it still expands. Only a genuine empty
1784    /// sequence `[]` is the authoritative "never publish".
1785    #[test]
1786    fn null_targets_expands_like_omitted() {
1787        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n---\n")
1788            .contract;
1789        assert_eq!(c.targets.len(), 1);
1790        assert_eq!(c.targets[0].registry, Registry::CratesIo);
1791    }
1792
1793    /// An empty-targets contract round-trips through canonical JSON unchanged:
1794    /// `targets` serializes as an empty array `[]` (faithfully reporting the
1795    /// never-publish intent, not omitting or defaulting it), and re-feeding that
1796    /// canonical `targets` value back through the normalizer preserves the empty
1797    /// set — the intent survives a normalize→serialize→normalize cycle.
1798    #[test]
1799    fn empty_targets_round_trips_through_canonical_json() {
1800        let n =
1801            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
1802        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1803        let json = serde_json::to_value(&n.contract).unwrap();
1804        // The canonical output faithfully reports the empty set as `[]`.
1805        assert_eq!(json["targets"], serde_json::json!([]));
1806
1807        // Re-feed the canonical `targets` value as frontmatter; the empty set is
1808        // preserved (still no expansion), proving the round-trip is stable.
1809        let targets_yaml = serde_yaml::to_string(&json["targets"]).unwrap();
1810        let refed = format!(
1811            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: {}\n---\n",
1812            targets_yaml.trim()
1813        );
1814        let n2 = norm(&refed);
1815        assert!(n2.is_valid(), "errors: {:?}", n2.problems.errors);
1816        assert_eq!(n2.contract.targets, n.contract.targets);
1817        assert!(n2.contract.targets.is_empty());
1818    }
1819
1820    /// Cross-field: an explicit empty `targets: []` skips the registry-license
1821    /// floor (no target → no registry that requires an SPDX license), while a
1822    /// genuinely invalid license is still caught by its OWN check. Locks in that
1823    /// the `!targets.is_empty()` gate on the floor keeps honoring an empty set.
1824    #[test]
1825    fn explicit_empty_targets_skips_registry_license_floor() {
1826        let n = norm(
1827            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
1828             license: not-a-real-spdx-id\n---\n",
1829        );
1830        // The bad license is still invalid on its own …
1831        assert!(!n.is_valid());
1832        // … but the registry-requires-license FLOOR must NOT fire — there is no
1833        // registry target to trigger it.
1834        assert!(
1835            !n.problems
1836                .errors
1837                .iter()
1838                .any(|e| e.contains("floor: a target has a registry")),
1839            "registry-license floor fired despite empty targets: {:?}",
1840            n.problems.errors
1841        );
1842    }
1843
1844    /// Cross-field: forcing a `registry` health badge while declaring `targets: []`
1845    /// is a floor error — the badge has no producer (no registry to publish to).
1846    /// The empty set is honored, and the badge/target consistency floor still
1847    /// guards against a badge with nothing behind it.
1848    #[test]
1849    fn registry_badge_with_explicit_empty_targets_fails() {
1850        let n = norm(
1851            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
1852             health_badges: [registry, license]\n---\n",
1853        );
1854        assert_error_contains(&n, "health_badge 'registry' has no producer");
1855    }
1856
1857    /// The expansion-skip is independent of the `ecosystems` list: an explicit
1858    /// `targets: []` with NO ecosystems is still an honored empty set (and, like
1859    /// the minimal contract, defaults its badges to [ci, license] — no registry
1860    /// badge without a target).
1861    #[test]
1862    fn explicit_empty_targets_with_no_ecosystems() {
1863        let n = norm("---\nstatus: approved\nmaturity: mvp\ntargets: []\n---\n");
1864        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1865        assert!(n.contract.targets.is_empty());
1866        assert_eq!(
1867            n.contract.health_badges,
1868            vec![HealthBadge::Ci, HealthBadge::License]
1869        );
1870    }
1871
1872    #[test]
1873    fn node_monorepo_adapter_is_changesets() {
1874        let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\n\
1875                    release:\n  model: gated\n  layout: monorepo\n---\n";
1876        let c = norm(text).contract;
1877        assert_eq!(c.targets[0].adapter, Adapter::Changesets);
1878    }
1879
1880    #[test]
1881    fn ecosystems_dedup_to_canonical_order() {
1882        let c =
1883            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python, rust, python]\n---\n")
1884                .contract;
1885        assert_eq!(c.ecosystems, vec![Ecosystem::Rust, Ecosystem::Python]);
1886    }
1887
1888    #[test]
1889    fn calver_splits_base_and_pattern() {
1890        let c = norm(
1891            "---\nstatus: approved\nmaturity: mvp\nversioning: \"calver:YYYY.MM.MICRO\"\n---\n",
1892        )
1893        .contract;
1894        assert_eq!(c.versioning, VersioningBase::Calver);
1895        assert_eq!(c.versioning_pattern.as_deref(), Some("YYYY.MM.MICRO"));
1896    }
1897
1898    #[test]
1899    fn bare_calver_is_rejected() {
1900        assert_error_contains(
1901            &norm("---\nstatus: approved\nmaturity: mvp\nversioning: calver\n---\n"),
1902            "must carry its pattern",
1903        );
1904    }
1905
1906    #[test]
1907    fn floor_auto_on_spike() {
1908        let text = "---\nstatus: approved\nmaturity: spike\n\
1909                    release:\n  model: auto\n  layout: single\nhealth_badges: [license]\n---\n";
1910        assert_error_contains(&norm(text), "release.model 'auto' is not allowed");
1911    }
1912
1913    #[test]
1914    fn floor_slsa_l3_production_only() {
1915        assert_error_contains(
1916            &norm("---\nstatus: approved\nmaturity: mvp\nprovenance_level: slsa-l3\n---\n"),
1917            "slsa-l3' is production-only",
1918        );
1919    }
1920
1921    #[test]
1922    fn floor_registry_requires_valid_license() {
1923        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
1924                    license: Proprietary-Acme\nhealth_badges: [ci, registry, license]\n---\n";
1925        let n = norm(text);
1926        // Both the SPDX-validity error and the registry-needs-license floor fire.
1927        assert_error_contains(&n, "not a valid SPDX expression");
1928        assert!(n
1929            .problems
1930            .errors
1931            .iter()
1932            .any(|e| e.contains("floor: a target has a registry")));
1933    }
1934
1935    #[test]
1936    fn floor_badge_without_producer() {
1937        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n\
1938                    health_badges: [ci, coverage]\n---\n";
1939        assert_error_contains(&norm(text), "health_badge 'coverage' has no producer");
1940    }
1941
1942    #[test]
1943    fn floor_schema_version_too_new() {
1944        assert_error_contains(
1945            &norm("---\nschema_version: 99\nstatus: approved\nmaturity: mvp\n---\n"),
1946            "exceeds what this tool knows",
1947        );
1948    }
1949
1950    #[test]
1951    fn floor_fragment_dir_escape() {
1952        let text = "---\nstatus: approved\nmaturity: mvp\n\
1953                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: /etc\n---\n";
1954        assert_error_contains(&norm(text), "must be a relative path inside the repo");
1955    }
1956
1957    #[test]
1958    fn floor_fragment_dir_escape_relative_root() {
1959        // Regression: with a *relative* repo root (the CLI's `--repo-root .`),
1960        // a `../`-escaping fragment_dir must still be rejected. The earlier
1961        // join-then-contain check accepted it because a `.` root normalizes to
1962        // an empty path that prefixes everything.
1963        let text = "---\nstatus: approved\nmaturity: mvp\n\
1964                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: ../etc\n---\n";
1965        let n = normalize_str(text, Path::new("."), &FakeFs::empty());
1966        assert_error_contains(&n, "must be a relative path inside the repo");
1967    }
1968
1969    #[test]
1970    fn path_inside_repo_verdicts() {
1971        // Inside — plain and `.`/`..`-collapsing relative paths that stay in.
1972        assert!(path_inside_repo("changelog/fragments"));
1973        assert!(path_inside_repo("./changelog/fragments"));
1974        assert!(path_inside_repo("a/../fragments"));
1975        assert!(path_inside_repo("")); // the repo root itself
1976                                       // Escapes — absolute, leading `..`, and mid-path `..` that pops out.
1977        assert!(!path_inside_repo("/etc"));
1978        assert!(!path_inside_repo("../etc"));
1979        assert!(!path_inside_repo("a/../../etc"));
1980    }
1981
1982    #[test]
1983    fn unknown_fields_preserved_and_warned() {
1984        let text =
1985            "---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://example.com/x\n---\n";
1986        let n = norm(text);
1987        assert!(n.is_valid());
1988        assert_eq!(
1989            n.contract
1990                .extra_fields
1991                .get("roadmap_url")
1992                .and_then(|v| v.as_str()),
1993            Some("https://example.com/x")
1994        );
1995        assert!(n
1996            .problems
1997            .warnings
1998            .iter()
1999            .any(|w| w.contains("roadmap_url") && w.contains("forward-compat")));
2000    }
2001
2002    #[test]
2003    fn duplicate_key_is_rejected() {
2004        assert_error_contains(
2005            &norm("---\nstatus: approved\nstatus: draft\nmaturity: mvp\n---\n"),
2006            "invalid YAML",
2007        );
2008    }
2009
2010    #[test]
2011    fn missing_frontmatter_is_rejected() {
2012        assert_error_contains(&norm("no frontmatter here\n"), "frontmatter missing");
2013    }
2014
2015    #[test]
2016    fn unclosed_frontmatter_is_rejected() {
2017        assert_error_contains(&norm("---\nstatus: approved\n"), "frontmatter not closed");
2018    }
2019
2020    #[test]
2021    fn invalid_enum_records_error_and_continues() {
2022        // A bad status AND a bad maturity: both surface (multi-error collection).
2023        let n = norm("---\nstatus: bogus\nmaturity: alsobogus\n---\n");
2024        assert!(n.problems.errors.iter().any(|e| e.contains("status")));
2025        assert!(n.problems.errors.iter().any(|e| e.contains("maturity")));
2026    }
2027
2028    #[test]
2029    fn fragment_dir_present_suppresses_advisory() {
2030        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2031                    changelog:\n  mode: fragment\n  source: manual\n---\n";
2032        // The default fragment dir exists → no advisory warning.
2033        let fs = FakeFs::with_dirs(["/repo/changelog/fragments"]);
2034        let n = norm_with(text, &fs);
2035        assert!(n.is_valid());
2036        assert!(
2037            !n.problems
2038                .warnings
2039                .iter()
2040                .any(|w| w.contains("does not exist yet")),
2041            "advisory should be suppressed when the dir exists: {:?}",
2042            n.problems.warnings
2043        );
2044    }
2045
2046    #[test]
2047    fn serializes_to_schema_v4_shape() {
2048        let json =
2049            serde_json::to_value(&norm("---\nstatus: approved\nmaturity: mvp\n---\n").contract)
2050                .unwrap();
2051        // Spot-check the §4 top-level keys that consumers read.
2052        for key in [
2053            "schema_version",
2054            "status",
2055            "maturity",
2056            "ecosystems",
2057            "targets",
2058            "distributions",
2059            "versioning",
2060            "versioning_pattern",
2061            "changelog",
2062            "conventional_commits",
2063            "release",
2064            "contribution_provenance",
2065            "provenance_level",
2066            "dependency_bot",
2067            "health_badges",
2068            "license",
2069            "docs_site",
2070            "warnings",
2071        ] {
2072            assert!(json.get(key).is_some(), "missing §4 key {key}");
2073        }
2074        assert!(json["versioning_pattern"].is_null());
2075        // A registry-only contract carries an explicit empty `distributions: []` —
2076        // the collection is always a JSON array (v2 canonical shape).
2077        assert_eq!(json["distributions"], serde_json::json!([]));
2078        // An EMPTY `extra_fields` is OMITTED from canonical JSON (Option A,
2079        // `skip_serializing_if`): a contract with no unknown keys carries no
2080        // `extra_fields` key at all. It reappears only when populated — see
2081        // [`empty_extra_fields_absent_populated_present`].
2082        assert!(
2083            json.get("extra_fields").is_none(),
2084            "empty extra_fields must be absent, got {:?}",
2085            json.get("extra_fields")
2086        );
2087    }
2088
2089    /// Option A (omit-when-empty), asserted SYMMETRICALLY on both the top-level
2090    /// [`Contract::extra_fields`] and the nested [`Distribution::extra_fields`]:
2091    /// an empty map is ABSENT from canonical JSON, a populated map is PRESENT and
2092    /// byte-for-shape unchanged from before the `skip_serializing_if`.
2093    #[test]
2094    fn empty_extra_fields_absent_populated_present() {
2095        // Empty (both levels): a contract with a distribution but no unknown keys.
2096        let empty = serde_json::to_value(
2097            norm(
2098                "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2099                 distribution:\n  adapter: cargo-dist\n---\n",
2100            )
2101            .contract,
2102        )
2103        .unwrap();
2104        assert!(
2105            empty.get("extra_fields").is_none(),
2106            "empty top-level extra_fields must be absent"
2107        );
2108        assert!(
2109            empty["distributions"][0].get("extra_fields").is_none(),
2110            "empty nested extra_fields must be absent"
2111        );
2112
2113        // Populated (both levels): an unknown top-level key and an unknown
2114        // distribution key are preserved and PRESENT.
2115        let populated = serde_json::to_value(
2116            norm(
2117                "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2118                 roadmap_url: https://example.com/roadmap\n\
2119                 distribution:\n  adapter: cargo-dist\n  future_x: 1\n---\n",
2120            )
2121            .contract,
2122        )
2123        .unwrap();
2124        assert_eq!(
2125            populated["extra_fields"]["roadmap_url"],
2126            "https://example.com/roadmap"
2127        );
2128        assert_eq!(populated["distributions"][0]["extra_fields"]["future_x"], 1);
2129    }
2130
2131    // ── distribution (cargo-dist binary layer) ───────────────────────────────
2132
2133    /// A registry-only contract has no distribution: it normalizes clean and
2134    /// `distributions` is empty.
2135    #[test]
2136    fn registry_only_contract_has_no_distribution() {
2137        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
2138        assert!(c.distributions.is_empty());
2139        assert_eq!(c.targets.len(), 1);
2140        assert_eq!(c.targets[0].registry, Registry::CratesIo);
2141    }
2142
2143    /// A cargo-dist repo: a `distribution` block (binaries + shell/Homebrew
2144    /// installers + a tap) coexisting with a crates.io registry target.
2145    #[test]
2146    fn cargo_dist_distribution_coexists_with_registry() {
2147        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2148                    targets:\n  - {ecosystem: rust, package: issuectl, registry: crates.io, adapter: cargo-publish}\n\
2149                    distribution:\n  adapter: cargo-dist\n  installers: [shell, homebrew]\n  \
2150                    homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
2151        let n = norm(text);
2152        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2153        let c = n.contract;
2154        // The registry publish is still a Target.
2155        assert_eq!(c.targets.len(), 1);
2156        assert_eq!(c.targets[0].registry, Registry::CratesIo);
2157        assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
2158        // The binary layer is the Distribution block.
2159        let d = c
2160            .distributions
2161            .into_iter()
2162            .next()
2163            .expect("distribution present");
2164        assert_eq!(d.adapter, DistributionAdapter::CargoDist);
2165        assert!(d.gh_releases); // default true
2166        assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
2167        assert_eq!(
2168            d.homebrew_tap.as_deref(),
2169            Some("jarimustonen/homebrew-issuectl")
2170        );
2171    }
2172
2173    /// Round-trip: the serialized JSON shape a downstream `/oss-*` member reads.
2174    #[test]
2175    fn distribution_json_round_trip_shape() {
2176        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2177                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
2178                    installers: [shell, homebrew]\n  homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
2179        let json = serde_json::to_value(&norm(text).contract).unwrap();
2180        let d = &json["distributions"][0];
2181        assert_eq!(d["adapter"], "cargo-dist");
2182        assert_eq!(d["gh_releases"], true);
2183        assert_eq!(d["installers"], serde_json::json!(["shell", "homebrew"]));
2184        assert_eq!(d["homebrew_tap"], "jarimustonen/homebrew-issuectl");
2185        // A bare (singular) block carries a `null` association key.
2186        assert!(d["package"].is_null());
2187    }
2188
2189    // ── homebrew cross-field consistency floors (truth table) ────────────────
2190
2191    /// Build a production contract exercising the three homebrew signals: a
2192    /// configured `tap`, an `installer` producer (a `homebrew` installer), and a
2193    /// `tap_target` producer (a `homebrew`-registry target with adapter
2194    /// `homebrew-tap`). A crates.io target is always present so the contract has a
2195    /// licensed publishable target.
2196    fn hb_case(tap: bool, installer: bool, tap_target: bool) -> String {
2197        let mut fm = String::from(
2198            "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
2199             - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n",
2200        );
2201        if tap_target {
2202            fm.push_str(
2203                "  - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n",
2204            );
2205        }
2206        // A distribution block exists whenever we need to express an installer
2207        // producer or a configured tap; otherwise the contract is registry-only.
2208        if installer || tap {
2209            fm.push_str("distribution:\n  adapter: cargo-dist\n");
2210            if installer {
2211                fm.push_str("  installers: [homebrew]\n");
2212            } else {
2213                fm.push_str("  installers: [shell]\n");
2214            }
2215            if tap {
2216                fm.push_str("  homebrew_tap: owner/tap\n");
2217            }
2218        }
2219        fm.push_str("---\n");
2220        fm
2221    }
2222
2223    /// The full 8-row truth table (tap × installer-producer × tap-target-producer).
2224    /// Each row asserts the accept/reject verdict; the floor/advisory messages are
2225    /// pinned in the focused tests below.
2226    #[test]
2227    fn homebrew_truth_table_all_eight_rows() {
2228        // (tap, installer, tap_target, expect_valid)
2229        let rows = [
2230            (false, false, false, true), // 1: nothing homebrew → clean
2231            (false, false, true, false), // 2: tap-target, no tap → missing-tap floor
2232            (false, true, false, false), // 3: installer, no tap → per-block floor
2233            (false, true, true, false),  // 4: both producers, no tap → floors
2234            (true, false, false, true),  // 5: tap, no producer → dead-tap advisory (valid)
2235            (true, false, true, true),   // 6: tap + tap-target → well-formed (ossctl's case)
2236            (true, true, false, true),   // 7: tap + installer → well-formed (cargo-dist)
2237            (true, true, true, false),   // 8: tap + both producers → double-publish floor
2238        ];
2239        for (tap, installer, tap_target, expect_valid) in rows {
2240            let n = norm(&hb_case(tap, installer, tap_target));
2241            assert_eq!(
2242                n.is_valid(),
2243                expect_valid,
2244                "row (tap={tap}, installer={installer}, tap_target={tap_target}) expected \
2245                 valid={expect_valid}; errors were {:?}",
2246                n.problems.errors
2247            );
2248        }
2249    }
2250
2251    /// Row 2: a `homebrew-tap` target with no tap anywhere is a hard error (the
2252    /// target-side counterpart of the per-block installer-without-tap floor).
2253    #[test]
2254    fn homebrew_tap_target_without_tap_is_a_floor() {
2255        assert_error_contains(
2256            &norm(&hb_case(false, false, true)),
2257            "generates a formula but no distribution sets homebrew_tap",
2258        );
2259    }
2260
2261    /// Row 8: an installer producer AND a `homebrew-tap` target both push a formula
2262    /// to the tap — the double-publish collision is a hard error.
2263    #[test]
2264    fn homebrew_double_publish_is_a_floor() {
2265        assert_error_contains(
2266            &norm(&hb_case(true, true, true)),
2267            "they would collide; keep exactly one homebrew formula producer",
2268        );
2269    }
2270
2271    /// Row 5: a configured tap with neither producer is dead config — an advisory
2272    /// warning, and the contract still normalizes clean.
2273    #[test]
2274    fn homebrew_dead_tap_is_an_advisory_not_a_floor() {
2275        let n = norm(&hb_case(true, false, false));
2276        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2277        assert!(
2278            n.problems
2279                .warnings
2280                .iter()
2281                .any(|w| w.contains("the tap will never be updated")),
2282            "expected dead-tap advisory, warnings were {:?}",
2283            n.problems.warnings
2284        );
2285    }
2286
2287    /// Row 6: a `homebrew-tap` target with a configured tap and no installer
2288    /// producer is the well-formed case (ossctl's own shape) — clean, no advisory.
2289    #[test]
2290    fn homebrew_tap_target_with_tap_is_clean() {
2291        let n = norm(&hb_case(true, false, true));
2292        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2293        assert!(
2294            !n.problems
2295                .warnings
2296                .iter()
2297                .any(|w| w.contains("the tap will never be updated")),
2298            "unexpected dead-tap advisory: {:?}",
2299            n.problems.warnings
2300        );
2301    }
2302
2303    /// registry/adapter compatibility: a `homebrew`-registry target with a
2304    /// non-homebrew adapter (here the ecosystem default via an explicit `manual`)
2305    /// is a hard error — it has no homebrew formula path.
2306    #[test]
2307    fn homebrew_registry_requires_homebrew_adapter() {
2308        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
2309                    - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: manual}\n\
2310                    distribution:\n  adapter: cargo-dist\n  homebrew_tap: owner/tap\n---\n";
2311        assert_error_contains(
2312            &norm(text),
2313            "requires adapter 'homebrew-tap' (personal tap) or 'homebrew-core'",
2314        );
2315    }
2316
2317    /// A `homebrew-core` target is a valid homebrew adapter and needs NO personal
2318    /// tap (it bumps the central formula) — it is neither a missing-tap floor nor a
2319    /// dead-tap advisory, and does not collide with a `homebrew` installer.
2320    #[test]
2321    fn homebrew_core_target_needs_no_tap() {
2322        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
2323                    - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n  \
2324                    - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-core}\n---\n";
2325        let n = norm(text);
2326        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2327    }
2328
2329    /// registry/adapter compat, OMITTED adapter: a `homebrew`-registry target with
2330    /// no adapter resolves to the ecosystem default (`cargo-publish` for rust),
2331    /// which is non-homebrew — so it hits the same floor. The normalizer never
2332    /// registry-defaults a homebrew target to `homebrew-tap` (that would silently
2333    /// choose personal-tap publication over a homebrew-core PR); the author must
2334    /// spell the adapter. This locks the omitted-adapter path, not just explicit
2335    /// `manual`.
2336    #[test]
2337    fn homebrew_registry_omitted_adapter_is_a_floor() {
2338        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
2339                    - {ecosystem: rust, package: ossctl, registry: homebrew}\n---\n";
2340        assert_error_contains(
2341            &norm(text),
2342            "requires adapter 'homebrew-tap' (personal tap) or 'homebrew-core'",
2343        );
2344    }
2345
2346    /// The homebrew cross-field check reads the plural `distributions:` (Vec) path,
2347    /// not only the singular back-compat mapping: a one-entry `distributions:` list
2348    /// carrying the tap satisfies a `homebrew-tap` target (row 6 via the Vec shape).
2349    #[test]
2350    fn homebrew_tap_target_satisfied_via_plural_distributions() {
2351        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
2352                    - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n  \
2353                    - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n\
2354                    distributions:\n  \
2355                    - {package: ossctl, adapter: cargo-dist, installers: [shell], homebrew_tap: owner/tap}\n---\n";
2356        let n = norm(text);
2357        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2358        assert!(
2359            !n.problems
2360                .warnings
2361                .iter()
2362                .any(|w| w.contains("the tap will never be updated")),
2363            "unexpected dead-tap advisory: {:?}",
2364            n.problems.warnings
2365        );
2366    }
2367
2368    // ── monorepo: Vec<Distribution> + per-package association ─────────────────
2369
2370    /// Back-compat: a bare singular `distribution:` mapping deserializes as a
2371    /// one-element `distributions` list with a `null` package — the v1 author
2372    /// changes nothing.
2373    #[test]
2374    fn singular_distribution_parses_as_one_element_list() {
2375        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2376                    distribution:\n  adapter: cargo-dist\n---\n";
2377        let c = norm(text).contract;
2378        assert_eq!(c.distributions.len(), 1);
2379        assert_eq!(c.distributions[0].package, None);
2380        assert_eq!(c.distributions[0].adapter, DistributionAdapter::CargoDist);
2381    }
2382
2383    /// A monorepo: a plural `distributions:` sequence, each entry tagged with the
2384    /// package it builds, parses with the per-package association preserved in
2385    /// order (each distribution keeps its own installers/tap).
2386    #[test]
2387    fn plural_distributions_parse_with_per_package_association() {
2388        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2389                    targets:\n  - {ecosystem: rust, package: alpha, registry: crates.io}\n  \
2390                    - {ecosystem: rust, package: beta, registry: crates.io}\n\
2391                    distributions:\n  - {package: alpha, adapter: cargo-dist, installers: [shell]}\n  \
2392                    - {package: beta, adapter: cargo-dist, installers: [homebrew], homebrew_tap: owner/tap}\n---\n";
2393        let n = norm(text);
2394        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2395        let d = n.contract.distributions;
2396        assert_eq!(d.len(), 2);
2397        assert_eq!(d[0].package.as_deref(), Some("alpha"));
2398        assert_eq!(d[0].installers, vec![Installer::Shell]);
2399        assert_eq!(d[1].package.as_deref(), Some("beta"));
2400        assert_eq!(d[1].homebrew_tap.as_deref(), Some("owner/tap"));
2401    }
2402
2403    /// Canonical JSON round-trips for BOTH shapes: the emitted `distributions`
2404    /// array re-feeds as YAML frontmatter and normalizes to the same list — the
2405    /// single (bare `distribution:`) and the monorepo (`distributions:`) cases.
2406    #[test]
2407    fn distributions_canonical_json_round_trip() {
2408        for text in [
2409            "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2410             distribution:\n  adapter: cargo-dist\n  installers: [shell]\n---\n",
2411            "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2412             targets:\n  - {ecosystem: rust, package: a, registry: crates.io}\n  \
2413             - {ecosystem: rust, package: b, registry: crates.io}\n\
2414             distributions:\n  - {package: a, adapter: cargo-dist}\n  \
2415             - {package: b, adapter: goreleaser}\n---\n",
2416        ] {
2417            let first = norm(text).contract;
2418            assert!(!first.distributions.is_empty());
2419            // Re-feed the canonical JSON as the frontmatter of a fresh document.
2420            let json = serde_json::to_value(&first).unwrap();
2421            let refed = format!("---\n{}---\n", serde_yaml::to_string(&json).unwrap());
2422            let second = norm(&refed).contract;
2423            assert_eq!(
2424                first.distributions, second.distributions,
2425                "round-trip drift for: {text}"
2426            );
2427        }
2428    }
2429
2430    /// Declaring BOTH `distribution:` and `distributions:` is ambiguous → error.
2431    #[test]
2432    fn both_distribution_keys_is_an_error() {
2433        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2434                    distribution:\n  adapter: cargo-dist\n\
2435                    distributions:\n  - {package: a, adapter: cargo-dist}\n---\n";
2436        assert_error_contains(&norm(text), "not both");
2437    }
2438
2439    /// A monorepo (≥2 distributions) with an entry missing `package` → floor error
2440    /// (the entries would be indistinguishable).
2441    #[test]
2442    fn multi_distribution_missing_package_is_a_floor_error() {
2443        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2444                    targets:\n  - {ecosystem: rust, package: a, registry: crates.io}\n\
2445                    distributions:\n  - {package: a, adapter: cargo-dist}\n  \
2446                    - {adapter: cargo-dist}\n---\n";
2447        assert_error_contains(&norm(text), "must name the package it builds");
2448    }
2449
2450    /// A monorepo with a duplicate `package` across distributions → floor error.
2451    #[test]
2452    fn multi_distribution_duplicate_package_is_a_floor_error() {
2453        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2454                    distributions:\n  - {package: dup, adapter: cargo-dist}\n  \
2455                    - {package: dup, adapter: goreleaser}\n---\n";
2456        assert_error_contains(&norm(text), "distinct package");
2457    }
2458
2459    /// A v1 document (explicit `schema_version: 1`, singular `distribution:`)
2460    /// normalizes to the v2 canonical shape AND is re-labeled `schema_version: 2` —
2461    /// never a v2 body stamped with a v1 number. The tool reads v1, emits v2.
2462    #[test]
2463    fn v1_document_is_relabeled_to_current_schema_version_on_emit() {
2464        let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
2465                    ecosystems: [rust]\n\
2466                    distribution:\n  adapter: cargo-dist\n---\n";
2467        let n = norm(text);
2468        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2469        // Emitted version is the current one, not the declared 1.
2470        assert_eq!(n.contract.schema_version, 2);
2471        let json = serde_json::to_value(&n.contract).unwrap();
2472        assert_eq!(json["schema_version"], 2);
2473        // …and the shape is the v2 `distributions` array (the singular key parsed).
2474        assert_eq!(json["distributions"].as_array().map(Vec::len), Some(1));
2475    }
2476
2477    /// A whitespace-padded `package` is trimmed before storing — so `"  alpha "`
2478    /// and `"alpha"` are the SAME package to the uniqueness floor and association,
2479    /// not two distinct ones that would slip past the dup-check.
2480    #[test]
2481    fn distribution_package_is_trimmed() {
2482        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2483                    distributions:\n  - {package: '  alpha ', adapter: cargo-dist}\n  \
2484                    - {package: alpha, adapter: goreleaser}\n---\n";
2485        // The two trimmed packages collide → the duplicate-package floor fires.
2486        assert_error_contains(&norm(text), "distinct package");
2487    }
2488
2489    /// A single distribution MAY carry a `package` (no floor below the ≥2
2490    /// threshold) — the association key is optional, not forbidden, for one block.
2491    #[test]
2492    fn single_distribution_may_carry_a_package() {
2493        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2494                    targets:\n  - {ecosystem: rust, package: solo, registry: crates.io}\n\
2495                    distributions:\n  - {package: solo, adapter: cargo-dist}\n---\n";
2496        let n = norm(text);
2497        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2498        assert_eq!(n.contract.distributions[0].package.as_deref(), Some("solo"));
2499    }
2500
2501    /// Forward-compat: an unknown key inside the `distribution` block is preserved
2502    /// under `distribution.extra_fields` (not dropped) and survives a
2503    /// parse→serialize round-trip, mirroring the top-level `extra_fields` capture.
2504    /// A warning reports it once; the known distribution keys are unaffected.
2505    #[test]
2506    fn distribution_unknown_subkey_preserved_and_warned() {
2507        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2508                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
2509                    future_signing: {enabled: true, kms_key: alias/oss}\n---\n";
2510        let n = norm(text);
2511        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2512        let d = n
2513            .contract
2514            .clone()
2515            .distributions
2516            .into_iter()
2517            .next()
2518            .expect("distribution present");
2519        // The unknown sub-key is captured, with its nested value intact.
2520        assert_eq!(
2521            d.extra_fields
2522                .get("future_signing")
2523                .and_then(|v| v.get("kms_key"))
2524                .and_then(|v| v.as_str()),
2525            Some("alias/oss")
2526        );
2527        // Known keys are untouched by the capture.
2528        assert_eq!(d.adapter, DistributionAdapter::CargoDist);
2529        assert!(d.gh_releases);
2530        // It round-trips through the serialized JSON downstream members read.
2531        let json = serde_json::to_value(&n.contract).unwrap();
2532        assert_eq!(
2533            json["distributions"][0]["extra_fields"]["future_signing"]["enabled"],
2534            serde_json::json!(true)
2535        );
2536        // Reported once, scoped to the block, naming the key.
2537        assert!(
2538            n.problems.warnings.iter().any(|w| {
2539                w.contains("unknown distribution field(s) preserved")
2540                    && w.contains("future_signing")
2541            }),
2542            "expected a scoped forward-compat warning: {:?}",
2543            n.problems.warnings
2544        );
2545    }
2546
2547    // ── installer ↔ platform cross-check (warning, not a floor) ──────────────
2548
2549    /// `installers: [msi]` with no Windows triple in `platforms` warns — the MSI
2550    /// installer points at a binary the release never builds. Still valid (warning,
2551    /// not error): the contract is internally consistent, just wasteful.
2552    #[test]
2553    fn msi_installer_without_windows_platform_warns() {
2554        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2555                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
2556                    platforms: [x86_64-apple-darwin, x86_64-unknown-linux-musl]\n---\n";
2557        let n = norm(text);
2558        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2559        assert!(
2560            n.problems
2561                .warnings
2562                .iter()
2563                .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
2564            "expected an msi/Windows cross-check warning: {:?}",
2565            n.problems.warnings
2566        );
2567    }
2568
2569    /// `installers: [msi]` WITH a Windows triple present → no cross-check warning.
2570    #[test]
2571    fn msi_installer_with_windows_platform_no_warning() {
2572        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2573                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
2574                    platforms: [x86_64-pc-windows-msvc]\n---\n";
2575        let n = norm(text);
2576        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2577        assert!(
2578            !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
2579            "unexpected msi cross-check warning: {:?}",
2580            n.problems.warnings
2581        );
2582    }
2583
2584    /// `installers: [homebrew]` with NEITHER a macOS nor a Linux triple warns —
2585    /// the generated formula has nothing to install. (A Windows-only platform set
2586    /// is the only way to strand a `homebrew` installer, since Homebrew serves
2587    /// both macOS and Linux.)
2588    #[test]
2589    fn homebrew_installer_without_darwin_or_linux_warns() {
2590        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2591                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
2592                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2593                    platforms: [x86_64-pc-windows-msvc]\n---\n";
2594        let n = norm(text);
2595        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2596        assert!(
2597            n.problems
2598                .warnings
2599                .iter()
2600                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2601            "expected a homebrew/(macOS|Linux) cross-check warning: {:?}",
2602            n.problems.warnings
2603        );
2604    }
2605
2606    /// `installers: [homebrew]` is satisfied by a LINUX triple alone (Linuxbrew) —
2607    /// no darwin triple required. The chosen interpretation: homebrew needs macOS
2608    /// OR Linux, so a Linux-only platform set is coherent, not a warning.
2609    #[test]
2610    fn homebrew_installer_with_linux_only_no_warning() {
2611        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2612                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
2613                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2614                    platforms: [x86_64-unknown-linux-musl]\n---\n";
2615        let n = norm(text);
2616        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2617        assert!(
2618            !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
2619            "unexpected homebrew cross-check warning for a Linux-only set: {:?}",
2620            n.problems.warnings
2621        );
2622    }
2623
2624    /// npm and shell installers are OS-agnostic: even a platform set that would
2625    /// strand an msi (no Windows) never warns for them.
2626    #[test]
2627    fn npm_and_shell_installers_never_cross_check_warn() {
2628        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust, node]\n\
2629                    distribution:\n  adapter: cargo-dist\n  installers: [shell, npm]\n  \
2630                    platforms: [x86_64-apple-darwin]\n---\n";
2631        let n = norm(text);
2632        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2633        assert!(
2634            !n.problems
2635                .warnings
2636                .iter()
2637                .any(|w| w.contains("nothing to install")),
2638            "OS-agnostic installers must not cross-check warn: {:?}",
2639            n.problems.warnings
2640        );
2641    }
2642
2643    /// A coherent installer/platform set (msi + Windows, homebrew + darwin) emits
2644    /// no cross-check warning.
2645    #[test]
2646    fn coherent_installer_platform_set_no_warning() {
2647        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2648                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, msi]\n  \
2649                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2650                    platforms: [aarch64-apple-darwin, x86_64-pc-windows-msvc]\n---\n";
2651        let n = norm(text);
2652        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2653        assert!(
2654            !n.problems
2655                .warnings
2656                .iter()
2657                .any(|w| w.contains("nothing to install")),
2658            "coherent set must not warn: {:?}",
2659            n.problems.warnings
2660        );
2661    }
2662
2663    /// ossctl's own contract shape — installers `[shell, powershell]` with a
2664    /// platform set spanning Windows + macOS + Linux — produces no cross-check
2665    /// warning (both installers are agnostic here, and every OS is covered anyway).
2666    #[test]
2667    fn ossctl_own_contract_shape_no_cross_check_warning() {
2668        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2669                    distribution:\n  adapter: cargo-dist\n  installers: [shell, powershell]\n  \
2670                    platforms: [aarch64-apple-darwin, x86_64-apple-darwin, \
2671                    x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, \
2672                    x86_64-pc-windows-msvc]\n---\n";
2673        let n = norm(text);
2674        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2675        assert!(
2676            !n.problems
2677                .warnings
2678                .iter()
2679                .any(|w| w.contains("nothing to install")),
2680            "ossctl's own shape must not cross-check warn: {:?}",
2681            n.problems.warnings
2682        );
2683    }
2684
2685    /// `installers: [msi]` with `platforms` OMITTED warns: the default set
2686    /// (macOS + Linux) carries no Windows triple, so the MSI installs nothing.
2687    /// This is the common footgun — the author added msi but never listed a
2688    /// Windows target.
2689    #[test]
2690    fn msi_installer_with_defaulted_platforms_warns() {
2691        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2692                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n---\n";
2693        let n = norm(text);
2694        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2695        assert!(
2696            n.problems
2697                .warnings
2698                .iter()
2699                .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
2700            "expected an msi/Windows warning against the defaulted platform set: {:?}",
2701            n.problems.warnings
2702        );
2703    }
2704
2705    /// `installers: [msi]` is satisfied by a `*-windows-gnu` triple just as by
2706    /// `*-windows-msvc` — both target the Windows OS. No warning.
2707    #[test]
2708    fn msi_installer_with_windows_gnu_no_warning() {
2709        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2710                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
2711                    platforms: [x86_64-pc-windows-gnu]\n---\n";
2712        let n = norm(text);
2713        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2714        assert!(
2715            !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
2716            "windows-gnu must satisfy msi: {:?}",
2717            n.problems.warnings
2718        );
2719    }
2720
2721    /// `installers: [homebrew]` with an ANDROID-only platform set warns: Android
2722    /// triples (`aarch64-linux-android`) carry `linux` in the *vendor* slot but an
2723    /// `android` OS component — Homebrew/Linuxbrew does not serve Android, so the
2724    /// formula has nothing to install. Regression guard for the positional
2725    /// `triple_os` OS-component match (vs a naive any-component `== "linux"`).
2726    #[test]
2727    fn homebrew_installer_with_android_only_warns() {
2728        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2729                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
2730                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2731                    platforms: [aarch64-linux-android]\n---\n";
2732        let n = norm(text);
2733        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2734        assert!(
2735            n.problems
2736                .warnings
2737                .iter()
2738                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2739            "Android-only must strand a homebrew installer: {:?}",
2740            n.problems.warnings
2741        );
2742    }
2743
2744    /// `installers: [homebrew]` with an APPLE-iOS-only set warns: `*-apple-ios`
2745    /// carries an `ios` OS component, not `darwin`, so it is not a macOS target and
2746    /// Homebrew serves neither iOS nor (here) Linux.
2747    #[test]
2748    fn homebrew_installer_with_apple_ios_only_warns() {
2749        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2750                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
2751                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2752                    platforms: [aarch64-apple-ios]\n---\n";
2753        let n = norm(text);
2754        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2755        assert!(
2756            n.problems
2757                .warnings
2758                .iter()
2759                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2760            "apple-ios must not satisfy homebrew's macOS need: {:?}",
2761            n.problems.warnings
2762        );
2763    }
2764
2765    /// `installers: [homebrew]` with a macOS-only set (no Linux) is coherent — the
2766    /// isolated darwin case, distinct from the Linux-only test above.
2767    #[test]
2768    fn homebrew_installer_with_macos_only_no_warning() {
2769        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2770                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
2771                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2772                    platforms: [aarch64-apple-darwin]\n---\n";
2773        let n = norm(text);
2774        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2775        assert!(
2776            !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
2777            "macOS-only must satisfy homebrew: {:?}",
2778            n.problems.warnings
2779        );
2780    }
2781
2782    /// Two stranded installers → two independent warnings. A wasm-only platform
2783    /// set has no OS component any installer supports, so both `msi` and `homebrew`
2784    /// warn (exactly once each — the installer list is de-duped and canonically
2785    /// ordered).
2786    #[test]
2787    fn both_installers_stranded_warn_once_each() {
2788        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2789                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, msi]\n  \
2790                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2791                    platforms: [wasm32-unknown-unknown]\n---\n";
2792        let n = norm(text);
2793        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2794        let msi = n
2795            .problems
2796            .warnings
2797            .iter()
2798            .filter(|w| w.contains("includes 'msi'"))
2799            .count();
2800        let brew = n
2801            .problems
2802            .warnings
2803            .iter()
2804            .filter(|w| w.contains("includes 'homebrew'"))
2805            .count();
2806        assert_eq!((msi, brew), (1, 1), "warnings: {:?}", n.problems.warnings);
2807    }
2808
2809    /// A malformed triple that happens to contain an OS keyword must NOT drive the
2810    /// cross-check: the block has a parse error (uppercase triple), so the advisory
2811    /// is gated off entirely. Otherwise the misspelled `x86_64-PC-WINDOWS-MSVC`
2812    /// would silently "satisfy" msi and the warning would flip once the author
2813    /// fixed the typo.
2814    #[test]
2815    fn malformed_platform_triple_gates_off_cross_check() {
2816        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2817                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
2818                    platforms: [x86_64-PC-WINDOWS-MSVC]\n---\n";
2819        let n = norm(text);
2820        // The uppercase triple is a hard error → the document is invalid …
2821        assert!(!n.is_valid(), "expected a malformed-triple error");
2822        // … and the cross-check emitted no (misleading) installer/platform warning.
2823        assert!(
2824            !n.problems
2825                .warnings
2826                .iter()
2827                .any(|w| w.contains("nothing to install")),
2828            "cross-check must be gated off while platforms has errors: {:?}",
2829            n.problems.warnings
2830        );
2831    }
2832
2833    /// A distribution block setting EVERY known key carries an empty
2834    /// `extra_fields` map and emits no forward-compat warning — the additive field
2835    /// is shape-neutral for existing contracts. Exercising all of
2836    /// `KNOWN_DISTRIBUTION_KEYS` guards against the allowlist drifting out of sync
2837    /// with the struct (a new known key wrongly captured as "unknown").
2838    #[test]
2839    fn distribution_all_known_keys_has_empty_extra_fields() {
2840        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2841                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
2842                    installers: [shell, homebrew]\n  homebrew_tap: owner/tap\n  \
2843                    platforms: [x86_64-unknown-linux-musl]\n---\n";
2844        let n = norm(text);
2845        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2846        let d = n
2847            .contract
2848            .distributions
2849            .into_iter()
2850            .next()
2851            .expect("distribution present");
2852        assert!(d.extra_fields.is_empty());
2853        assert!(
2854            !n.problems
2855                .warnings
2856                .iter()
2857                .any(|w| w.contains("unknown distribution field(s) preserved")),
2858            "no forward-compat warning for an all-known-keys block: {:?}",
2859            n.problems.warnings
2860        );
2861    }
2862
2863    /// Top-level and nested `extra_fields` capture are independent: a contract
2864    /// with BOTH an unknown top-level key AND an unknown distribution sub-key
2865    /// populates both maps and warns once for each, with the correct
2866    /// `schema_version` in each message.
2867    #[test]
2868    fn distribution_and_top_level_extra_fields_coexist() {
2869        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2870                    roadmap_url: https://example.com/x\n\
2871                    distribution:\n  adapter: cargo-dist\n  future_x: 1\n---\n";
2872        let n = norm(text);
2873        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2874        let c = n.contract.clone();
2875        assert!(c.extra_fields.contains_key("roadmap_url"));
2876        let d = c
2877            .distributions
2878            .into_iter()
2879            .next()
2880            .expect("distribution present");
2881        assert_eq!(d.extra_fields.get("future_x"), Some(&serde_json::json!(1)));
2882        // Two independent forward-compat warnings, each naming schema_version 2
2883        // (the contract omits schema_version → defaults to KNOWN_SCHEMA_VERSION).
2884        let fc: Vec<&String> = n
2885            .problems
2886            .warnings
2887            .iter()
2888            .filter(|w| w.contains("forward-compat") && w.contains("schema_version 2"))
2889            .collect();
2890        assert_eq!(fc.len(), 2, "expected two versioned warnings: {fc:?}");
2891    }
2892
2893    // ── extra_fields capture hardening ───────────────────────────────────────
2894
2895    /// A non-string top-level mapping key (`42:`, legal YAML) is a STRUCTURAL
2896    /// error, not silently coerced/dropped: distinct non-string keys collapse onto
2897    /// the same JSON key (`42` and `"42"`; every list key onto `<list>`), so
2898    /// preserving them losslessly is impossible — the normalizer rejects instead,
2899    /// keeping the never-drop invariant vacuously.
2900    #[test]
2901    fn non_string_top_level_key_rejected() {
2902        let n = norm("---\nstatus: approved\nmaturity: mvp\n42: answer\n---\n");
2903        assert_error_contains(&n, "must be a string");
2904        assert!(
2905            n.problems.errors.iter().any(|e| e.contains("42")),
2906            "error should name the offending key: {:?}",
2907            n.problems.errors
2908        );
2909    }
2910
2911    /// The nested `distribution` scan rejects the same way, with the block scope in
2912    /// the message.
2913    #[test]
2914    fn non_string_distribution_key_rejected() {
2915        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2916                    distribution:\n  adapter: cargo-dist\n  true: enabled\n---\n";
2917        let n = norm(text);
2918        assert_error_contains(&n, "must be a string");
2919        assert!(
2920            n.problems
2921                .errors
2922                .iter()
2923                .any(|e| e.contains("distribution field key")),
2924            "error should be scoped to the distribution block: {:?}",
2925            n.problems.errors
2926        );
2927    }
2928
2929    /// A known key placed normally is parsed as its field and NOT double-captured
2930    /// into `extra_fields` — the dedupe guarantee (a key is never both a known
2931    /// field and an extra field).
2932    #[test]
2933    fn known_key_not_double_captured() {
2934        let n = norm("---\nstatus: approved\nmaturity: production\necosystems: [rust]\n---\n");
2935        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2936        assert!(!n.contract.extra_fields.contains_key("ecosystems"));
2937        assert!(!n.contract.extra_fields.contains_key("status"));
2938        assert!(n.contract.extra_fields.is_empty());
2939    }
2940
2941    /// The reserved `extra_fields` metadata key is not re-captured into a nested
2942    /// `extra_fields.extra_fields`; its mapping contents are MERGED back, so a
2943    /// hand-authored (or defensively re-fed canonical) block round-trips losslessly
2944    /// rather than being silently dropped. The derived `warnings` key is ignored
2945    /// (regenerated), not preserved — it is not user contract data.
2946    #[test]
2947    fn reserved_extra_fields_block_merged_warnings_ignored() {
2948        let text = "---\nstatus: approved\nmaturity: mvp\n\
2949                    extra_fields:\n  foo: 1\nwarnings:\n  - a prior note\n---\n";
2950        let n = norm(text);
2951        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2952        // `foo` is preserved (merged), not nested and not dropped.
2953        assert_eq!(
2954            n.contract.extra_fields.get("foo"),
2955            Some(&serde_json::json!(1))
2956        );
2957        assert!(!n.contract.extra_fields.contains_key("extra_fields"));
2958        // The stale input `warnings` list is not resurrected into the output.
2959        assert!(
2960            !n.contract
2961                .warnings
2962                .iter()
2963                .any(|w| w.contains("a prior note")),
2964            "input warnings must be regenerated, not preserved: {:?}",
2965            n.contract.warnings
2966        );
2967    }
2968
2969    /// The nested analogue: `distribution.extra_fields` is merged back, not nested
2970    /// and not dropped.
2971    #[test]
2972    fn distribution_reserved_extra_fields_block_merged() {
2973        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2974                    distribution:\n  adapter: cargo-dist\n  extra_fields:\n    foo: 1\n---\n";
2975        let n = norm(text);
2976        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2977        let d = n
2978            .contract
2979            .distributions
2980            .into_iter()
2981            .next()
2982            .expect("distribution present");
2983        assert_eq!(d.extra_fields.get("foo"), Some(&serde_json::json!(1)));
2984        assert!(!d.extra_fields.contains_key("extra_fields"));
2985    }
2986
2987    /// Idempotence: normalizing, serializing the canonical `extra_fields` map, and
2988    /// re-feeding it as an `extra_fields` block yields the identical map — the
2989    /// round-trip the reserve+merge design guarantees (no nesting, no loss).
2990    #[test]
2991    fn extra_fields_round_trip_is_idempotent() {
2992        let first = norm("---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://x/y\n---\n");
2993        assert!(first.is_valid(), "errors: {:?}", first.problems.errors);
2994        assert_eq!(first.contract.extra_fields.len(), 1);
2995        // Feed the captured extra_fields back under the reserved key.
2996        let inner = serde_yaml::to_string(&first.contract.extra_fields).unwrap();
2997        let indented = inner
2998            .lines()
2999            .map(|l| format!("  {l}"))
3000            .collect::<Vec<_>>()
3001            .join("\n");
3002        let text =
3003            format!("---\nstatus: approved\nmaturity: mvp\nextra_fields:\n{indented}\n---\n");
3004        let second = norm(&text);
3005        assert!(second.is_valid(), "errors: {:?}", second.problems.errors);
3006        assert_eq!(second.contract.extra_fields, first.contract.extra_fields);
3007    }
3008
3009    /// A key present BOTH inside the reserved `extra_fields` block AND as a sibling
3010    /// unknown top-level key is an ambiguity error — never a silent overwrite of
3011    /// either value (dedupe: a key resolves to exactly one source).
3012    #[test]
3013    fn extra_fields_block_sibling_collision_is_error() {
3014        let text = "---\nstatus: approved\nmaturity: mvp\n\
3015                    extra_fields:\n  dup: 1\ndup: 2\n---\n";
3016        let n = norm(text);
3017        assert_error_contains(&n, "appears both");
3018    }
3019
3020    /// A reserved `extra_fields` value that is not a mapping is a structural error
3021    /// (it can only carry preserved key/value pairs).
3022    #[test]
3023    fn reserved_extra_fields_non_mapping_is_error() {
3024        let n = norm("---\nstatus: approved\nmaturity: mvp\nextra_fields: nonsense\n---\n");
3025        assert_error_contains(&n, "must be a mapping");
3026    }
3027
3028    /// A contract setting EVERY parsed top-level known key carries an empty
3029    /// `extra_fields` and emits no forward-compat warning — the top-level analogue
3030    /// of `distribution_all_known_keys_has_empty_extra_fields`, guarding
3031    /// [`KNOWN_KEYS`] against drifting out of sync with the [`Contract`] struct (a
3032    /// new field whose key is missing here would be wrongly captured as unknown).
3033    #[test]
3034    fn top_level_all_known_keys_has_empty_extra_fields() {
3035        let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
3036                    ecosystems: [rust]\n\
3037                    targets:\n  - {ecosystem: rust, package: x, registry: crates.io, adapter: cargo-publish}\n\
3038                    distribution:\n  adapter: cargo-dist\n\
3039                    versioning: semver\n\
3040                    changelog:\n  mode: curated\n  source: manual\n\
3041                    conventional_commits: false\n\
3042                    release:\n  model: gated\n  layout: single\n\
3043                    contribution_provenance: none\n\
3044                    provenance_level: none\n\
3045                    dependency_bot: dependabot\n\
3046                    health_badges: [ci, registry, license]\n\
3047                    license: MIT\n\
3048                    docs_site: none\n---\n";
3049        let n = norm(text);
3050        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3051        assert!(
3052            n.contract.extra_fields.is_empty(),
3053            "unexpected extra_fields (KNOWN_KEYS drift?): {:?}",
3054            n.contract.extra_fields
3055        );
3056        assert!(
3057            !n.problems
3058                .warnings
3059                .iter()
3060                .any(|w| w.contains("forward-compat")),
3061            "no forward-compat warning for an all-known-keys contract: {:?}",
3062            n.problems.warnings
3063        );
3064    }
3065
3066    /// Installers de-dup into canonical order regardless of source order.
3067    #[test]
3068    fn distribution_installers_dedup_canonical_order() {
3069        let text = "---\nstatus: approved\nmaturity: mvp\n\
3070                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, shell, homebrew]\n  \
3071                    homebrew_tap: owner/tap\n---\n";
3072        let d = norm(text)
3073            .contract
3074            .distributions
3075            .into_iter()
3076            .next()
3077            .unwrap();
3078        assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
3079    }
3080
3081    /// A `homebrew` installer without a tap is a floor error.
3082    #[test]
3083    fn distribution_homebrew_installer_requires_tap() {
3084        let text = "---\nstatus: approved\nmaturity: mvp\n\
3085                    distribution:\n  adapter: cargo-dist\n  installers: [shell, homebrew]\n---\n";
3086        assert_error_contains(
3087            &norm(text),
3088            "includes 'homebrew' but no distribution.homebrew_tap",
3089        );
3090    }
3091
3092    /// A malformed tap slug (not `owner/repo`) is rejected AND, because the
3093    /// invalid value substitutes `None`, the homebrew-needs-tap floor still fires
3094    /// — a present-but-invalid tap must not slip a `homebrew` installer through.
3095    #[test]
3096    fn distribution_bad_tap_slug_rejected() {
3097        let text = "---\nstatus: approved\nmaturity: mvp\n\
3098                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
3099                    homebrew_tap: not-a-slug\n---\n";
3100        let n = norm(text);
3101        assert_error_contains(&n, "must be an 'owner/repo' slug");
3102        assert!(
3103            n.problems
3104                .errors
3105                .iter()
3106                .any(|e| e.contains("includes 'homebrew' but no distribution.homebrew_tap")),
3107            "the tap floor must still fire on an invalid (→None) tap: {:?}",
3108            n.problems.errors
3109        );
3110        // The malformed slug never leaks into the built block.
3111        assert_eq!(
3112            n.contract
3113                .distributions
3114                .into_iter()
3115                .next()
3116                .unwrap()
3117                .homebrew_tap,
3118            None
3119        );
3120    }
3121
3122    /// An unknown installer flavor surfaces an error listing the valid set.
3123    #[test]
3124    fn distribution_bad_installer_rejected() {
3125        let text = "---\nstatus: approved\nmaturity: mvp\n\
3126                    distribution:\n  adapter: cargo-dist\n  installers: [snap]\n---\n";
3127        assert_error_contains(&norm(text), "distribution.installers");
3128    }
3129
3130    /// `adapter` is required when a distribution block is present — a bare
3131    /// `distribution: {}` must not silently claim cargo-dist ownership.
3132    #[test]
3133    fn distribution_adapter_is_required() {
3134        assert_error_contains(
3135            &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: {}\n---\n"),
3136            "distribution.adapter is required",
3137        );
3138    }
3139
3140    /// A distribution block ships public binaries — forbidden at maturity 'spike'
3141    /// (mirrors the `release.model: auto`-on-spike floor).
3142    #[test]
3143    fn distribution_forbidden_on_spike() {
3144        let text = "---\nstatus: approved\nmaturity: spike\n\
3145                    distribution:\n  adapter: cargo-dist\n---\n";
3146        assert_error_contains(&norm(text), "not allowed on maturity 'spike'");
3147    }
3148
3149    /// A `homebrew_tap` set with neither a `homebrew` installer nor a
3150    /// `homebrew`-registry target is dead config — a warning, not a floor (the
3151    /// contract is still valid). This is the genuinely-orphaned tap: no consumer
3152    /// exists, so the tap is truly never updated.
3153    #[test]
3154    fn distribution_tap_without_installer_warns() {
3155        let text = "---\nstatus: approved\nmaturity: mvp\n\
3156                    distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  \
3157                    homebrew_tap: owner/tap\n---\n";
3158        let n = norm(text);
3159        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3160        assert!(
3161            n.problems
3162                .warnings
3163                .iter()
3164                .any(|w| w.contains("no formula is generated, so the tap will never be updated")),
3165            "expected dead-tap warning: {:?}",
3166            n.problems.warnings
3167        );
3168    }
3169
3170    /// A `homebrew_tap` set with NO `homebrew` installer but WITH a
3171    /// `homebrew`-registry target (the release engine's homebrew-tap adapter, which
3172    /// pushes the formula in its `dist` phase) is NOT dead config — the tap IS
3173    /// updated by the engine, so the dead-config warning must NOT fire. This is
3174    /// ossctl's own (correct) contract shape.
3175    #[test]
3176    fn distribution_tap_with_homebrew_target_no_warning() {
3177        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
3178                    targets:\n  \
3179                    - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n  \
3180                    - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n\
3181                    distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  \
3182                    homebrew_tap: owner/tap\n---\n";
3183        let n = norm(text);
3184        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3185        // Stronger than a negative substring match: the whole contract is clean,
3186        // so it must produce NO warnings at all — this also catches any reworded
3187        // dead-tap advisory that a substring check would miss.
3188        assert!(
3189            n.problems.warnings.is_empty(),
3190            "homebrew-target contract must not warn: {:?}",
3191            n.problems.warnings
3192        );
3193    }
3194
3195    /// A goreleaser distribution with no installers and no tap is valid — the
3196    /// block is minimal and forward-compatible.
3197    #[test]
3198    fn distribution_goreleaser_minimal_is_valid() {
3199        let text = "---\nstatus: approved\nmaturity: production\necosystems: [go]\n\
3200                    distribution:\n  adapter: goreleaser\n  gh_releases: true\n---\n";
3201        let n = norm(text);
3202        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3203        let d = n.contract.distributions.into_iter().next().unwrap();
3204        assert_eq!(d.adapter, DistributionAdapter::Goreleaser);
3205        assert!(d.installers.is_empty());
3206        assert_eq!(d.homebrew_tap, None);
3207    }
3208
3209    /// A non-mapping `distribution` value is a structural error.
3210    #[test]
3211    fn distribution_non_mapping_rejected() {
3212        assert_error_contains(
3213            &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: [nope]\n---\n"),
3214            "distribution must be a mapping",
3215        );
3216    }
3217
3218    // ── distribution.platforms (cross-platform target set) ───────────────────
3219
3220    /// Helper: does a platform list contain any Linux triple? The cross-platform
3221    /// install requirement is "at least one Linux triple", inspected via the OS
3222    /// component of the triple (exactly how `audit` will read this field).
3223    fn has_linux(platforms: &[String]) -> bool {
3224        platforms.iter().any(|t| t.contains("-linux"))
3225    }
3226
3227    /// Omitted `platforms` → the cross-platform default (macOS + Linux). The
3228    /// KEYSTONE assertion: the DEFAULT covers Linux, so every distribution that
3229    /// omits the field does (an explicit set is the author's own choice, which the
3230    /// cross-platform `audit` — not this normalizer — checks).
3231    #[test]
3232    fn distribution_platforms_default_is_cross_platform() {
3233        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3234                    distribution:\n  adapter: cargo-dist\n---\n";
3235        let d = norm(text)
3236            .contract
3237            .distributions
3238            .into_iter()
3239            .next()
3240            .expect("distribution present");
3241        assert_eq!(
3242            d.platforms,
3243            vec![
3244                "aarch64-apple-darwin",
3245                "x86_64-apple-darwin",
3246                "aarch64-unknown-linux-musl",
3247                "x86_64-unknown-linux-musl",
3248            ]
3249        );
3250        assert!(
3251            has_linux(&d.platforms),
3252            "the default set MUST contain a Linux triple: {:?}",
3253            d.platforms
3254        );
3255    }
3256
3257    /// An explicit `platforms` list round-trips through normalization and the
3258    /// serialized JSON downstream members read, order + values preserved.
3259    #[test]
3260    fn distribution_platforms_explicit_round_trips() {
3261        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3262                    distribution:\n  adapter: cargo-dist\n  \
3263                    platforms: [x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc]\n---\n";
3264        let n = norm(text);
3265        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3266        let d = n.contract.clone().distributions.into_iter().next().unwrap();
3267        assert_eq!(
3268            d.platforms,
3269            vec!["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
3270        );
3271        let json = serde_json::to_value(&n.contract).unwrap();
3272        assert_eq!(
3273            json["distributions"][0]["platforms"],
3274            serde_json::json!(["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"])
3275        );
3276    }
3277
3278    /// An explicit empty `platforms: []` is a hard error — NOT silently defaulted.
3279    /// Only an omitted/null field yields the cross-platform default; an empty list
3280    /// is a mistake (a distribution with no platforms builds nothing) and, if
3281    /// silently defaulted, would surprise the author and erase the intent the
3282    /// cross-platform audit needs to see.
3283    #[test]
3284    fn distribution_platforms_empty_is_rejected() {
3285        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3286                    distribution:\n  adapter: cargo-dist\n  platforms: []\n---\n";
3287        assert_error_contains(&norm(text), "empty list — omit the key");
3288    }
3289
3290    /// Duplicate triples de-duplicate, preserving first-seen order.
3291    #[test]
3292    fn distribution_platforms_dedup_preserves_order() {
3293        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3294                    distribution:\n  adapter: cargo-dist\n  \
3295                    platforms: [aarch64-apple-darwin, x86_64-apple-darwin, aarch64-apple-darwin]\n---\n";
3296        let d = norm(text)
3297            .contract
3298            .distributions
3299            .into_iter()
3300            .next()
3301            .unwrap();
3302        assert_eq!(
3303            d.platforms,
3304            vec!["aarch64-apple-darwin", "x86_64-apple-darwin"]
3305        );
3306    }
3307
3308    /// A malformed triple is rejected with a message naming the field.
3309    #[test]
3310    fn distribution_platforms_bad_triple_rejected() {
3311        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3312                    distribution:\n  adapter: cargo-dist\n  platforms: [not_a_triple]\n---\n";
3313        assert_error_contains(&norm(text), "is not a well-formed target-triple");
3314    }
3315
3316    /// A non-string entry (a nested list) is rejected structurally.
3317    #[test]
3318    fn distribution_platforms_non_string_entry_rejected() {
3319        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3320                    distribution:\n  adapter: cargo-dist\n  platforms: [[nope]]\n---\n";
3321        assert_error_contains(&norm(text), "each entry must be a target-triple string");
3322    }
3323
3324    /// A `platforms` value that is not a list is a structural error.
3325    #[test]
3326    fn distribution_platforms_non_list_rejected() {
3327        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3328                    distribution:\n  adapter: cargo-dist\n  platforms: x86_64-apple-darwin\n---\n";
3329        assert_error_contains(&norm(text), "must be a list of target-triple strings");
3330    }
3331
3332    /// Regression: a registry-only contract (no distribution block at all) is
3333    /// wholly unaffected by the additive `platforms` field — no distribution, so
3334    /// no `platforms` in the emitted shape.
3335    #[test]
3336    fn registry_only_contract_unaffected_by_platforms() {
3337        let json = serde_json::to_value(
3338            &norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract,
3339        )
3340        .unwrap();
3341        assert_eq!(json["distributions"], serde_json::json!([]));
3342    }
3343
3344    #[test]
3345    fn looks_like_target_triple_verdicts() {
3346        // Standard triples across arch/vendor/os/env shapes.
3347        assert!(looks_like_target_triple("aarch64-apple-darwin"));
3348        assert!(looks_like_target_triple("x86_64-apple-darwin"));
3349        assert!(looks_like_target_triple("x86_64-unknown-linux-musl"));
3350        assert!(looks_like_target_triple("x86_64-unknown-linux-gnu"));
3351        assert!(looks_like_target_triple("x86_64-pc-windows-msvc"));
3352        assert!(looks_like_target_triple("armv7-unknown-linux-gnueabihf"));
3353        assert!(looks_like_target_triple("wasm32-wasi"));
3354        // Real dotted arch names must pass (regression: the `.` was rejected).
3355        assert!(looks_like_target_triple("thumbv8m.main-none-eabi"));
3356        assert!(looks_like_target_triple("thumbv8m.base-none-eabi"));
3357        // Rejects: too few/many components, empty parts, case, punctuation.
3358        assert!(!looks_like_target_triple("linux"));
3359        assert!(!looks_like_target_triple("a-b-c-d-e"));
3360        assert!(!looks_like_target_triple("x86_64--linux"));
3361        assert!(!looks_like_target_triple("-apple-darwin"));
3362        assert!(!looks_like_target_triple("X86_64-apple-darwin"));
3363        assert!(!looks_like_target_triple("x86_64-apple-darwin;rm"));
3364        assert!(!looks_like_target_triple("x86_64 apple darwin"));
3365        assert!(!looks_like_target_triple(""));
3366        // Structural-only: nonsense that happens to be well-formed IS accepted —
3367        // the toolchain, not the contract, is the authority on buildability.
3368        assert!(looks_like_target_triple("aa-bb"));
3369    }
3370
3371    #[test]
3372    fn is_tap_slug_verdicts() {
3373        // Valid GitHub-style slugs.
3374        assert!(is_tap_slug("owner/repo"));
3375        assert!(is_tap_slug("jarimustonen/homebrew-issuectl"));
3376        assert!(is_tap_slug("Owner_1/repo.rb"));
3377        // Structural rejects.
3378        assert!(!is_tap_slug("no-slash"));
3379        assert!(!is_tap_slug("/repo"));
3380        assert!(!is_tap_slug("owner/"));
3381        assert!(!is_tap_slug("owner/repo/extra"));
3382        assert!(!is_tap_slug("owner / repo"));
3383        // Strict-charset rejects: path traversal, punctuation, injection chars.
3384        assert!(!is_tap_slug("owner/.."));
3385        assert!(!is_tap_slug("../repo"));
3386        assert!(!is_tap_slug("owner/repo;rm -rf"));
3387        assert!(!is_tap_slug("owner/@repo"));
3388        assert!(!is_tap_slug("ownér/repo"));
3389    }
3390
3391    /// `quote_for_diagnostic` JSON-encodes: quotes/backslashes/newlines/control
3392    /// chars are escaped, ordinary text stays readable.
3393    #[test]
3394    fn quote_for_diagnostic_escapes_hostile_input() {
3395        assert_eq!(quote_for_diagnostic("foo"), "\"foo\"");
3396        assert_eq!(quote_for_diagnostic("a\"b"), "\"a\\\"b\"");
3397        assert_eq!(quote_for_diagnostic("a\nb"), "\"a\\nb\"");
3398        assert_eq!(quote_for_diagnostic("a\tb"), "\"a\\tb\"");
3399        // A bare C0 control char (0x01) escapes to , never a raw byte.
3400        assert_eq!(quote_for_diagnostic("\u{1}"), "\"\\u0001\"");
3401    }
3402
3403    /// Log-injection hardening: a user-controlled unknown-field KEY carrying a
3404    /// quote, newline, and control char cannot forge a diagnostic line or emit a
3405    /// raw control char — it is JSON-encoded onto a single intact line.
3406    #[test]
3407    fn unknown_field_key_is_escaped_in_warning() {
3408        // The key is `evil"key` + newline + a forged-looking line + a control char.
3409        // Quoted in YAML so the literal quote/newline/control byte are the KEY text.
3410        let text =
3411            "---\nstatus: approved\nmaturity: mvp\n\"evil\\\"key\\nforged: line\\u0001\": 1\n---\n";
3412        let n = norm(text);
3413        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3414        let warning = n
3415            .problems
3416            .warnings
3417            .iter()
3418            .find(|w| w.contains("unknown field(s) preserved"))
3419            .expect("expected an unknown-field warning");
3420        // The raw quote/newline/control char never appear unescaped in the message:
3421        // no forged second line, no bare control byte.
3422        assert!(
3423            !warning.contains('\n'),
3424            "warning must stay on one line: {warning:?}"
3425        );
3426        assert!(
3427            !warning.contains('\u{1}'),
3428            "warning must not carry a raw control char: {warning:?}"
3429        );
3430        assert!(
3431            !warning.contains("evil\"key"),
3432            "the raw unescaped key must not appear: {warning:?}"
3433        );
3434        // The escaped JSON form is present (quote → \", newline → \n, ctrl → ).
3435        assert!(
3436            warning.contains("\\\"") && warning.contains("\\n") && warning.contains("\\u0001"),
3437            "the key must be JSON-escaped: {warning:?}"
3438        );
3439    }
3440
3441    /// The same hardening on a user-controlled VALUE routed through `yaml_display`
3442    /// (an invalid enum): a newline in the rejected value cannot forge an error
3443    /// line.
3444    #[test]
3445    fn invalid_enum_value_is_escaped_in_error() {
3446        let text = "---\nstatus: approved\nmaturity: \"mvp\\nforged: line\"\n---\n";
3447        let n = norm(text);
3448        assert_error_contains(&n, "maturity");
3449        let err = n
3450            .problems
3451            .errors
3452            .iter()
3453            .find(|e| e.contains("maturity") && e.contains("invalid"))
3454            .expect("expected a maturity-invalid error");
3455        assert!(!err.contains('\n'), "error must stay on one line: {err:?}");
3456        assert!(
3457            err.contains("\\n"),
3458            "the rejected value's newline must be escaped: {err:?}"
3459        );
3460    }
3461}