Skip to main content

shipshape_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::facts::{detect_distribution_surface, CargoPublishPolicy};
28use crate::ports::Fs;
29use crate::release::distribution::{
30    find_undeclared_distribution, undeclared_distribution_warnings,
31};
32
33/// The contract file the normalizer reads, relative to the repo root.
34pub const CONTRACT_FILENAME: &str = "OSS-RELEASE.md";
35
36/// The optional cargo-dist configuration checked for a Homebrew-release drift.
37const DIST_WORKSPACE_FILENAME: &str = "dist-workspace.toml";
38
39/// Canonical ecosystem order — used to de-duplicate and stably order the
40/// `ecosystems` list (mirrors the Python `VALID_ECOSYSTEMS` ordered list).
41const ECOSYSTEM_ORDER: [Ecosystem; 5] = [
42    Ecosystem::Rust,
43    Ecosystem::Node,
44    Ecosystem::Python,
45    Ecosystem::Go,
46    Ecosystem::Binary,
47];
48
49/// Known top-level frontmatter keys; anything else is preserved under
50/// [`Contract::extra_fields`] (forward-compat).
51///
52/// **Invariant:** this list MUST stay in sync with the [`Contract`] struct
53/// fields — every parsed field has its source key here. A field added to
54/// [`Contract`] without its key here would be captured as an "unknown" field on
55/// input; the `all_known_keys_*` tests guard against that drift.
56///
57/// The two trailing entries — `extra_fields` and `warnings` — are the canonical
58/// *output* metadata keys, reserved here so canonical JSON (which carries them)
59/// re-fed to the normalizer as YAML does NOT re-capture them into a nested
60/// `extra_fields.extra_fields` on each pass. They are handled asymmetrically:
61/// `extra_fields`'s mapping contents are merged back into the captured map (see
62/// [`capture_unknown_fields`]) so the block round-trips losslessly; `warnings` is
63/// derived diagnostic output, regenerated every pass, so any input value under it
64/// is intentionally ignored (not preserved — it is not user contract data).
65const KNOWN_KEYS: &[&str] = &[
66    "schema_version",
67    "status",
68    "maturity",
69    "ecosystems",
70    "targets",
71    // Both distribution input keys are known: `distribution` (a single mapping,
72    // v1 back-compat) and `distributions` (a sequence, the monorepo shape). See
73    // [`parse_distributions`]; declaring both is an error, not an unknown-field.
74    "distribution",
75    "distributions",
76    "versioning",
77    "changelog",
78    "conventional_commits",
79    "release",
80    "contribution_provenance",
81    "provenance_level",
82    "dependency_bot",
83    "health_badges",
84    "license",
85    "docs_site",
86    // Reserved canonical-output metadata keys (not parsed) — see doc above.
87    "extra_fields",
88    "warnings",
89];
90
91/// Collected fatal errors and non-fatal warnings from a normalization pass.
92#[derive(Debug, Default)]
93pub struct Problems {
94    /// Fatal validation errors; a non-empty list means the config would not
95    /// normalize (the CLI exits non-zero with the §10 error envelope).
96    pub errors: Vec<String>,
97    /// Non-fatal notes (aspirational draft producers, the unknown-field report).
98    pub warnings: Vec<String>,
99}
100
101impl Problems {
102    fn err(&mut self, msg: String) {
103        self.errors.push(msg);
104    }
105
106    fn warn(&mut self, msg: String) {
107        self.warnings.push(msg);
108    }
109}
110
111/// The result of a normalization pass: the canonical [`Contract`] plus the
112/// [`Problems`] gathered while building it.
113#[derive(Debug)]
114pub struct Normalized {
115    /// The canonical contract. Only meaningful when [`Self::is_valid`] holds.
116    pub contract: Contract,
117    /// Errors and warnings gathered during normalization.
118    pub problems: Problems,
119}
120
121impl Normalized {
122    /// Whether the config normalized cleanly (no fatal errors).
123    #[must_use]
124    pub fn is_valid(&self) -> bool {
125        self.problems.errors.is_empty()
126    }
127}
128
129/// Why the contract file could not be loaded (distinct from a *validation*
130/// failure, which is carried by [`Problems`]). Maps to a §2 exit-2 system error.
131#[derive(Debug)]
132pub enum LoadError {
133    /// No `OSS-RELEASE.md` at the expected path.
134    NotFound(PathBuf),
135    /// The file exists but could not be read.
136    Io(PathBuf, io::Error),
137    /// The file is not valid UTF-8.
138    Utf8(PathBuf),
139}
140
141impl std::fmt::Display for LoadError {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        match self {
144            Self::NotFound(p) => write!(
145                f,
146                "no {CONTRACT_FILENAME} at {} (run /shipshape-init to generate one)",
147                p.display()
148            ),
149            Self::Io(p, e) => write!(f, "cannot read {}: {e}", p.display()),
150            Self::Utf8(p) => write!(f, "{} is not valid UTF-8", p.display()),
151        }
152    }
153}
154
155/// Read `<repo_root>/OSS-RELEASE.md` through the [`Fs`] port and normalize it.
156///
157/// # Errors
158/// Returns [`LoadError`] when the file is missing, unreadable, or not UTF-8. A
159/// *validation* failure is not an error here — it is carried in the returned
160/// [`Normalized::problems`]; check [`Normalized::is_valid`].
161pub fn normalize(repo_root: &Path, fs: &dyn Fs) -> Result<Normalized, LoadError> {
162    let path = repo_root.join(CONTRACT_FILENAME);
163    let bytes = fs.read(&path).map_err(|e| match e.kind() {
164        io::ErrorKind::NotFound => LoadError::NotFound(path.clone()),
165        _ => LoadError::Io(path.clone(), e),
166    })?;
167    let text = String::from_utf8(bytes).map_err(|_| LoadError::Utf8(path.clone()))?;
168    Ok(normalize_str(&text, repo_root, fs))
169}
170
171/// Normalize the full text of an `OSS-RELEASE.md` (frontmatter + body).
172///
173/// Split from [`normalize`] so tests can exercise the pipeline on a string
174/// without a real file. `repo_root` and `fs` are still needed for the
175/// filesystem-dependent floors (the fragment-dir path floor and its advisory
176/// existence check).
177#[must_use]
178pub fn normalize_str(text: &str, repo_root: &Path, fs: &dyn Fs) -> Normalized {
179    let mut p = Problems::default();
180    let map = match split_frontmatter(text, &mut p) {
181        Some(fm) => parse_frontmatter(&fm, &mut p),
182        None => Mapping::new(),
183    };
184    let contract = build(&map, &mut p, repo_root, fs);
185    Normalized {
186        contract,
187        problems: p,
188    }
189}
190
191/// Read a required enum field, or record an error and fall back to `default`.
192/// Absent → `default` silently; present-but-invalid → error + `default`
193/// (matching the Python default-substitution behavior).
194macro_rules! enum_field {
195    ($map:expr, $key:expr, $ty:ty, $default:expr, $p:expr) => {{
196        match $map.get($key) {
197            None => $default,
198            Some(v) => match v.as_str().and_then(<$ty>::parse) {
199                Some(x) => x,
200                None => {
201                    $p.err(format!(
202                        "{} {} invalid — must be one of {:?}",
203                        $key,
204                        yaml_display(v),
205                        <$ty>::VALID
206                    ));
207                    $default
208                }
209            },
210        }
211    }};
212}
213
214#[allow(clippy::too_many_lines)]
215fn build(map: &Mapping, p: &mut Problems, repo_root: &Path, fs: &dyn Fs) -> Contract {
216    // schema_version — validate the DECLARED version (a too-new config is a hard
217    // stop, a sub-1 or non-integer is an error), but do NOT echo it: the canonical
218    // output is ALWAYS the current shape, so the emitted `schema_version` is
219    // KNOWN_SCHEMA_VERSION regardless of what the (older, still-readable) document
220    // declared. Echoing the declared version would stamp a canonical v2 body with a
221    // v1 number — a mislabeled, self-inconsistent shape a strict consumer cannot
222    // trust. The tool reads a v1 `distribution:` mapping and emits the v2
223    // `distributions: [...]` shape under `schema_version: 2`.
224    match map.get("schema_version") {
225        None => {}
226        Some(v) => match v.as_i64() {
227            Some(n) if n > i64::from(KNOWN_SCHEMA_VERSION) => p.err(format!(
228                "schema_version {n} exceeds what this tool knows ({KNOWN_SCHEMA_VERSION}); \
229                 upgrade the OSS-release skills before reading this config (refusing rather \
230                 than guessing)."
231            )),
232            Some(n) if n < 1 => p.err(format!("schema_version {n} is invalid (must be >= 1)")),
233            Some(_) => {}
234            None => p.err(format!(
235                "schema_version must be an integer, got {}",
236                yaml_display(v)
237            )),
238        },
239    }
240    let schema_version = KNOWN_SCHEMA_VERSION;
241
242    let status = enum_field!(map, "status", Status, Status::Draft, p);
243
244    // maturity — required (inference is /shipshape-init's job, not the normalizer's).
245    let maturity = match map.get("maturity") {
246        None => {
247            p.err(
248                "maturity is required (spike|mvp|production) — /shipshape-init infers it"
249                    .to_string(),
250            );
251            Maturity::Mvp
252        }
253        Some(v) => {
254            if let Some(m) = v.as_str().and_then(Maturity::parse) {
255                m
256            } else {
257                p.err(format!(
258                    "maturity {} invalid — must be one of {:?}",
259                    yaml_display(v),
260                    Maturity::VALID
261                ));
262                Maturity::Mvp
263            }
264        }
265    };
266
267    // ecosystems — validate, then de-dup into canonical order.
268    let mut parsed_ecos: Vec<Ecosystem> = Vec::new();
269    for item in as_list(map.get("ecosystems")) {
270        match item.as_str().and_then(Ecosystem::parse) {
271            Some(e) => parsed_ecos.push(e),
272            None => p.err(format!(
273                "ecosystems: {} invalid — must be one of {:?}",
274                yaml_display(&item),
275                Ecosystem::VALID
276            )),
277        }
278    }
279    let ecosystems: Vec<Ecosystem> = ECOSYSTEM_ORDER
280        .into_iter()
281        .filter(|e| parsed_ecos.contains(e))
282        .collect();
283
284    // versioning — split the base enum from the calver pattern.
285    let (versioning, versioning_pattern) = parse_versioning(map.get("versioning"), p);
286
287    // release (model + layout + optional bump_hook).
288    let (model, layout, bump_hook) = match map.get("release") {
289        None | Some(Value::Null) => (ReleaseModel::Gated, ReleaseLayout::Single, None),
290        Some(Value::Mapping(m)) => (
291            enum_field!(m, "model", ReleaseModel, ReleaseModel::Gated, p),
292            enum_field!(m, "layout", ReleaseLayout, ReleaseLayout::Single, p),
293            parse_bump_hook(m, p),
294        ),
295        Some(_) => {
296            p.err("release must be a mapping (model / layout / bump_hook)".to_string());
297            (ReleaseModel::Gated, ReleaseLayout::Single, None)
298        }
299    };
300
301    // targets — expand from ecosystems when the key is OMITTED; but an explicit
302    // empty list is the author's authoritative "never publish anywhere" and is
303    // honored as-is (not re-expanded). Distinguishing *absent* from *explicit
304    // empty* is the whole point: a version-tracked/changelogged repo with no
305    // registry publish (a private service deployed by its own script) must be
306    // expressible. An empty target set is a valid, honored state — every floor
307    // and downstream consumer already treats "no targets" gracefully (no
308    // registry-license floor, no `registry` health badge, "nothing to publish"
309    // in the release engine).
310    let targets = match map.get("targets") {
311        None | Some(Value::Null) => expand_targets(&ecosystems, layout),
312        Some(Value::Sequence(seq)) if seq.is_empty() => Vec::new(),
313        Some(Value::Sequence(seq)) => validate_targets(seq, &ecosystems, layout, p),
314        Some(_) => {
315            p.err(
316                "targets must be a list of {ecosystem, package?, registry, adapter?} maps"
317                    .to_string(),
318            );
319            Vec::new()
320        }
321    };
322
323    // distributions — the binary-distribution blocks (cargo-dist/goreleaser); a
324    // registry-only repo has none (→ empty list), leaving its contract shape
325    // unchanged. The homebrew cross-field truth table (tap ↔ installer-producer ↔
326    // target-producer) is enforced afterwards by [`check_homebrew_configuration`],
327    // once both `targets` and `distributions` are resolved.
328    let distributions = parse_distributions(map, &targets, schema_version, p);
329
330    // changelog (mode + source + fragment_dir).
331    let changelog = match map.get("changelog") {
332        None | Some(Value::Null) => Changelog {
333            mode: ChangelogMode::Curated,
334            source: ChangelogSource::Manual,
335            fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
336        },
337        Some(Value::Mapping(m)) => {
338            let mode = enum_field!(m, "mode", ChangelogMode, ChangelogMode::Curated, p);
339            let source = enum_field!(m, "source", ChangelogSource, ChangelogSource::Manual, p);
340            let fragment_dir = match m.get("fragment_dir") {
341                None => DEFAULT_FRAGMENT_DIR.to_string(),
342                Some(v) => {
343                    if let Some(s) = v.as_str() {
344                        s.to_string()
345                    } else {
346                        p.err("changelog.fragment_dir must be a string path".to_string());
347                        DEFAULT_FRAGMENT_DIR.to_string()
348                    }
349                }
350            };
351            Changelog {
352                mode,
353                source,
354                fragment_dir,
355            }
356        }
357        Some(_) => {
358            p.err("changelog must be a mapping with mode/source".to_string());
359            Changelog {
360                mode: ChangelogMode::Curated,
361                source: ChangelogSource::Manual,
362                fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
363            }
364        }
365    };
366    // fragment_dir must be a relative path inside the repo (floor 6).
367    if !path_inside_repo(&changelog.fragment_dir) {
368        p.err(format!(
369            "floor: changelog.fragment_dir {} must be a relative path inside the repo (an \
370             absolute or '../'-escaping path is refused)",
371            quote_for_diagnostic(&changelog.fragment_dir)
372        ));
373    }
374
375    // conventional_commits.
376    let conventional_commits = match map.get("conventional_commits") {
377        None => false,
378        Some(Value::Bool(b)) => *b,
379        Some(v) => {
380            p.err(format!(
381                "conventional_commits must be true|false, got {}",
382                yaml_display(v)
383            ));
384            false
385        }
386    };
387
388    let contribution_provenance = enum_field!(
389        map,
390        "contribution_provenance",
391        ContributionProvenance,
392        ContributionProvenance::None,
393        p
394    );
395    let provenance_level = enum_field!(
396        map,
397        "provenance_level",
398        ProvenanceLevel,
399        ProvenanceLevel::None,
400        p
401    );
402
403    let dep_default = if maturity == Maturity::Spike {
404        DependencyBot::None
405    } else {
406        DependencyBot::Dependabot
407    };
408    let dependency_bot = enum_field!(map, "dependency_bot", DependencyBot, dep_default, p);
409
410    // license — a valid SPDX expression when set (default MIT).
411    let license = match map.get("license") {
412        None => "MIT".to_string(),
413        Some(v) => match v.as_str() {
414            Some(s) if !s.trim().is_empty() => {
415                if !spdx_valid(s) {
416                    p.err(format!(
417                        "license {} is not a valid SPDX expression (unknown id or malformed \
418                         AND/OR/WITH grammar)",
419                        quote_for_diagnostic(s)
420                    ));
421                }
422                s.to_string()
423            }
424            _ => {
425                p.err("license must be a non-empty SPDX id/expression (default MIT)".to_string());
426                "MIT".to_string()
427            }
428        },
429    };
430
431    let docs_site = enum_field!(map, "docs_site", DocsSite, DocsSite::None, p);
432
433    // health_badges — validate when present (key-presence, per Python), else
434    // materialize a floor-clean default (maturity/target aware).
435    let health_badges = if map.contains_key("health_badges") {
436        let mut out = Vec::new();
437        for item in as_list(map.get("health_badges")) {
438            match item.as_str().and_then(HealthBadge::parse) {
439                Some(hb) => out.push(hb),
440                None => p.err(format!(
441                    "health_badges: {} invalid — must be one of {:?}",
442                    yaml_display(&item),
443                    HealthBadge::VALID
444                )),
445            }
446        }
447        out
448    } else {
449        default_health_badges(maturity, &targets)
450    };
451
452    // ── Cross-field floors (§2) — config-internal, ALWAYS hard errors ────────
453    if model == ReleaseModel::Auto && maturity == Maturity::Spike {
454        p.err(
455            "floor: release.model 'auto' is not allowed on maturity 'spike' — a spike is not \
456             being published; raise maturity or set release.model: gated"
457                .to_string(),
458        );
459    }
460    if provenance_level == ProvenanceLevel::SlsaL3 && maturity != Maturity::Production {
461        p.err(format!(
462            "floor: provenance_level 'slsa-l3' is production-only — current maturity is '{}'",
463            maturity.as_str()
464        ));
465    }
466    // A target with a registry requires a valid SPDX license. Every expanded
467    // target carries a registry, so "any registry" reduces to "any target".
468    if !targets.is_empty() && !spdx_valid(&license) {
469        p.err(format!(
470            "floor: a target has a registry (crates.io/npm/PyPI/… require a license) but license \
471             {} is not a valid SPDX expression",
472            quote_for_diagnostic(&license)
473        ));
474    }
475    check_badge_producers(&health_badges, maturity, &targets, p);
476    // Homebrew cross-field consistency: missing-tap (either producer), the
477    // double-publish collision, and the dead-tap advisory — the full truth table.
478    check_homebrew_configuration(&targets, &distributions, p);
479    check_publisher_conflicts(&targets, p);
480    check_cargo_publish_evidence(&ecosystems, &targets, repo_root, fs, p);
481    check_dist_workspace_homebrew(&targets, &distributions, repo_root, fs, p);
482    let distribution_surface = detect_distribution_surface(repo_root, fs);
483    for warning in undeclared_distribution_warnings(&find_undeclared_distribution(
484        &targets,
485        &distribution_surface,
486        distributions
487            .iter()
488            .any(|d| d.homebrew_tap.is_some() && !d.installers.contains(&Installer::Homebrew)),
489    )) {
490        p.warn(warning);
491    }
492    // Publish-none must mean NOTHING is published, by anyone. A distribution block is
493    // a second, independent publish surface (GH-Release binaries, an installer, a tap
494    // formula) produced by a tag-triggered workflow — so `targets: []` next to a
495    // declared distribution is a self-contradiction with real consequences: the engine
496    // reads the empty target set as a TAG-ONLY cut, pushes the tag, and reports
497    // "published nothing" while that very tag triggers cargo-dist/goreleaser to publish
498    // binaries the run never planned, journalled, or verified. This floor is what makes
499    // `targets.is_empty()` a sound publish-none discriminator for the coordinator.
500    if targets.is_empty() && !distributions.is_empty() {
501        p.err(
502            "floor: a distribution block declares a binary publish surface (GitHub Release \
503             artifacts / installers / a tap formula) but targets is empty — the two contradict \
504             each other: the release engine would treat the empty target set as publish-none and \
505             cut a TAG-ONLY release, while the pushed tag triggers the distribution's workflow to \
506             publish anyway, unplanned and unverified. Declare the distribution's target (e.g. \
507             {ecosystem, package, registry: gh-releases, adapter: cargo-dist}) or drop the \
508             distribution block for a genuine publish-none contract"
509                .to_string(),
510        );
511    }
512    // A distribution block ships public binaries (GH-Release artifacts, a curl-pipe
513    // installer, a Homebrew tap PR) — that is publishing, and a spike is not being
514    // published. Mirrors the `release.model: auto` floor: raise maturity or drop the
515    // block. (No blocks → no constraint; registry-only spikes are unaffected.)
516    if !distributions.is_empty() && maturity == Maturity::Spike {
517        p.err(
518            "floor: a distribution block ships public binaries (installer + tap) — not allowed on \
519             maturity 'spike' (a spike is not being published); raise maturity or drop distribution"
520                .to_string(),
521        );
522    }
523
524    // ── Filesystem/producer-existence semantic check — ADVISORY, never fatal ─
525    if changelog.mode == ChangelogMode::Fragment
526        && path_inside_repo(&changelog.fragment_dir)
527        && !fs.is_dir(&repo_root.join(&changelog.fragment_dir))
528    {
529        p.warn(format!(
530            "changelog.mode 'fragment' but the fragment dir {} does not exist yet under {} — \
531             /shipshape-changelog creates it; /shipshape-readiness reports it as a gap until then",
532            quote_for_diagnostic(&changelog.fragment_dir),
533            repo_root.display()
534        ));
535    }
536
537    // ── Forward-compat: preserve unknown fields, report once ─────────────────
538    let extra_fields =
539        capture_unknown_fields(map, KNOWN_KEYS, CaptureScope::TopLevel, schema_version, p);
540
541    let warnings = p.warnings.clone();
542    Contract {
543        schema_version,
544        status,
545        maturity,
546        ecosystems,
547        targets,
548        distributions,
549        versioning,
550        versioning_pattern,
551        changelog,
552        conventional_commits,
553        release: Release {
554            model,
555            layout,
556            bump_hook,
557        },
558        contribution_provenance,
559        provenance_level,
560        dependency_bot,
561        health_badges,
562        license,
563        docs_site,
564        extra_fields,
565        warnings,
566    }
567}
568
569/// Warn when cargo-dist configures Homebrew but the release contract cannot plan it.
570///
571/// `OSS-RELEASE.md` remains authoritative: cargo-dist's config is only a
572/// best-effort drift signal. A missing, unreadable, invalid, or irrelevant file
573/// therefore produces no diagnostic and never changes validation's success.
574fn check_dist_workspace_homebrew(
575    targets: &[Target],
576    distributions: &[Distribution],
577    repo_root: &Path,
578    fs: &dyn Fs,
579    p: &mut Problems,
580) {
581    let path = repo_root.join(DIST_WORKSPACE_FILENAME);
582    let Ok(bytes) = fs.read(&path) else {
583        return;
584    };
585    let Ok(text) = String::from_utf8(bytes) else {
586        return;
587    };
588    let Ok(config) = text.parse::<toml::Value>() else {
589        return;
590    };
591    let Some(dist) = config.get("dist").and_then(toml::Value::as_table) else {
592        return;
593    };
594
595    let configured_tap = dist
596        .get("tap")
597        .and_then(toml::Value::as_str)
598        .filter(|tap| !tap.trim().is_empty());
599    let has_tap = configured_tap.is_some();
600    let has_homebrew_publish_job = dist
601        .get("publish-jobs")
602        .and_then(toml::Value::as_array)
603        .is_some_and(|jobs| {
604            jobs.iter()
605                .filter_map(toml::Value::as_str)
606                .any(|job| job == "homebrew")
607        });
608
609    let contract_tap = distributions.iter().find_map(|d| d.homebrew_tap.as_deref());
610    let has_delegated_homebrew_target = targets.iter().any(|target| {
611        target.registry == Registry::Homebrew && target.adapter == Adapter::CargoDist
612    });
613    if has_homebrew_publish_job && !has_delegated_homebrew_target {
614        p.err(
615            "floor: dist-workspace.toml publish-jobs includes 'homebrew', but the contract has \
616             no delegated Homebrew target — cargo-dist would write a formula that the verify \
617             barrier never observes. Add a target with registry 'homebrew' and adapter \
618             'cargo-dist', or remove cargo-dist's Homebrew publish job"
619                .to_string(),
620        );
621    }
622    if has_homebrew_publish_job
623        && targets.iter().any(|target| {
624            target.registry == Registry::Homebrew && target.adapter == Adapter::HomebrewTap
625        })
626    {
627        p.err(
628            "floor: dist-workspace.toml publish-jobs includes 'homebrew', but the contract's \
629             Homebrew target uses adapter 'homebrew-tap' — cargo-dist CI and shipshape would both \
630             write the same tap. Change that target to adapter 'cargo-dist' so the engine \
631             delegates the write and verifies the formula, or remove cargo-dist's Homebrew \
632             publish job"
633                .to_string(),
634        );
635    }
636    if (has_tap || has_homebrew_publish_job) && contract_tap.is_none() {
637        p.warn(
638            "dist-workspace.toml configures Homebrew, but the contract omits \
639             distribution.homebrew_tap; the Homebrew leg will not be planned. Add \
640             distribution.homebrew_tap: owner/repo to OSS-RELEASE.md"
641                .to_string(),
642        );
643    }
644
645    // A CI-delegated target has no publish receipt carrying the actual tap, so its
646    // post-cut observer must use the contract destination. A disagreement with
647    // cargo-dist's configured tap could observe an unrelated stale formula and
648    // report a false green. Reject that reachable double-source mismatch early.
649    let has_ci_delegated_homebrew = targets.iter().any(|target| {
650        target.registry == Registry::Homebrew && target.adapter == Adapter::CargoDist
651    });
652    if let (true, Some(configured), Some(declared)) =
653        (has_ci_delegated_homebrew, configured_tap, contract_tap)
654    {
655        if configured != declared {
656            p.err(format!(
657                "floor: dist-workspace.toml configures Homebrew tap {}, but the CI-delegated \
658                 target's distribution.homebrew_tap is {} — cargo-dist would write one tap while \
659                 shipshape verifies another",
660                quote_for_diagnostic(configured),
661                quote_for_diagnostic(declared)
662            ));
663        }
664    }
665}
666
667/// Parse the optional `release.bump_hook` command string.
668///
669/// Absent/`null` → `None` (the default, no hook). A present value must be a
670/// **non-empty** string (the engine runs it verbatim in the clean checkout during
671/// the bump phase); an empty string or a non-string is a fatal error, substituting
672/// `None` so the built contract never carries a malformed hook (the "placeholders
673/// keep the strong type" error-path rule the rest of the normalizer follows).
674fn parse_bump_hook(m: &Mapping, p: &mut Problems) -> Option<String> {
675    match m.get("bump_hook") {
676        None | Some(Value::Null) => None,
677        Some(v) => match v.as_str() {
678            Some(s) if !s.trim().is_empty() => Some(s.to_string()),
679            Some(_) => {
680                p.err(
681                    "release.bump_hook must be a non-empty command string (or omit it for no hook)"
682                        .to_string(),
683                );
684                None
685            }
686            None => {
687                p.err("release.bump_hook must be a command string".to_string());
688                None
689            }
690        },
691    }
692}
693
694fn parse_versioning(value: Option<&Value>, p: &mut Problems) -> (VersioningBase, Option<String>) {
695    let Some(v) = value else {
696        return (VersioningBase::Semver, None);
697    };
698    let Some(s) = v.as_str() else {
699        p.err(format!(
700            "versioning {} invalid — must be semver | calver:<pattern> | zerover",
701            yaml_display(v)
702        ));
703        return (VersioningBase::Semver, None);
704    };
705    if let Some(rest) = s.strip_prefix("calver:") {
706        let pattern = rest.trim();
707        if pattern.is_empty() {
708            p.err(
709                "versioning 'calver:' carries no pattern — e.g. calver:YYYY.MM.MICRO".to_string(),
710            );
711        }
712        (VersioningBase::Calver, Some(pattern.to_string()))
713    } else if s == "calver" {
714        p.err(
715            "versioning 'calver' must carry its pattern (calver:YYYY.MM.MICRO), not a bare label"
716                .to_string(),
717        );
718        (VersioningBase::Calver, None)
719    } else if let Some(base) = VersioningBase::parse(s) {
720        (base, None)
721    } else {
722        p.err(format!(
723            "versioning {} invalid — must be semver | calver:<pattern> | zerover",
724            quote_for_diagnostic(s)
725        ));
726        (VersioningBase::Semver, None)
727    }
728}
729
730/// Derive one target per ecosystem with default registry + adapter.
731fn expand_targets(ecosystems: &[Ecosystem], layout: ReleaseLayout) -> Vec<Target> {
732    ecosystems
733        .iter()
734        .map(|&e| Target {
735            ecosystem: e,
736            package: None,
737            registry: e.default_registry(),
738            adapter: e.default_adapter(layout),
739        })
740        .collect()
741}
742
743/// Floor the registry/ecosystem/adapter combinations a target may declare — the
744/// checks that stop a well-formed-but-inert (or dangerously mis-routed) target
745/// before a cut, not during one.
746///
747/// Each field is an `Option` because a parse error already reported its own
748/// problem and substituted nothing; a combination is only judged once the fields
749/// it involves are well-formed.
750fn check_target_adapter_compat(
751    idx: usize,
752    ecosystem: Option<Ecosystem>,
753    registry: Option<Registry>,
754    adapter: Option<Adapter>,
755    p: &mut Problems,
756) {
757    // Floor: registry/adapter compatibility. A `homebrew`-registry target is
758    // either engine-owned (`homebrew-tap` pushes a personal-tap formula;
759    // `homebrew-core` opens the central-formula PR) or CI-delegated
760    // (`cargo-dist`'s publish-homebrew-formula job writes the tap). Any other
761    // adapter has no formula path, so the target would silently do nothing at
762    // cut time. Reject it here rather than at release time. Only checked once
763    // both fields are well-formed (a parse error already reported its problem).
764    if let (Some(Registry::Homebrew), Some(a)) = (registry, adapter) {
765        if !matches!(
766            a,
767            Adapter::HomebrewTap | Adapter::HomebrewCore | Adapter::CargoDist
768        ) {
769            p.err(format!(
770                "floor: targets[{idx}] has registry 'homebrew' but adapter {} — a \
771                 homebrew-registry target requires adapter 'homebrew-tap' (personal tap), \
772                 'homebrew-core' (central formula), or 'cargo-dist' (CI-delegated tap)",
773                quote_for_diagnostic(a.as_str())
774            ));
775        }
776    }
777
778    // Floor: `cargo-publish-ci` (the CI-delegated crates.io publish) is
779    // meaningful only for a rust crate going to crates.io. Its whole contract
780    // with the engine is "skip the local publish, then observe crates.io" — on
781    // any other registry there is nothing the observer knows how to look at, so
782    // the target would tag, skip, and then fail the mandatory verify barrier
783    // AFTER the irreversible tag push. Reject it at normalization instead, where
784    // nothing has happened yet. (The cargo adapter refuses a non-crates.io
785    // registry too, but only once a cut is already running.)
786    if let (Some(r), Some(Adapter::CargoPublishCi)) = (registry, adapter) {
787        if r != Registry::CratesIo {
788            p.err(format!(
789                "floor: targets[{idx}] has adapter 'cargo-publish-ci' but registry {} — the \
790                 CI-delegated cargo publish targets crates.io only (use 'cargo-publish' for \
791                 an engine-run publish, or the registry's own adapter)",
792                quote_for_diagnostic(r.as_str())
793            ));
794        }
795        if let Some(e) = ecosystem {
796            if e != Ecosystem::Rust {
797                p.err(format!(
798                    "floor: targets[{idx}] has adapter 'cargo-publish-ci' but ecosystem {} — \
799                     `cargo publish` releases a rust crate",
800                    quote_for_diagnostic(e.as_str())
801                ));
802            }
803        }
804    }
805}
806
807fn validate_targets(
808    seq: &[Value],
809    ecosystems: &[Ecosystem],
810    layout: ReleaseLayout,
811    p: &mut Problems,
812) -> Vec<Target> {
813    let mut out = Vec::new();
814    for (idx, item) in seq.iter().enumerate() {
815        let Value::Mapping(m) = item else {
816            p.err(format!(
817                "targets[{idx}] must be a map with at least {{ecosystem, registry}}"
818            ));
819            continue;
820        };
821
822        let ecosystem = if let Some(s) = m.get("ecosystem").and_then(Value::as_str) {
823            if let Some(e) = Ecosystem::parse(s) {
824                if !ecosystems.is_empty() && !ecosystems.contains(&e) {
825                    p.err(format!(
826                        "targets[{idx}].ecosystem {} is not in ecosystems {:?}",
827                        quote_for_diagnostic(s),
828                        ecosystems.iter().map(|e| e.as_str()).collect::<Vec<_>>()
829                    ));
830                }
831                Some(e)
832            } else {
833                p.err(format!(
834                    "targets[{idx}].ecosystem {} invalid — one of {:?}",
835                    quote_for_diagnostic(s),
836                    Ecosystem::VALID
837                ));
838                None
839            }
840        } else {
841            p.err(format!(
842                "targets[{idx}].ecosystem invalid — one of {:?}",
843                Ecosystem::VALID
844            ));
845            None
846        };
847
848        let registry = match m.get("registry").and_then(Value::as_str) {
849            None => {
850                p.err(format!(
851                    "targets[{idx}] has no registry (required — the publish destination)"
852                ));
853                None
854            }
855            Some(s) => {
856                if let Some(r) = Registry::parse(s) {
857                    Some(r)
858                } else {
859                    p.err(format!(
860                        "targets[{idx}].registry {} invalid — one of {:?}",
861                        quote_for_diagnostic(s),
862                        Registry::VALID
863                    ));
864                    None
865                }
866            }
867        };
868
869        let adapter = match m.get("adapter") {
870            None => ecosystem.map(|e| e.default_adapter(layout)),
871            Some(v) => {
872                if let Some(a) = v.as_str().and_then(Adapter::parse) {
873                    Some(a)
874                } else {
875                    p.err(format!(
876                        "targets[{idx}].adapter {} invalid — one of {:?}",
877                        yaml_display(v),
878                        Adapter::VALID
879                    ));
880                    None
881                }
882            }
883        };
884
885        check_target_adapter_compat(idx, ecosystem, registry, adapter, p);
886
887        // On the error path, placeholders keep the strong type; the document is
888        // never emitted when problems.errors is non-empty.
889        out.push(Target {
890            ecosystem: ecosystem.unwrap_or(Ecosystem::Binary),
891            package: m.get("package").and_then(Value::as_str).map(str::to_string),
892            registry: registry.unwrap_or(Registry::GhReleases),
893            adapter: adapter.unwrap_or(Adapter::Manual),
894        });
895    }
896    out
897}
898
899/// Known `distribution`-block keys; anything else is preserved under
900/// [`Distribution::extra_fields`] (forward-compat), the nested analogue of
901/// [`KNOWN_KEYS`].
902///
903/// **Invariant:** this list MUST stay in sync with the [`Distribution`] struct
904/// fields (see the [`KNOWN_KEYS`] note). The trailing `extra_fields` entry is the
905/// reserved canonical-output metadata key — its contents are merged back rather
906/// than nested (see [`capture_unknown_fields`]). A [`Distribution`] carries no
907/// `warnings` (those live only at the top level), so only `extra_fields` needs
908/// reserving here.
909const KNOWN_DISTRIBUTION_KEYS: &[&str] = &[
910    "package",
911    "adapter",
912    "gh_releases",
913    "installers",
914    "homebrew_tap",
915    "platforms",
916    // Reserved canonical-output metadata key (not parsed) — see doc above.
917    "extra_fields",
918];
919
920/// Canonical installer order — used to de-duplicate and stably order the
921/// `distribution.installers` list (mirrors [`ECOSYSTEM_ORDER`]'s role).
922const INSTALLER_ORDER: [Installer; 5] = [
923    Installer::Shell,
924    Installer::Powershell,
925    Installer::Homebrew,
926    Installer::Msi,
927    Installer::Npm,
928];
929
930/// Parse the optional distribution layer, accepting BOTH input spellings:
931/// `distribution:` (a single mapping — v1 back-compat, the overwhelmingly common
932/// case) and `distributions:` (a sequence of mappings — a monorepo shipping
933/// several independently-distributed binaries). A registry-only repo declares
934/// neither (or a bare/null key) and gets an empty list, leaving its contract
935/// shape unchanged. Declaring BOTH keys at once is ambiguous and is an error.
936///
937/// Each element is parsed by [`parse_one_distribution`]; the collection-level
938/// floor (a monorepo's `package` must be present and unique) lives here.
939fn parse_distributions(
940    map: &Mapping,
941    targets: &[Target],
942    schema_version: u32,
943    p: &mut Problems,
944) -> Vec<Distribution> {
945    let single = map.get("distribution");
946    let many = map.get("distributions");
947    // Distinguish "absent" from "present-but-null": a bare `distribution:` /
948    // `distributions:` (null value) reads as absent, exactly like the sibling keys.
949    let single_present = matches!(single, Some(v) if !v.is_null());
950    let many_present = matches!(many, Some(v) if !v.is_null());
951    if single_present && many_present {
952        p.err(
953            "declare either `distribution` (one block) or `distributions` (a list), not both — \
954             they are the singular and plural spellings of the same field"
955                .to_string(),
956        );
957        // Fall through parsing the plural so the rest of the pass still surfaces
958        // problems; the document is never emitted while `errors` is non-empty.
959    }
960
961    let distributions = match (single, many) {
962        // `distributions:` — a sequence of mappings (the monorepo shape). Wins
963        // over a stray singular key (already flagged above).
964        (_, Some(Value::Sequence(seq))) => {
965            let mut out = Vec::with_capacity(seq.len());
966            for (idx, item) in seq.iter().enumerate() {
967                match item {
968                    Value::Mapping(m) => {
969                        out.push(parse_one_distribution(m, schema_version, p));
970                    }
971                    _ => p.err(format!(
972                        "distributions[{idx}] must be a mapping with {{package, adapter, \
973                         gh_releases?, installers?, homebrew_tap?, platforms?}}"
974                    )),
975                }
976            }
977            out
978        }
979        (_, Some(v)) if !v.is_null() => {
980            p.err(format!(
981                "distributions must be a list of distribution mappings, got {}",
982                yaml_display(v)
983            ));
984            Vec::new()
985        }
986        // `distribution:` — a single mapping (v1 back-compat) → a one-element list.
987        (Some(Value::Mapping(m)), _) => {
988            vec![parse_one_distribution(m, schema_version, p)]
989        }
990        (Some(v), _) if !v.is_null() => {
991            p.err(
992                "distribution must be a mapping with {adapter?, gh_releases?, installers?, \
993                 homebrew_tap?, platforms?} (or use `distributions:` for a list)"
994                    .to_string(),
995            );
996            Vec::new()
997        }
998        // Neither key (or both null) → a registry-only repo.
999        _ => Vec::new(),
1000    };
1001
1002    // Collection floor: a monorepo (≥2 distributions) must tag each entry with a
1003    // non-null, UNIQUE `package` — otherwise its distributions are
1004    // indistinguishable and the association is meaningless. A single distribution
1005    // may leave `package` null (the bare `distribution:` back-compat case).
1006    if distributions.len() >= 2 {
1007        let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1008        for (idx, d) in distributions.iter().enumerate() {
1009            match d.package.as_deref() {
1010                None => p.err(format!(
1011                    "floor: distributions[{idx}] has no `package` — with two or more \
1012                     distributions each must name the package it builds (the monorepo \
1013                     association key), so they can be told apart"
1014                )),
1015                Some(pkg) if !seen.insert(pkg) => p.err(format!(
1016                    "floor: distributions[{idx}].package {} is used by more than one \
1017                     distribution — each distribution must name a distinct package",
1018                    quote_for_diagnostic(pkg)
1019                )),
1020                Some(_) => {}
1021            }
1022        }
1023
1024        // Typo guard (advisory): once a monorepo names packages, a distribution
1025        // whose `package` matches NO `targets[].package` is very likely a typo — a
1026        // distribution should build a package the contract also tracks as a target.
1027        // A warning, not a floor: a binary-only package legitimately need not appear
1028        // in the registry `targets`, and `targets` may be empty (a version-tracked,
1029        // unpublished repo) — so it fires only when there ARE named target packages
1030        // to compare against.
1031        let target_pkgs: std::collections::BTreeSet<&str> = targets
1032            .iter()
1033            .filter_map(|t| t.package.as_deref())
1034            .collect();
1035        if !target_pkgs.is_empty() {
1036            for (idx, d) in distributions.iter().enumerate() {
1037                if let Some(pkg) = d.package.as_deref() {
1038                    if !target_pkgs.contains(pkg) {
1039                        p.warn(format!(
1040                            "distributions[{idx}].package {} matches no targets[].package \
1041                             ({target_pkgs:?}) — likely a typo; a distribution should build a \
1042                             package the contract also lists as a target",
1043                            quote_for_diagnostic(pkg)
1044                        ));
1045                    }
1046                }
1047            }
1048        }
1049    }
1050
1051    distributions
1052}
1053
1054/// Parse ONE distribution mapping (an element of `distributions`, or the sole
1055/// `distribution:` block) into a [`Distribution`]. On the error path it records
1056/// problems and returns a placeholder — the document is never emitted while
1057/// `problems.errors` is non-empty.
1058#[allow(clippy::too_many_lines)]
1059fn parse_one_distribution(m: &Mapping, schema_version: u32, p: &mut Problems) -> Distribution {
1060    // package — the monorepo association key. Optional (null for the sole/bare
1061    // distribution); the collection-level floor in `parse_distributions` requires
1062    // it once there are two or more distributions.
1063    let package = match m.get("package") {
1064        None | Some(Value::Null) => None,
1065        Some(v) => match v.as_str() {
1066            // Store the TRIMMED value: surrounding whitespace would otherwise make
1067            // `" alpha"` and `"alpha"` distinct to the uniqueness floor and to the
1068            // per-package association/audit keying, silently breaking both.
1069            Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
1070            _ => {
1071                p.err(
1072                    "distribution.package must be a non-empty string (the package this \
1073                     distribution builds)"
1074                        .to_string(),
1075                );
1076                None
1077            }
1078        },
1079    };
1080
1081    // adapter — required when the block is present. Which tool OWNS the existing
1082    // tag-triggered release workflow is not the normalizer's to guess (it renames
1083    // release semantics and picks a Rust-specific default); inference is
1084    // /shipshape-init's job, exactly as for `maturity`. A bare `distribution: {}` is
1085    // therefore an error, not a silent "cargo-dist owns this repo".
1086    let adapter = match m.get("adapter") {
1087        None => {
1088            p.err(
1089                "distribution.adapter is required when a distribution block is present \
1090                 (cargo-dist|goreleaser|manual) — /shipshape-init infers it"
1091                    .to_string(),
1092            );
1093            DistributionAdapter::CargoDist
1094        }
1095        Some(v) => {
1096            if let Some(a) = v.as_str().and_then(DistributionAdapter::parse) {
1097                a
1098            } else {
1099                p.err(format!(
1100                    "distribution.adapter {} invalid — must be one of {:?}",
1101                    yaml_display(v),
1102                    DistributionAdapter::VALID
1103                ));
1104                DistributionAdapter::CargoDist
1105            }
1106        }
1107    };
1108
1109    let gh_releases = match m.get("gh_releases") {
1110        // cargo-dist/goreleaser attach per-platform binaries by default.
1111        None => true,
1112        Some(Value::Bool(b)) => *b,
1113        Some(v) => {
1114            p.err(format!(
1115                "distribution.gh_releases must be true|false, got {}",
1116                yaml_display(v)
1117            ));
1118            true
1119        }
1120    };
1121
1122    // installers — validate each, then de-dup into canonical order.
1123    let mut parsed_installers: Vec<Installer> = Vec::new();
1124    for item in as_list(m.get("installers")) {
1125        match item.as_str().and_then(Installer::parse) {
1126            Some(i) => parsed_installers.push(i),
1127            None => p.err(format!(
1128                "distribution.installers: {} invalid — must be one of {:?}",
1129                yaml_display(&item),
1130                Installer::VALID
1131            )),
1132        }
1133    }
1134    let installers: Vec<Installer> = INSTALLER_ORDER
1135        .into_iter()
1136        .filter(|i| parsed_installers.contains(i))
1137        .collect();
1138
1139    let homebrew_tap = match m.get("homebrew_tap") {
1140        None | Some(Value::Null) => None,
1141        Some(v) => match v.as_str() {
1142            Some(s) if is_tap_slug(s) => Some(s.to_string()),
1143            // An invalid slug substitutes `None` (not the bad value) so the
1144            // built `Distribution` never carries a malformed tap — matching the
1145            // "placeholders keep the strong type" error-path rule the rest of the
1146            // normalizer follows, and letting the homebrew-needs-tap floor below
1147            // still fire (a present-but-invalid tap is no tap).
1148            Some(s) => {
1149                p.err(format!(
1150                    "distribution.homebrew_tap {} invalid — must be an 'owner/repo' slug",
1151                    quote_for_diagnostic(s)
1152                ));
1153                None
1154            }
1155            None => {
1156                p.err("distribution.homebrew_tap must be an 'owner/repo' string".to_string());
1157                None
1158            }
1159        },
1160    };
1161
1162    let wants_homebrew = installers.contains(&Installer::Homebrew);
1163    // Floor: a `homebrew` installer needs a tap to push the generated formula to.
1164    // This is a PER-BLOCK check — cargo-dist pushes the formula to the tap
1165    // configured in this same distribution, so the tap must live here, not in a
1166    // sibling distribution. (The target-side missing-tap floor, the double-publish
1167    // collision, and the dead-tap advisory are cross-field and aggregate over all
1168    // distributions + targets — they live in [`check_homebrew_configuration`].)
1169    if wants_homebrew && homebrew_tap.is_none() {
1170        p.err(
1171            "floor: distribution.installers includes 'homebrew' but no distribution.homebrew_tap \
1172             is set — the generated formula has nowhere to be pushed"
1173                .to_string(),
1174        );
1175    }
1176
1177    // platforms — the binary target-triple set. Omitted/null → the cross-platform
1178    // default (macOS + Linux musl), so a distribution that doesn't specify
1179    // platforms covers Linux by default (the cross-platform install requirement).
1180    // An explicit list is validated per triple and de-duplicated, preserving the
1181    // author's order (like the sibling `targets` list — there is no canonical
1182    // triple ordering to impose). An explicit *empty* list is NOT the same as
1183    // omitted: it is a mistake, and silently defaulting it would surprise the
1184    // author with targets they never listed and erase the intent the downstream
1185    // cross-platform audit needs — so it is a hard error.
1186    let platforms = match m.get("platforms") {
1187        None | Some(Value::Null) => default_cross_platform_targets(),
1188        Some(Value::Sequence(seq)) if seq.is_empty() => {
1189            // Default fallback keeps error-collection going; the contract is never
1190            // emitted while `problems.errors` is non-empty.
1191            p.err(
1192                "distribution.platforms is an empty list — omit the key to accept the \
1193                 cross-platform default (macOS + Linux) or list explicit target-triples; a \
1194                 distribution with no platforms builds nothing"
1195                    .to_string(),
1196            );
1197            default_cross_platform_targets()
1198        }
1199        Some(Value::Sequence(seq)) => {
1200            let mut out: Vec<String> = Vec::new();
1201            for item in seq {
1202                match item.as_str() {
1203                    Some(s) if looks_like_target_triple(s) => {
1204                        let triple = s.to_string();
1205                        if !out.contains(&triple) {
1206                            out.push(triple);
1207                        }
1208                    }
1209                    Some(s) => p.err(format!(
1210                        "distribution.platforms: {} is not a well-formed target-triple \
1211                         (e.g. x86_64-unknown-linux-musl, aarch64-apple-darwin) — structural \
1212                         check only; the toolchain is the final authority on what builds",
1213                        quote_for_diagnostic(s)
1214                    )),
1215                    None => p.err(format!(
1216                        "distribution.platforms: {} invalid — each entry must be a \
1217                         target-triple string",
1218                        yaml_display(item)
1219                    )),
1220                }
1221            }
1222            out
1223        }
1224        Some(v) => {
1225            p.err(format!(
1226                "distribution.platforms must be a list of target-triple strings, got {}",
1227                yaml_display(v)
1228            ));
1229            default_cross_platform_targets()
1230        }
1231    };
1232
1233    // Cross-check: an OS-specific installer whose target OS is absent from the
1234    // resolved `platforms` set is dead config — the generated installer points at
1235    // a binary the release never builds ("the installer has nothing to install").
1236    // A warning, not a floor (mirrors the `homebrew_tap`-without-consumer advisory
1237    // above): the contract is internally consistent, just wasteful. Only the
1238    // OS-specific installers constrain the set — see [`installer_os_need`] for the
1239    // full installer→OS table; npm/shell/powershell are not cross-checked.
1240    //
1241    // Gated on a clean parse: this is a cross-field semantic advisory, so it must
1242    // read only well-formed triples. A malformed triple (rejected above) that
1243    // happens to contain an OS keyword must neither satisfy nor spuriously fail
1244    // the coverage check — otherwise the warning would flip as the author fixes an
1245    // unrelated error. Errors already block emission, so gating here loses nothing.
1246    if p.errors.is_empty() {
1247        let has_windows = platforms.iter().any(|t| is_windows_triple(t));
1248        let has_macos = platforms.iter().any(|t| is_macos_triple(t));
1249        let has_linux = platforms.iter().any(|t| is_linux_triple(t));
1250        for &installer in &installers {
1251            let unmet = match installer_os_need(installer) {
1252                OsNeed::Unchecked => None,
1253                OsNeed::Windows => (!has_windows).then_some(
1254                    "distribution.installers includes 'msi' but the resolved \
1255                     distribution.platforms set has no Windows (*-windows-*) target — the MSI \
1256                     installer has nothing to install",
1257                ),
1258                // Homebrew serves macOS natively AND Linux via Linuxbrew, so a
1259                // single Linux triple satisfies it just as a darwin triple does;
1260                // the warning fires only when NEITHER is present (the issue's stated
1261                // intent when the darwin-vs-linux question is ambiguous).
1262                OsNeed::MacosOrLinux => (!has_macos && !has_linux).then_some(
1263                    "distribution.installers includes 'homebrew' but the resolved \
1264                     distribution.platforms set has no macOS (*-apple-darwin) or Linux \
1265                     (*-linux-*) target — the Homebrew formula has nothing to install",
1266                ),
1267            };
1268            if let Some(msg) = unmet {
1269                p.warn(msg.to_string());
1270            }
1271        }
1272    }
1273
1274    // Forward-compat: preserve unknown distribution sub-keys (the nested analogue
1275    // of the top-level `extra_fields` scan), so an older reader round-trips a
1276    // newer contract's distribution keys rather than dropping them. Reported once,
1277    // scoped to the block via the `Distribution` scope, mirroring the top-level
1278    // unknown-field warning — the shared helper keeps the two from drifting.
1279    let extra_fields = capture_unknown_fields(
1280        m,
1281        KNOWN_DISTRIBUTION_KEYS,
1282        CaptureScope::Distribution,
1283        schema_version,
1284        p,
1285    );
1286
1287    Distribution {
1288        package,
1289        adapter,
1290        gh_releases,
1291        installers,
1292        homebrew_tap,
1293        platforms,
1294        extra_fields,
1295    }
1296}
1297
1298/// The cross-platform default `distribution.platforms` set as owned strings —
1299/// materialized when the block omits `platforms` (or gives an empty list). Always
1300/// contains at least one Linux triple (the cross-platform install requirement).
1301fn default_cross_platform_targets() -> Vec<String> {
1302    DEFAULT_CROSS_PLATFORM_TARGETS
1303        .iter()
1304        .map(|&s| s.to_string())
1305        .collect()
1306}
1307
1308/// The OS coverage an installer needs from `distribution.platforms` to install
1309/// anything — the small installer→OS spec behind the installer↔platform
1310/// cross-check warning. Kept as one table (see [`installer_os_need`]) rather than
1311/// scattered conditionals so the mapping stays inspectable in one place.
1312enum OsNeed {
1313    /// Not cross-checked — this installer never constrains `platforms`.
1314    Unchecked,
1315    /// Needs at least one Windows triple.
1316    Windows,
1317    /// Needs at least one macOS OR Linux triple.
1318    MacosOrLinux,
1319}
1320
1321/// The OS an installer's generated artifact can actually install onto — the spec
1322/// that lets the normalizer flag an installer whose target OS is absent from
1323/// `platforms`. Only `msi` and `homebrew` are OS-gated; the rest are deliberately
1324/// left `Unchecked` (a scoping choice, not a claim that they run everywhere):
1325///
1326/// | installer    | need              | rationale                                          |
1327/// |--------------|-------------------|----------------------------------------------------|
1328/// | `msi`        | Windows           | an `.msi` installs only on Windows                 |
1329/// | `homebrew`   | macOS **or** Linux| Homebrew serves macOS natively and Linux (Linuxbrew) |
1330/// | `shell`      | — (not checked)   | a POSIX script; only msi/homebrew are gated for now |
1331/// | `powershell` | — (not checked)   | Windows-oriented; only msi/homebrew are gated for now |
1332/// | `npm`        | — (not checked)   | published to a registry, not tied to one OS's artifact |
1333fn installer_os_need(i: Installer) -> OsNeed {
1334    match i {
1335        Installer::Msi => OsNeed::Windows,
1336        Installer::Homebrew => OsNeed::MacosOrLinux,
1337        Installer::Shell | Installer::Powershell | Installer::Npm => OsNeed::Unchecked,
1338    }
1339}
1340
1341/// The OS ("system") component of a target-triple — the 3rd `-`-separated field
1342/// in the `<arch>-<vendor>-<os>[-<env>]` shape the shipped desktop triples use
1343/// (`x86_64-pc-windows-msvc`, `aarch64-apple-darwin`, `x86_64-unknown-linux-musl`).
1344/// `None` for a 2-component triple that names no vendor (`wasm32-wasip1`). Matching
1345/// the OS *positionally* (rather than "any component equals …") is what keeps
1346/// `aarch64-linux-android` out of the Linux bucket: its `linux` sits in the vendor
1347/// slot and the real OS component is `android`.
1348fn triple_os(s: &str) -> Option<&str> {
1349    s.split('-').nth(2)
1350}
1351
1352/// Whether a target-triple targets Windows — OS component `windows` (covering
1353/// both `-windows-msvc` and `-windows-gnu`).
1354fn is_windows_triple(s: &str) -> bool {
1355    triple_os(s) == Some("windows")
1356}
1357
1358/// Whether a target-triple targets macOS — OS component `darwin` (e.g.
1359/// `aarch64-apple-darwin`). Apple's non-macOS triples (`*-apple-ios`, `-tvos`, …)
1360/// carry a different OS component and are correctly excluded.
1361fn is_macos_triple(s: &str) -> bool {
1362    triple_os(s) == Some("darwin")
1363}
1364
1365/// Whether a target-triple targets Linux — OS component `linux` (e.g.
1366/// `x86_64-unknown-linux-musl`), covering the Linuxbrew case for `homebrew`.
1367/// Android (`aarch64-linux-android`) has `android` as its OS component and does
1368/// not count.
1369fn is_linux_triple(s: &str) -> bool {
1370    triple_os(s) == Some("linux")
1371}
1372
1373/// Whether `s` is a *structurally* plausible target-triple — 2–4 `-`-separated
1374/// components, each a non-empty run of `[a-z0-9_.]`. Deliberately LEXICAL, not
1375/// semantic: the real triple set is open and rustc-defined, so this is a
1376/// well-formedness gate, not a whitelist. It rejects what could never be a triple
1377/// (empty parts, uppercase, whitespace, punctuation, injection chars, wrong shape)
1378/// and accepts real triples including dotted arch names like
1379/// `thumbv8m.main-none-eabi` — but it also accepts structurally-valid nonsense like
1380/// `aa-bb`, because the toolchain is the final authority on whether a triple
1381/// actually builds. The OS component stays intact and inspectable so the
1382/// cross-platform `audit` can classify a set downstream.
1383fn looks_like_target_triple(s: &str) -> bool {
1384    let parts: Vec<&str> = s.split('-').collect();
1385    (2..=4).contains(&parts.len())
1386        && parts.iter().all(|part| {
1387            !part.is_empty()
1388                && part.bytes().all(|b| {
1389                    b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.')
1390                })
1391        })
1392}
1393
1394/// Whether `s` is a plausible `owner/repo` tap slug — exactly one `/`, and each
1395/// part a non-empty run of the GitHub-name character set (ASCII alphanumeric plus
1396/// `-`, `_`, `.`), with `.`/`..` rejected. Lexical only — existence is not
1397/// checked. Deliberately strict: this value flows into `brew tap` and repo URLs
1398/// downstream, so arbitrary punctuation, whitespace, or path traversal
1399/// (`owner/..`) must not pass.
1400fn is_tap_slug(s: &str) -> bool {
1401    fn valid_part(part: &str) -> bool {
1402        !part.is_empty()
1403            && part != "."
1404            && part != ".."
1405            && part
1406                .bytes()
1407                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
1408    }
1409    match s.split_once('/') {
1410        Some((owner, repo)) => valid_part(owner) && valid_part(repo) && !repo.contains('/'),
1411        None => false,
1412    }
1413}
1414
1415/// A floor-clean default badge set: `ci` at mvp+, `registry` when a publishable
1416/// target exists, `license` always.
1417fn default_health_badges(maturity: Maturity, targets: &[Target]) -> Vec<HealthBadge> {
1418    let mut badges = Vec::new();
1419    if matches!(maturity, Maturity::Mvp | Maturity::Production) {
1420        badges.push(HealthBadge::Ci);
1421    }
1422    if !targets.is_empty() {
1423        badges.push(HealthBadge::Registry);
1424    }
1425    badges.push(HealthBadge::License);
1426    badges
1427}
1428
1429/// Every enabled badge must have its producer enabled (floor 4).
1430fn check_badge_producers(
1431    badges: &[HealthBadge],
1432    maturity: Maturity,
1433    targets: &[Target],
1434    p: &mut Problems,
1435) {
1436    let has_registry_target = !targets.is_empty();
1437    for b in badges {
1438        match b {
1439            HealthBadge::Ci if maturity == Maturity::Spike => p.err(
1440                "floor: health_badge 'ci' has no producer at maturity 'spike' (no CI until mvp) — \
1441                 drop it or raise maturity"
1442                    .to_string(),
1443            ),
1444            HealthBadge::Registry if !has_registry_target => p.err(
1445                "floor: health_badge 'registry' has no producer — no target has a registry to \
1446                 publish to"
1447                    .to_string(),
1448            ),
1449            HealthBadge::Coverage if maturity != Maturity::Production => p.err(format!(
1450                "floor: health_badge 'coverage' has no producer — the coverage gate is a \
1451                 production-tier /shipshape-ci output; current maturity is '{}'",
1452                maturity.as_str()
1453            )),
1454            HealthBadge::Scorecard if maturity != Maturity::Production => p.err(format!(
1455                "floor: health_badge 'scorecard' has no producer — the Scorecard action is a \
1456                 production-tier output; current maturity is '{}'",
1457                maturity.as_str()
1458            )),
1459            _ => {}
1460        }
1461    }
1462}
1463
1464/// Floor a package declared with **two different crates.io publishers** — one
1465/// engine-run (`cargo-publish`) and one CI-delegated (`cargo-publish-ci`).
1466///
1467/// The two identities are mutually exclusive statements about who runs `cargo
1468/// publish` for that crate, and the per-target journal id keeps them distinct, so
1469/// both would plan: the engine would publish the crate in publish-all AND the tag
1470/// push would trigger CI to publish it again. crates.io rejects the duplicate, so
1471/// the damage is a red CI job rather than a corrupted release — but the contract is
1472/// self-contradictory and the cut would report green over a failed workflow. Only
1473/// reachable since `cargo-publish-ci` exists, hence floored with it.
1474fn check_publisher_conflicts(targets: &[Target], p: &mut Problems) {
1475    for target in targets
1476        .iter()
1477        .filter(|t| t.adapter == Adapter::CargoPublishCi)
1478    {
1479        let Some(package) = target.package.as_deref() else {
1480            continue;
1481        };
1482        if targets.iter().any(|other| {
1483            other.adapter == Adapter::CargoPublish
1484                && other.registry == target.registry
1485                && other.package.as_deref() == Some(package)
1486        }) {
1487            p.err(format!(
1488                "floor: package {} is declared for {} twice, once with adapter 'cargo-publish' \
1489                 (the engine publishes it) and once with 'cargo-publish-ci' (CI publishes it) — \
1490                 the engine would publish it AND the tag would trigger CI to publish it again. \
1491                 Keep exactly one publisher for a package",
1492                quote_for_diagnostic(package),
1493                quote_for_diagnostic(target.registry.as_str())
1494            ));
1495        }
1496    }
1497}
1498
1499/// Cross-read the repo's Cargo manifests as evidence for (or against) what the
1500/// contract says about publishing to crates.io.
1501///
1502/// The contract is the authority on *intent*; `Cargo.toml`'s `publish` key is the
1503/// authority on what Cargo will actually permit. Where the two disagree the
1504/// normalized contract would assert a publish surface that cannot exist (or omit
1505/// one nothing prevents), which is exactly the silent-wrong-result the machine
1506/// contract must not carry, so each direction is diagnosed here:
1507///
1508/// - **Contradiction (hard error).** A declared crates.io target whose crate
1509///   [forbids](CargoPublishPolicy::Forbidden) publishing can never publish. Refusing
1510///   at normalization keeps that failure *before* the irreversible tag: the
1511///   engine-run form would die in `cargo publish`, and the CI-delegated form
1512///   (`cargo-publish-ci`) is worse — publish-all skips it, so the first symptom
1513///   would be a red workflow after the tag, with verify then reporting the crate
1514///   Missing at its destination.
1515/// - **Unguarded publish-none (warning).** A contract with **no publish targets at
1516///   all** is the authored publish-none shape (an explicit `targets: []`; the
1517///   normalizer never expands an empty list back into a phantom target). That is a
1518///   valid, honored state — but if nothing in the tree forbids publishing, the
1519///   declaration rests on the contract alone and a stray `cargo publish` would still
1520///   succeed. Non-fatal: the contract is the truth, this only names the missing
1521///   belt-and-braces.
1522///
1523/// The warning deliberately keys on `targets` being **entirely** empty, not on the
1524/// absence of a *crates.io* target: a repo that ships binaries through a
1525/// `gh-releases`/`homebrew` target publishes plenty and is not publish-none, so
1526/// telling it to set `publish = false` would be both wrong and (for a cargo-dist
1527/// repo) actively bad advice.
1528///
1529/// Evidence-gated in every direction. No readable `Cargo.toml`, a target naming a
1530/// package no manifest declares, or a manifest whose `publish` key the reader could
1531/// not resolve ([`Unknown`](CargoPublishPolicy::Unknown) — inheritance with no
1532/// `[workspace.package]`, an inline table) all yield **no** diagnostic: absence of
1533/// evidence is never evidence, in either direction. Only an explicit `Forbidden`
1534/// errors and only an explicit `Allowed` warns.
1535///
1536/// Scoped to rust/crates.io on purpose: it is the one ecosystem whose manifest
1537/// carries a machine-readable publish veto. A non-crates.io registry target is
1538/// governed by its own allow-list semantics, not by this key.
1539fn check_cargo_publish_evidence(
1540    ecosystems: &[Ecosystem],
1541    targets: &[Target],
1542    repo_root: &Path,
1543    fs: &dyn Fs,
1544    p: &mut Problems,
1545) {
1546    if !ecosystems.contains(&Ecosystem::Rust) {
1547        return;
1548    }
1549    let evidence = crate::facts::cargo_publish_evidence(repo_root, fs);
1550    if evidence.is_empty() {
1551        return;
1552    }
1553
1554    // Publish-none: NO target of any kind. Distinct from CI-DELEGATION (a
1555    // `cargo-publish-ci` target still publishes, just from CI) and from a
1556    // binary-only repo (a gh-releases/homebrew target publishes too) — neither
1557    // reaches this branch.
1558    if targets.is_empty() {
1559        let unguarded: Vec<String> = evidence
1560            .iter()
1561            .filter(|m| m.policy == CargoPublishPolicy::Allowed)
1562            .map(|m| quote_for_diagnostic(&m.manifest))
1563            .collect();
1564        if !unguarded.is_empty() {
1565            p.warn(format!(
1566                "the contract declares no publish targets (publish-none), but {} {} not forbid \
1567                 publishing — the intent holds in the contract, yet nothing in the tree stops an \
1568                 accidental 'cargo publish'. Set publish = false to make it enforceable",
1569                unguarded.join(", "),
1570                if unguarded.len() == 1 { "does" } else { "do" }
1571            ));
1572        }
1573        return;
1574    }
1575
1576    for target in targets.iter().filter(|t| {
1577        t.ecosystem == Ecosystem::Rust
1578            && t.registry == Registry::CratesIo
1579            && matches!(t.adapter, Adapter::CargoPublish | Adapter::CargoPublishCi)
1580    }) {
1581        let forbidden =
1582            |m: &&crate::facts::CargoPublishFlag| m.policy == CargoPublishPolicy::Forbidden;
1583        // An unresolved (`null`) package cannot be matched to one manifest, so it is
1584        // contradicted only when EVERY manifest in the tree forbids the publish (a
1585        // single `Unknown` or `Allowed` is enough to withhold the verdict). The
1586        // message then names them all — an arbitrary "first" would send the author to
1587        // one of several equally-blocking manifests.
1588        let blocking: Vec<&crate::facts::CargoPublishFlag> = match target.package.as_deref() {
1589            Some(package) => evidence
1590                .iter()
1591                .filter(|m| m.package.as_deref() == Some(package))
1592                .filter(forbidden)
1593                .collect(),
1594            None if evidence.iter().all(|m| forbidden(&m)) => evidence.iter().collect(),
1595            None => Vec::new(),
1596        };
1597        if blocking.is_empty() {
1598            continue;
1599        }
1600        p.err(format!(
1601            "floor: targets declares a crates.io publish for {} with adapter {}, but {} \
1602             forbids publishing ('publish = false', 'publish = []', or an allow-list without \
1603             'crates-io') — the publish can never succeed. Drop the target (an explicit \
1604             'targets: []' is the publish-none contract) or allow the publish in the manifest. \
1605             Run 'shipshape facts --json' from the repository root and inspect \
1606             data.cargo_publish to see the manifest evidence shipshape read.",
1607            quote_for_diagnostic(target.package.as_deref().unwrap_or("this repo's crate")),
1608            target.adapter.as_str(),
1609            blocking
1610                .iter()
1611                .map(|m| quote_for_diagnostic(&m.manifest))
1612                .collect::<Vec<_>>()
1613                .join(", ")
1614        ));
1615    }
1616}
1617
1618/// Cross-field Homebrew consistency — the full truth table over three aggregate
1619/// signals: a **tap** configured on any distribution, an installer-side formula
1620/// **producer** (a `homebrew` installer on any distribution — cargo-dist), and a
1621/// target-side formula **producer** (a `homebrew`-registry target whose adapter is
1622/// `homebrew-tap`, i.e. the release engine pushes a formula to the personal tap).
1623/// A `cargo-dist` Homebrew target is deliberately absent from this signal: its
1624/// tag-triggered CI writes the formula, so it is declared and verified but never an
1625/// engine-side writer.
1626///
1627/// A `homebrew-core` target is deliberately NOT a tap-producer: it bumps the
1628/// central formula via a PR and needs no personal tap, so it neither requires a
1629/// `homebrew_tap` nor collides with the installer's tap push.
1630///
1631/// The floors (all hard errors, per the AI-first fail-fast contract):
1632/// - **missing-tap (target side):** a `homebrew-tap` target with no `homebrew_tap`
1633///   anywhere — the engine's `dist` phase has nowhere to push the formula. (The
1634///   installer side is floored per-block in [`parse_one_distribution`].)
1635/// - **double-publish:** a `homebrew` installer AND a `homebrew-tap` target both
1636///   generate + push a formula to the personal tap — a guaranteed collision.
1637///
1638/// A configured tap with no producer is a **floor**, not an advisory: a successful
1639/// release would otherwise omit the Homebrew channel. The engine cannot safely
1640/// synthesize a target because a distribution block may not identify which package
1641/// supplies the formula (especially in a multi-crate workspace), so the contract
1642/// must name the target explicitly.
1643///
1644/// **Why aggregate, not per-package (deliberate).** The three signals are OR-ed
1645/// across all distributions/targets rather than grouped by the monorepo `package`
1646/// key. This matches the release engine's actual homebrew model: a cut carries a
1647/// SINGLE tap (`ReleasePlan::homebrew_tap` is the first-found tap, see
1648/// `release::plan`), and the CLI's `ensure_single_distribution` rejects a
1649/// multi-distribution monorepo BEFORE it can be planned — a per-package multi-tap
1650/// monorepo is an explicit deferred follow-up, not a shape the engine can cut. For
1651/// everything the engine supports (≤1 distribution), aggregate == per-package, and
1652/// crucially the aggregate view is what lets a bare `package: null` distribution's
1653/// tap serve a named-package `homebrew-tap` target (shipshape's OWN contract shape) —
1654/// a strict `target.package == distribution.package` grouping would wrongly reject
1655/// it. Revisit this only alongside the engine's per-package-tap follow-up.
1656fn check_homebrew_configuration(
1657    targets: &[Target],
1658    distributions: &[Distribution],
1659    p: &mut Problems,
1660) {
1661    let has_tap = distributions.iter().any(|d| d.homebrew_tap.is_some());
1662    let installer_producer = distributions
1663        .iter()
1664        .any(|d| d.installers.contains(&Installer::Homebrew));
1665    // Narrowed to `homebrew-tap` (not merely `registry == homebrew`): only the tap
1666    // adapter pushes a formula to the personal tap. `validate_targets` already
1667    // floors any other adapter on a `homebrew` registry, so a `homebrew-core`
1668    // target is the only other well-formed case, and it is not a tap-producer.
1669    let tap_target_producer = targets
1670        .iter()
1671        .any(|t| t.registry == Registry::Homebrew && t.adapter == Adapter::HomebrewTap);
1672    // cargo-dist does not write from the engine, but its CI job and the mandatory
1673    // observer both need the contract's tap destination. A target without it would
1674    // only fail later in verify as unobservable, so reject it at normalization.
1675    let tap_target_needing_tap = targets.iter().any(|t| {
1676        t.registry == Registry::Homebrew
1677            && matches!(t.adapter, Adapter::HomebrewTap | Adapter::CargoDist)
1678    });
1679
1680    // Floor: a personal-tap target needs a declared tap destination, whether its
1681    // formula writer is the engine or cargo-dist CI.
1682    if tap_target_needing_tap && !has_tap {
1683        p.err(
1684            "floor: a 'homebrew'-registry target with adapter 'homebrew-tap' or 'cargo-dist' \
1685             needs distribution.homebrew_tap — the formula has nowhere to be published or \
1686             observed (set distribution.homebrew_tap to the 'owner/repo' tap)"
1687                .to_string(),
1688        );
1689    }
1690
1691    let ci_delegated_tap_target = targets
1692        .iter()
1693        .any(|t| t.registry == Registry::Homebrew && t.adapter == Adapter::CargoDist);
1694    // cargo-dist publishes a tap formula only through its Homebrew installer job.
1695    // Reject an otherwise-declared target that CI can never realize rather than
1696    // letting mandatory verification time out after the tag has been pushed.
1697    if ci_delegated_tap_target && !installer_producer {
1698        p.err(
1699            "floor: a 'homebrew'-registry target with adapter 'cargo-dist' requires \
1700             distribution.installers to include 'homebrew' — cargo-dist only writes the formula \
1701             through its Homebrew installer job"
1702                .to_string(),
1703        );
1704    }
1705
1706    // Floor: the double-publish collision — two mechanisms push a formula to the
1707    // personal tap (cargo-dist's installer AND the engine's homebrew-tap adapter).
1708    // A cargo-dist Homebrew target names that same CI writer, so it does not collide.
1709    if installer_producer && tap_target_producer {
1710        p.err(
1711            "floor: both a 'homebrew' installer (distribution.installers) and a 'homebrew'-registry \
1712             target with adapter 'homebrew-tap' generate + push a formula to the tap — they would \
1713             collide; keep exactly one homebrew formula producer, not both"
1714                .to_string(),
1715        );
1716    }
1717
1718    // A tap without an engine-owned target is diagnosed by the facts-to-contract
1719    // advisory after this structural pass. It remains a warning so validation and
1720    // planning can explain the exact missing release surface before `cut` refuses.
1721}
1722
1723// ── Frontmatter extraction + parse ───────────────────────────────────────────
1724
1725/// A `---` fence line (exactly three dashes plus optional trailing whitespace).
1726fn is_fence(line: &str) -> bool {
1727    let t = line.trim_end();
1728    t == "---" || (t.starts_with("---") && t[3..].chars().all(char::is_whitespace))
1729}
1730
1731/// Split the YAML frontmatter block out of the document. Returns the frontmatter
1732/// text (body discarded — the normalizer never reads it), or `None` on a
1733/// structural error (recorded on `p`).
1734fn split_frontmatter(text: &str, p: &mut Problems) -> Option<String> {
1735    let mut lines = text.lines();
1736    match lines.next() {
1737        Some(first) if is_fence(first) => {}
1738        _ => {
1739            p.err("frontmatter missing: file must begin with a '---' YAML block".to_string());
1740            return None;
1741        }
1742    }
1743    let mut fm = String::new();
1744    for line in lines {
1745        if is_fence(line) {
1746            return Some(fm);
1747        }
1748        fm.push_str(line);
1749        fm.push('\n');
1750    }
1751    p.err("frontmatter not closed: no terminating '---' line found".to_string());
1752    None
1753}
1754
1755/// Parse the frontmatter into a YAML mapping. `serde_yaml` rejects duplicate
1756/// keys natively; a non-mapping top level or any YAML error is recorded on `p`.
1757fn parse_frontmatter(fm: &str, p: &mut Problems) -> Mapping {
1758    if fm.trim().is_empty() {
1759        return Mapping::new();
1760    }
1761    match serde_yaml::from_str::<Value>(fm) {
1762        Ok(Value::Null) => Mapping::new(),
1763        Ok(Value::Mapping(m)) => m,
1764        Ok(_) => {
1765            p.err("frontmatter: top level must be a mapping".to_string());
1766            Mapping::new()
1767        }
1768        Err(e) => {
1769            p.err(format!("frontmatter: invalid YAML — {e}"));
1770            Mapping::new()
1771        }
1772    }
1773}
1774
1775// ── Helpers ──────────────────────────────────────────────────────────────────
1776
1777/// Coerce a value to a list: a sequence stays; absent/null → empty; a scalar
1778/// becomes a one-element list (mirrors the Python `_as_list`).
1779fn as_list(v: Option<&Value>) -> Vec<Value> {
1780    match v {
1781        None | Some(Value::Null) => Vec::new(),
1782        Some(Value::Sequence(seq)) => seq.clone(),
1783        Some(other) => vec![other.clone()],
1784    }
1785}
1786
1787/// A compact display of a YAML scalar for error messages (strings are quoted).
1788fn yaml_display(v: &Value) -> String {
1789    match v {
1790        Value::String(s) => quote_for_diagnostic(s),
1791        Value::Bool(b) => b.to_string(),
1792        Value::Number(n) => n.to_string(),
1793        Value::Null => "null".to_string(),
1794        Value::Sequence(_) => "<list>".to_string(),
1795        Value::Mapping(_) => "<map>".to_string(),
1796        Value::Tagged(t) => yaml_display(&t.value),
1797    }
1798}
1799
1800/// Quote a user-controlled string for safe embedding in a warning/error message.
1801///
1802/// Diagnostics interleave user-controlled text (unknown field keys, rejected enum
1803/// values, package/tap/path strings) into a single line that lands in the §10
1804/// error envelope and the JSONL log. Wrapping such a value in bare single quotes
1805/// (`'{s}'`) lets a value carrying a quote, newline, or control character forge a
1806/// second diagnostic line or corrupt the log — a log-injection vector. JSON string
1807/// encoding escapes `"`, `\`, newlines, and C0 control characters (and leaves
1808/// ordinary text readable), so `foo` renders as `"foo"` and a hostile
1809/// `a"\ninjected` renders as `"a\"\ninjected"` on one intact line. Infallible:
1810/// serializing a string to JSON never fails.
1811fn quote_for_diagnostic(s: &str) -> String {
1812    serde_json::Value::String(s.to_owned()).to_string()
1813}
1814
1815/// Whether `rel` is a relative path that stays inside the repo — no absolute
1816/// path, no `../` escape — the fragment-dir floor. Lexical, so the path need not
1817/// exist. The check is purely on `rel`'s own component depth, so it holds
1818/// whether the repo root is absolute or relative (notably `--repo-root .`,
1819/// where `repo_root` normalizes to an empty path): a `..` is an escape the
1820/// moment it would pop above the repo root, exactly the Python
1821/// `_path_inside_repo` verdict (which rejects any `rel` that normalizes to an
1822/// escaping path). Joining `rel` onto a relative root and testing containment —
1823/// the previous approach — silently accepted `../etc` under a `.` root, because
1824/// an empty normalized root is a prefix of every path.
1825fn path_inside_repo(rel: &str) -> bool {
1826    let mut depth: usize = 0;
1827    for comp in Path::new(rel).components() {
1828        match comp {
1829            Component::CurDir => {}
1830            Component::Normal(_) => depth += 1,
1831            Component::ParentDir => {
1832                // An escape above the repo root the instant depth would go < 0.
1833                if depth == 0 {
1834                    return false;
1835                }
1836                depth -= 1;
1837            }
1838            // An absolute path (or a Windows drive prefix) never stays inside a
1839            // relative repo root.
1840            Component::RootDir | Component::Prefix(_) => return false,
1841        }
1842    }
1843    true
1844}
1845
1846/// Which mapping the [`capture_unknown_fields`] scan is running over — scopes the
1847/// forward-compat warning text and error messages. An enum (rather than a bare
1848/// string prefix) so a new call site cannot silently pass a mis-spaced label and
1849/// produce `unknown distributionfield(s)`.
1850#[derive(Clone, Copy)]
1851enum CaptureScope {
1852    /// The top-level frontmatter mapping ([`KNOWN_KEYS`]).
1853    TopLevel,
1854    /// The nested `distribution` block ([`KNOWN_DISTRIBUTION_KEYS`]).
1855    Distribution,
1856}
1857
1858impl CaptureScope {
1859    /// The infix woven into the warning/error text — `""` for the top level,
1860    /// `"distribution "` for the block — so a message reads `unknown field(s) …`
1861    /// vs `unknown distribution field(s) …`.
1862    fn label(self) -> &'static str {
1863        match self {
1864            Self::TopLevel => "",
1865            Self::Distribution => "distribution ",
1866        }
1867    }
1868
1869    /// A path rooted at the actual reserved YAML key, with the distribution
1870    /// context expressed separately rather than inventing a `distribution
1871    /// extra_fields` key in the diagnostic.
1872    fn reserved_extra_fields_path(self, key: &str) -> String {
1873        let path = format!(
1874            "reserved 'extra_fields' field {}",
1875            quote_for_diagnostic(key)
1876        );
1877        match self {
1878            Self::TopLevel => path,
1879            Self::Distribution => format!("{path} in distribution"),
1880        }
1881    }
1882}
1883
1884/// Scan a YAML mapping for keys outside `known` and preserve them under a
1885/// forward-compat `extra_fields` map, warning once when any were captured. The
1886/// single implementation behind BOTH the top-level ([`KNOWN_KEYS`]) and nested
1887/// `distribution` ([`KNOWN_DISTRIBUTION_KEYS`]) scans, so the two cannot drift
1888/// (the nested warning once silently omitted `schema_version`).
1889///
1890/// The guarantee is that an unknown **string** key is never dropped, never
1891/// double-captured, and round-trips predictably:
1892/// - A string key not in `known` is captured verbatim.
1893/// - A known string key is skipped (parsed as its field, not double-captured).
1894/// - The reserved canonical-output key `extra_fields` (in `known`) is not
1895///   re-captured into a nested `extra_fields.extra_fields`; instead its mapping
1896///   contents are **merged back** into the returned map (see
1897///   [`merge_reserved_extra_fields`]) so a hand-authored — or, defensively, a
1898///   re-fed canonical — `extra_fields` block round-trips losslessly rather than
1899///   being silently dropped. A key present both in that block and as a sibling
1900///   unknown field is an ambiguity error, not a silent overwrite.
1901///
1902/// Non-string keys (`42:`, `true:`, a list/map key — legal YAML) are a **structural
1903/// error**, not silently coerced: they can never be a forward-compatible schema
1904/// field (canonical JSON object keys are strings), and coercing them through the
1905/// display formatter would collapse distinct keys onto the same string (`42` and
1906/// `"42"`; every list key onto `<list>`) and silently drop a value — the opposite
1907/// of the never-drop intent. Rejecting keeps the invariant vacuously (an invalid
1908/// contract's output is never consumed) and matches the normalizer's
1909/// error-collection style.
1910fn capture_unknown_fields(
1911    m: &Mapping,
1912    known: &[&str],
1913    scope: CaptureScope,
1914    schema_version: u32,
1915    p: &mut Problems,
1916) -> serde_json::Map<String, serde_json::Value> {
1917    let label = scope.label();
1918    let mut extra_fields = serde_json::Map::new();
1919    // Merge an explicit `extra_fields` block first (reserved metadata key), so a
1920    // sibling unknown key colliding with it is detected below rather than
1921    // silently overwriting it.
1922    if let Some(v) = m.get("extra_fields") {
1923        merge_reserved_extra_fields(v, scope, &mut extra_fields, p);
1924    }
1925    for (k, v) in m {
1926        match k {
1927            Value::String(key) => {
1928                if known.contains(&key.as_str()) {
1929                    continue;
1930                }
1931                if extra_fields.contains_key(key) {
1932                    p.err(format!(
1933                        "{label}field '{key}' appears both as an unknown top-level key and inside \
1934                         the reserved '{label}extra_fields' block — refusing to drop either value; \
1935                         remove one"
1936                    ));
1937                } else {
1938                    let path = format!("{label}extra field {}", quote_for_diagnostic(key));
1939                    match yaml_to_json(v, &path) {
1940                        Ok(value) => {
1941                            extra_fields.insert(key.clone(), value);
1942                        }
1943                        Err(error) => p.err(error),
1944                    }
1945                }
1946            }
1947            other => p.err(format!(
1948                "{label}field key {} must be a string — a non-string key is not a \
1949                 forward-compatible schema shape and cannot be preserved losslessly (distinct \
1950                 non-string keys collapse onto the same JSON key)",
1951                yaml_display(other)
1952            )),
1953        }
1954    }
1955    if !extra_fields.is_empty() {
1956        // serde_json::Map is ordered (BTreeMap, no `preserve_order`) → keys already
1957        // sorted. Each key is a user-controlled map key, so JSON-encode it (rather
1958        // than bare single-quoting) to keep a hostile key from forging a diagnostic
1959        // line — see [`quote_for_diagnostic`].
1960        let keys = extra_fields
1961            .keys()
1962            .map(|k| quote_for_diagnostic(k))
1963            .collect::<Vec<_>>()
1964            .join(", ");
1965        p.warn(format!(
1966            "unknown {label}field(s) preserved under schema_version {schema_version} \
1967             (forward-compat): [{keys}]"
1968        ));
1969    }
1970    extra_fields
1971}
1972
1973/// Merge the contents of a reserved `extra_fields` block (a hand-authored, or
1974/// defensively a re-fed canonical, mapping under the reserved `extra_fields` key)
1975/// into `out`, upholding the never-drop invariant for that block rather than
1976/// silently discarding it now that the key is reserved in `known`. A non-mapping
1977/// value, or a non-string key inside it, is a structural error (same rationale as
1978/// the sibling scan in [`capture_unknown_fields`]). Sibling-key collisions are
1979/// detected back in the caller, after this has seeded `out`.
1980fn merge_reserved_extra_fields(
1981    v: &Value,
1982    scope: CaptureScope,
1983    out: &mut serde_json::Map<String, serde_json::Value>,
1984    p: &mut Problems,
1985) {
1986    let label = scope.label();
1987    match v {
1988        Value::Null => {}
1989        Value::Mapping(inner) => {
1990            for (k, val) in inner {
1991                match k {
1992                    Value::String(key) => {
1993                        let path = scope.reserved_extra_fields_path(key);
1994                        match yaml_to_json(val, &path) {
1995                            Ok(value) => {
1996                                out.insert(key.clone(), value);
1997                            }
1998                            Err(error) => p.err(error),
1999                        }
2000                    }
2001                    other => p.err(format!(
2002                        "reserved '{label}extra_fields' block has a non-string key {} — its keys \
2003                         must be strings",
2004                        yaml_display(other)
2005                    )),
2006                }
2007            }
2008        }
2009        other => p.err(format!(
2010            "reserved '{label}extra_fields' must be a mapping when present, got {}",
2011            yaml_display(other)
2012        )),
2013    }
2014}
2015
2016/// Convert an arbitrary YAML value to JSON, for `extra_fields` preservation.
2017///
2018/// JSON objects only admit string keys. Rather than coercing an arbitrary YAML
2019/// mapping key (which can collapse distinct values such as `42` and `"42"`),
2020/// reject it with the preserved field path. That fail-closed behavior upholds the
2021/// never-drop guarantee for mapping keys without changing the canonical JSON
2022/// output shape.
2023fn yaml_to_json(v: &Value, path: &str) -> Result<serde_json::Value, String> {
2024    use serde_json::Value as J;
2025    match v {
2026        Value::Null => Ok(J::Null),
2027        Value::Bool(b) => Ok(J::Bool(*b)),
2028        Value::Number(n) => {
2029            if let Some(i) = n.as_i64() {
2030                Ok(J::from(i))
2031            } else if let Some(u) = n.as_u64() {
2032                Ok(J::from(u))
2033            } else if let Some(f) = n.as_f64() {
2034                Ok(serde_json::Number::from_f64(f).map_or(J::Null, J::Number))
2035            } else {
2036                Ok(J::Null)
2037            }
2038        }
2039        Value::String(s) => Ok(J::String(s.clone())),
2040        Value::Sequence(seq) => seq
2041            .iter()
2042            .enumerate()
2043            .map(|(index, value)| yaml_to_json(value, &format!("{path}[{index}]")))
2044            .collect::<Result<Vec<_>, _>>()
2045            .map(J::Array),
2046        Value::Mapping(m) => {
2047            let mut obj = serde_json::Map::new();
2048            for (k, val) in m {
2049                let Value::String(key) = k else {
2050                    return Err(format!(
2051                        "preserved content at {path} has non-string mapping key {}; \
2052                         canonical JSON object keys must be strings to preserve every value. \
2053                         Quote the key in YAML or replace it with a string key",
2054                        yaml_display(k)
2055                    ));
2056                };
2057                let child_path = format!("{path}[{}]", quote_for_diagnostic(key));
2058                obj.insert(key.clone(), yaml_to_json(val, &child_path)?);
2059            }
2060            Ok(J::Object(obj))
2061        }
2062        Value::Tagged(t) => yaml_to_json(&t.value, path),
2063    }
2064}
2065
2066#[cfg(test)]
2067mod tests {
2068    use super::*;
2069    use std::collections::{HashMap, HashSet};
2070
2071    /// A fake `Fs` for filesystem-dependent normalization checks.
2072    struct FakeFs {
2073        dirs: HashSet<PathBuf>,
2074        files: HashMap<PathBuf, Vec<u8>>,
2075    }
2076
2077    impl FakeFs {
2078        fn empty() -> Self {
2079            Self {
2080                dirs: HashSet::new(),
2081                files: HashMap::new(),
2082            }
2083        }
2084
2085        fn with_dirs<const N: usize>(dirs: [&str; N]) -> Self {
2086            Self {
2087                dirs: dirs.iter().map(PathBuf::from).collect(),
2088                files: HashMap::new(),
2089            }
2090        }
2091
2092        fn with_file(path: &str, content: &str) -> Self {
2093            Self {
2094                dirs: HashSet::new(),
2095                files: HashMap::from([(PathBuf::from(path), content.as_bytes().to_vec())]),
2096            }
2097        }
2098
2099        fn with_files<const N: usize>(files: [(&str, &str); N]) -> Self {
2100            Self {
2101                dirs: HashSet::new(),
2102                files: files
2103                    .iter()
2104                    .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec()))
2105                    .collect(),
2106            }
2107        }
2108    }
2109
2110    impl Fs for FakeFs {
2111        fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
2112            self.files
2113                .get(path)
2114                .cloned()
2115                .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))
2116        }
2117        fn exists(&self, path: &Path) -> bool {
2118            self.dirs.contains(path)
2119        }
2120        fn is_dir(&self, path: &Path) -> bool {
2121            self.dirs.contains(path)
2122        }
2123        fn is_file(&self, path: &Path) -> bool {
2124            self.files.contains_key(path)
2125        }
2126        fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
2127            // The contract normalizer never lists directories.
2128            Ok(Vec::new())
2129        }
2130    }
2131
2132    fn repo() -> &'static Path {
2133        Path::new("/repo")
2134    }
2135
2136    fn norm(text: &str) -> Normalized {
2137        normalize_str(text, repo(), &FakeFs::empty())
2138    }
2139
2140    fn norm_with(text: &str, fs: &dyn Fs) -> Normalized {
2141        normalize_str(text, repo(), fs)
2142    }
2143
2144    fn assert_error_contains(n: &Normalized, needle: &str) {
2145        assert!(
2146            !n.is_valid(),
2147            "expected invalid, got clean normalize: {:?}",
2148            n.contract
2149        );
2150        assert!(
2151            n.problems.errors.iter().any(|e| e.contains(needle)),
2152            "no error contained {needle:?}; errors were {:?}",
2153            n.problems.errors
2154        );
2155    }
2156
2157    const MINIMAL: &str = "---\nstatus: approved\nmaturity: mvp\n---\n";
2158
2159    #[test]
2160    fn materializes_all_defaults() {
2161        let c = norm(MINIMAL).contract;
2162        // Pinned to the literal (not KNOWN_SCHEMA_VERSION) so a future bump is an
2163        // explicit, visible test change rather than silently tracking the constant.
2164        assert_eq!(c.schema_version, 2);
2165        assert_eq!(c.status, Status::Approved);
2166        assert_eq!(c.maturity, Maturity::Mvp);
2167        assert!(c.ecosystems.is_empty());
2168        assert!(c.targets.is_empty());
2169        assert_eq!(c.versioning, VersioningBase::Semver);
2170        assert_eq!(c.versioning_pattern, None);
2171        assert_eq!(c.changelog.mode, ChangelogMode::Curated);
2172        assert_eq!(c.changelog.source, ChangelogSource::Manual);
2173        assert_eq!(c.changelog.fragment_dir, DEFAULT_FRAGMENT_DIR);
2174        assert!(!c.conventional_commits);
2175        assert_eq!(c.release.model, ReleaseModel::Gated);
2176        assert_eq!(c.release.layout, ReleaseLayout::Single);
2177        assert_eq!(c.release.bump_hook, None); // optional, absent by default
2178        assert_eq!(c.contribution_provenance, ContributionProvenance::None);
2179        assert_eq!(c.provenance_level, ProvenanceLevel::None);
2180        assert_eq!(c.dependency_bot, DependencyBot::Dependabot); // mvp default
2181        assert_eq!(c.license, "MIT");
2182        assert_eq!(c.docs_site, DocsSite::None);
2183        // mvp, no publishable target → [ci, license].
2184        assert_eq!(c.health_badges, vec![HealthBadge::Ci, HealthBadge::License]);
2185        assert!(c.extra_fields.is_empty());
2186    }
2187
2188    #[test]
2189    fn parses_a_declared_bump_hook() {
2190        // `release.bump_hook` (facet 3) — the command the engine runs during the bump
2191        // phase to regenerate version-embedding artifacts (e.g. insta snapshots).
2192        let c = norm(
2193            "---\nstatus: approved\nmaturity: mvp\n\
2194             release:\n  model: gated\n  bump_hook: \"cargo insta test --accept\"\n---\n",
2195        )
2196        .contract;
2197        assert_eq!(
2198            c.release.bump_hook.as_deref(),
2199            Some("cargo insta test --accept")
2200        );
2201        // Additive: it round-trips through the canonical JSON when present.
2202        let json = serde_json::to_value(&c).unwrap();
2203        assert_eq!(
2204            json["release"]["bump_hook"],
2205            serde_json::json!("cargo insta test --accept")
2206        );
2207    }
2208
2209    #[test]
2210    fn an_absent_bump_hook_is_omitted_from_canonical_json() {
2211        // The additive superset guarantee: a contract with no hook serializes exactly
2212        // as before — no `bump_hook` key in the release block.
2213        let c = norm(MINIMAL).contract;
2214        let json = serde_json::to_value(&c).unwrap();
2215        assert!(
2216            json["release"].get("bump_hook").is_none(),
2217            "an absent hook must not appear in canonical JSON, got {:?}",
2218            json["release"]
2219        );
2220    }
2221
2222    #[test]
2223    fn an_empty_bump_hook_is_rejected() {
2224        // A present-but-empty command is a configuration error (fail closed), not a
2225        // silently-ignored no-op.
2226        assert_error_contains(
2227            &norm(
2228                "---\nstatus: approved\nmaturity: mvp\n\
2229                 release:\n  model: gated\n  bump_hook: \"   \"\n---\n",
2230            ),
2231            "release.bump_hook must be a non-empty",
2232        );
2233    }
2234
2235    #[test]
2236    fn a_non_string_bump_hook_is_rejected() {
2237        assert_error_contains(
2238            &norm(
2239                "---\nstatus: approved\nmaturity: mvp\n\
2240                 release:\n  model: gated\n  bump_hook: [not, a, string]\n---\n",
2241            ),
2242            "release.bump_hook must be a command string",
2243        );
2244    }
2245
2246    #[test]
2247    fn spike_defaults_no_bot_no_ci_badge() {
2248        let c = norm("---\nstatus: approved\nmaturity: spike\n---\n").contract;
2249        assert_eq!(c.dependency_bot, DependencyBot::None);
2250        assert_eq!(c.health_badges, vec![HealthBadge::License]);
2251    }
2252
2253    #[test]
2254    fn maturity_is_required() {
2255        assert_error_contains(
2256            &norm("---\nstatus: approved\n---\n"),
2257            "maturity is required",
2258        );
2259    }
2260
2261    #[test]
2262    fn expands_targets_from_ecosystems() {
2263        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n---\n").contract;
2264        assert_eq!(c.targets.len(), 1);
2265        assert_eq!(c.targets[0].ecosystem, Ecosystem::Python);
2266        assert_eq!(c.targets[0].package, None);
2267        assert_eq!(c.targets[0].registry, Registry::Pypi);
2268        assert_eq!(c.targets[0].adapter, Adapter::GhActionPypiPublish);
2269    }
2270
2271    /// Option B (publish-target-none): an explicit empty `targets: []` is the
2272    /// author's authoritative "never publish" and is honored as an empty set —
2273    /// NOT re-expanded into the ecosystem default. This is the whole fix: a
2274    /// version-tracked repo with a registry ecosystem but no publish must be
2275    /// expressible.
2276    #[test]
2277    fn explicit_empty_targets_is_honored_not_expanded() {
2278        let n =
2279            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
2280        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2281        let c = n.contract;
2282        // The rust ecosystem is still recorded …
2283        assert_eq!(c.ecosystems, vec![Ecosystem::Rust]);
2284        // … but NO crates.io target is force-expanded: the empty set is honored.
2285        assert!(
2286            c.targets.is_empty(),
2287            "explicit targets:[] must stay empty, got {:?}",
2288            c.targets
2289        );
2290    }
2291
2292    /// The counterpart to the above: OMITTING `targets` keeps the unchanged
2293    /// ecosystem-default expansion. Absent ≠ explicit-empty.
2294    #[test]
2295    fn omitted_targets_still_expands_to_ecosystem_default() {
2296        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
2297        assert_eq!(c.targets.len(), 1);
2298        assert_eq!(c.targets[0].ecosystem, Ecosystem::Rust);
2299        assert_eq!(c.targets[0].registry, Registry::CratesIo);
2300        assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
2301    }
2302
2303    /// A YAML `targets:` with a null value (not a list) is treated as *absent*,
2304    /// not as an explicit empty set — it still expands. Only a genuine empty
2305    /// sequence `[]` is the authoritative "never publish".
2306    #[test]
2307    fn null_targets_expands_like_omitted() {
2308        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n---\n")
2309            .contract;
2310        assert_eq!(c.targets.len(), 1);
2311        assert_eq!(c.targets[0].registry, Registry::CratesIo);
2312    }
2313
2314    /// An empty-targets contract round-trips through canonical JSON unchanged:
2315    /// `targets` serializes as an empty array `[]` (faithfully reporting the
2316    /// never-publish intent, not omitting or defaulting it), and re-feeding that
2317    /// canonical `targets` value back through the normalizer preserves the empty
2318    /// set — the intent survives a normalize→serialize→normalize cycle.
2319    #[test]
2320    fn empty_targets_round_trips_through_canonical_json() {
2321        let n =
2322            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
2323        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2324        let json = serde_json::to_value(&n.contract).unwrap();
2325        // The canonical output faithfully reports the empty set as `[]`.
2326        assert_eq!(json["targets"], serde_json::json!([]));
2327
2328        // Re-feed the canonical `targets` value as frontmatter; the empty set is
2329        // preserved (still no expansion), proving the round-trip is stable.
2330        let targets_yaml = serde_yaml::to_string(&json["targets"]).unwrap();
2331        let refed = format!(
2332            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: {}\n---\n",
2333            targets_yaml.trim()
2334        );
2335        let n2 = norm(&refed);
2336        assert!(n2.is_valid(), "errors: {:?}", n2.problems.errors);
2337        assert_eq!(n2.contract.targets, n.contract.targets);
2338        assert!(n2.contract.targets.is_empty());
2339    }
2340
2341    /// Cross-field: an explicit empty `targets: []` skips the registry-license
2342    /// floor (no target → no registry that requires an SPDX license), while a
2343    /// genuinely invalid license is still caught by its OWN check. Locks in that
2344    /// the `!targets.is_empty()` gate on the floor keeps honoring an empty set.
2345    #[test]
2346    fn explicit_empty_targets_skips_registry_license_floor() {
2347        let n = norm(
2348            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
2349             license: not-a-real-spdx-id\n---\n",
2350        );
2351        // The bad license is still invalid on its own …
2352        assert!(!n.is_valid());
2353        // … but the registry-requires-license FLOOR must NOT fire — there is no
2354        // registry target to trigger it.
2355        assert!(
2356            !n.problems
2357                .errors
2358                .iter()
2359                .any(|e| e.contains("floor: a target has a registry")),
2360            "registry-license floor fired despite empty targets: {:?}",
2361            n.problems.errors
2362        );
2363    }
2364
2365    /// Cross-field: forcing a `registry` health badge while declaring `targets: []`
2366    /// is a floor error — the badge has no producer (no registry to publish to).
2367    /// The empty set is honored, and the badge/target consistency floor still
2368    /// guards against a badge with nothing behind it.
2369    #[test]
2370    fn registry_badge_with_explicit_empty_targets_fails() {
2371        let n = norm(
2372            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
2373             health_badges: [registry, license]\n---\n",
2374        );
2375        assert_error_contains(&n, "health_badge 'registry' has no producer");
2376    }
2377
2378    /// The expansion-skip is independent of the `ecosystems` list: an explicit
2379    /// `targets: []` with NO ecosystems is still an honored empty set (and, like
2380    /// the minimal contract, defaults its badges to [ci, license] — no registry
2381    /// badge without a target).
2382    #[test]
2383    fn explicit_empty_targets_with_no_ecosystems() {
2384        let n = norm("---\nstatus: approved\nmaturity: mvp\ntargets: []\n---\n");
2385        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2386        assert!(n.contract.targets.is_empty());
2387        assert_eq!(
2388            n.contract.health_badges,
2389            vec![HealthBadge::Ci, HealthBadge::License]
2390        );
2391    }
2392
2393    // ── Cargo `publish` cross-read (publish-none supporting evidence) ────────
2394
2395    const PUBLISH_NONE: &str =
2396        "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n";
2397    const RUST_DEFAULT: &str = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n";
2398
2399    /// The intactl/intakectl case end to end: a private service whose `Cargo.toml`
2400    /// sets `publish = false` and whose contract declares `targets: []`. The manifest
2401    /// CONFIRMS the declaration, so the contract normalizes clean and silent — no
2402    /// phantom crates.io target, no error, and no warning.
2403    #[test]
2404    fn publish_none_confirmed_by_publish_false_normalizes_silently() {
2405        let fs = FakeFs::with_file(
2406            "/repo/Cargo.toml",
2407            "[package]\nname = \"intakectl\"\nversion = \"0.1.0\"\npublish = false\n",
2408        );
2409        let n = norm_with(PUBLISH_NONE, &fs);
2410        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2411        assert!(n.contract.targets.is_empty());
2412        assert!(
2413            !n.problems
2414                .warnings
2415                .iter()
2416                .any(|w| w.contains("publish-none")),
2417            "publish = false is the supporting evidence — nothing to warn about: {:?}",
2418            n.problems.warnings
2419        );
2420    }
2421
2422    /// Publish-none declared in the contract but NOT backed by the manifest: the
2423    /// contract is still honored (valid, empty target set), and a warning names the
2424    /// manifest that leaves an accidental `cargo publish` possible.
2425    #[test]
2426    fn publish_none_without_publish_false_warns_with_the_manifest_path() {
2427        let fs = FakeFs::with_file(
2428            "/repo/Cargo.toml",
2429            "[package]\nname = \"intakectl\"\nversion = \"0.1.0\"\n",
2430        );
2431        let n = norm_with(PUBLISH_NONE, &fs);
2432        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2433        assert!(n.contract.targets.is_empty());
2434        let warning = n
2435            .problems
2436            .warnings
2437            .iter()
2438            .find(|w| w.contains("publish-none"))
2439            .unwrap_or_else(|| panic!("no publish-none warning in {:?}", n.problems.warnings));
2440        assert!(warning.contains("Cargo.toml"), "warning was {warning:?}");
2441    }
2442
2443    /// The contradiction, via the DEFAULT expansion: a repo that never publishes but
2444    /// omits `targets` gets the ecosystem-default crates.io target — which its
2445    /// `publish = false` manifest can never satisfy. That is the phantom target the
2446    /// issue is about, and it is now a hard error pointing at `targets: []`.
2447    #[test]
2448    fn expanded_crates_io_target_contradicted_by_publish_false_is_an_error() {
2449        let fs = FakeFs::with_file(
2450            "/repo/Cargo.toml",
2451            "[package]\nname = \"intakectl\"\nversion = \"0.1.0\"\npublish = false\n",
2452        );
2453        let n = norm_with(RUST_DEFAULT, &fs);
2454        assert_error_contains(&n, "forbids publishing");
2455        assert!(
2456            n.problems.errors.iter().any(|e| e.contains("targets: []")),
2457            "the error must name the publish-none escape: {:?}",
2458            n.problems.errors
2459        );
2460        assert!(
2461            n.problems
2462                .errors
2463                .iter()
2464                .any(|e| e.contains("shipshape facts --json") && e.contains("data.cargo_publish")),
2465            "the error must point to the inspectable evidence: {:?}",
2466            n.problems.errors
2467        );
2468    }
2469
2470    /// A NAMED target is contradicted by its own member manifest, and only by that
2471    /// one: the workspace's other, publishable crate does not rescue it.
2472    #[test]
2473    fn named_crates_io_target_contradicted_by_its_member_manifest_is_an_error() {
2474        let fs = FakeFs::with_files([
2475            (
2476                "/repo/Cargo.toml",
2477                "[workspace]\nmembers = [\"a\", \"b\"]\n",
2478            ),
2479            (
2480                "/repo/a/Cargo.toml",
2481                "[package]\nname = \"a\"\nversion = \"1.0.0\"\n",
2482            ),
2483            (
2484                "/repo/b/Cargo.toml",
2485                "[package]\nname = \"b\"\nversion = \"1.0.0\"\npublish = false\n",
2486            ),
2487        ]);
2488        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2489                    targets:\n  - ecosystem: rust\n    package: b\n    registry: crates.io\n---\n";
2490        let n = norm_with(text, &fs);
2491        assert_error_contains(&n, "b/Cargo.toml");
2492
2493        // The publishable sibling declares cleanly.
2494        let text_a = text.replace("package: b", "package: a");
2495        let n_a = norm_with(&text_a, &fs);
2496        assert!(n_a.is_valid(), "errors: {:?}", n_a.problems.errors);
2497    }
2498
2499    /// The CI-DELEGATED publish is not publish-none: `cargo-publish-ci` still puts the
2500    /// crate on crates.io (CI runs `cargo publish` on the tag), so `publish = false`
2501    /// contradicts it exactly as it contradicts the engine-run form — and it must never
2502    /// be mistaken for the no-target case, whose warning would be nonsense here.
2503    #[test]
2504    fn ci_delegated_crates_io_target_is_contradicted_by_publish_false_too() {
2505        let fs = FakeFs::with_file(
2506            "/repo/Cargo.toml",
2507            "[package]\nname = \"tool\"\nversion = \"1.0.0\"\npublish = false\n",
2508        );
2509        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n  \
2510                    - ecosystem: rust\n    package: tool\n    registry: crates.io\n    \
2511                    adapter: cargo-publish-ci\n---\n";
2512        let n = norm_with(text, &fs);
2513        assert_error_contains(&n, "forbids publishing");
2514        assert!(
2515            !n.problems
2516                .warnings
2517                .iter()
2518                .any(|w| w.contains("publish-none")),
2519            "a delegated publish is not publish-none: {:?}",
2520            n.problems.warnings
2521        );
2522    }
2523
2524    /// Publish-none must mean nothing is published by ANYONE: a distribution block
2525    /// alongside an empty target set is floored, because the engine would cut it as
2526    /// tag-only while the pushed tag triggers cargo-dist to publish binaries the run
2527    /// never planned or verified. This floor is what makes an empty target set a
2528    /// sound publish-none signal for the coordinator.
2529    #[test]
2530    fn publish_none_with_a_distribution_block_is_a_floor_error() {
2531        let n = norm(
2532            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
2533             distribution:\n  adapter: cargo-dist\n  gh_releases: true\n---\n",
2534        );
2535        assert_error_contains(&n, "but targets is empty");
2536    }
2537
2538    /// The same floor catches the shape that has no ecosystems either — an empty
2539    /// target set from ANY route is incompatible with a declared binary surface.
2540    #[test]
2541    fn a_distribution_block_without_any_target_is_a_floor_error() {
2542        let n = norm(
2543            "---\nstatus: approved\nmaturity: mvp\n\
2544             distribution:\n  adapter: cargo-dist\n  gh_releases: true\n---\n",
2545        );
2546        assert_error_contains(&n, "but targets is empty");
2547    }
2548
2549    /// A repo that publishes BINARIES but not crates (a `gh-releases`/`cargo-dist`
2550    /// target) is not publish-none: it must not be told to set `publish = false`,
2551    /// which would be wrong advice and can break cargo-dist packaging.
2552    #[test]
2553    fn a_binary_only_rust_repo_gets_no_publish_none_warning() {
2554        let fs = FakeFs::with_file(
2555            "/repo/Cargo.toml",
2556            "[package]\nname = \"tool\"\nversion = \"1.0.0\"\n",
2557        );
2558        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n  \
2559                    - ecosystem: rust\n    package: tool\n    registry: gh-releases\n    \
2560                    adapter: cargo-dist\n---\ndistribution:\n";
2561        let n = norm_with(text, &fs);
2562        assert!(
2563            !n.problems
2564                .warnings
2565                .iter()
2566                .any(|w| w.contains("publish-none")),
2567            "a binary-publishing repo was called publish-none: {:?}",
2568            n.problems.warnings
2569        );
2570    }
2571
2572    /// A manifest whose `publish` the reader could not resolve is NOT evidence.
2573    /// `publish.workspace = true` with no `[workspace.package]` to inherit from is
2574    /// `Unknown`: it neither contradicts a declared target nor counts as "unguarded".
2575    #[test]
2576    fn an_unresolvable_publish_key_produces_no_diagnostic_in_either_direction() {
2577        let fs = FakeFs::with_files([
2578            ("/repo/Cargo.toml", "[workspace]\nmembers = [\"a\"]\n"),
2579            (
2580                "/repo/a/Cargo.toml",
2581                "[package]\nname = \"a\"\nversion = \"1.0.0\"\npublish.workspace = true\n",
2582            ),
2583        ]);
2584        // Declared target: not contradicted (Unknown is not Forbidden).
2585        let declared = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n  \
2586                        - ecosystem: rust\n    package: a\n    registry: crates.io\n---\n";
2587        let n = norm_with(declared, &fs);
2588        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2589        // Publish-none: not warned about (Unknown is not Allowed).
2590        let n_none = norm_with(PUBLISH_NONE, &fs);
2591        assert!(
2592            !n_none
2593                .problems
2594                .warnings
2595                .iter()
2596                .any(|w| w.contains("publish-none")),
2597            "an unresolved publish key was reported as unguarded: {:?}",
2598            n_none.problems.warnings
2599        );
2600    }
2601
2602    /// Inherited `publish = false` (the modern single-place layout) IS resolved, so
2603    /// a publish-none repo using it is confirmed rather than falsely warned.
2604    #[test]
2605    fn inherited_publish_false_is_supporting_evidence_for_publish_none() {
2606        let fs = FakeFs::with_files([
2607            (
2608                "/repo/Cargo.toml",
2609                "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\npublish = false\n",
2610            ),
2611            (
2612                "/repo/a/Cargo.toml",
2613                "[package]\nname = \"a\"\nversion = \"1.0.0\"\npublish.workspace = true\n",
2614            ),
2615        ]);
2616        let n = norm_with(PUBLISH_NONE, &fs);
2617        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2618        assert!(
2619            !n.problems
2620                .warnings
2621                .iter()
2622                .any(|w| w.contains("publish-none")),
2623            "inherited publish = false was not read as evidence: {:?}",
2624            n.problems.warnings
2625        );
2626    }
2627
2628    /// A member's `publish = false` behind a publishable HYBRID root is still seen:
2629    /// the evidence read covers the root package AND the members.
2630    #[test]
2631    fn a_blocked_member_under_a_hybrid_root_still_contradicts_its_target() {
2632        let fs = FakeFs::with_files([
2633            (
2634                "/repo/Cargo.toml",
2635                "[package]\nname = \"root\"\nversion = \"1.0.0\"\n\n\
2636                 [workspace]\nmembers = [\"cli\"]\n",
2637            ),
2638            (
2639                "/repo/cli/Cargo.toml",
2640                "[package]\nname = \"cli\"\nversion = \"1.0.0\"\npublish = false\n",
2641            ),
2642        ]);
2643        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n  \
2644                    - ecosystem: rust\n    package: cli\n    registry: crates.io\n---\n";
2645        assert_error_contains(&norm_with(text, &fs), "cli/Cargo.toml");
2646    }
2647
2648    /// Evidence-gated: no readable `Cargo.toml` means no evidence, so neither
2649    /// diagnostic fires. Absence of evidence is never evidence of absence — and this
2650    /// is what keeps every fixture-driven contract (and any non-checkout consumer)
2651    /// unaffected.
2652    #[test]
2653    fn cargo_publish_cross_read_is_silent_without_a_manifest() {
2654        let n = norm(PUBLISH_NONE);
2655        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2656        assert!(
2657            n.problems.warnings.is_empty(),
2658            "warnings without evidence: {:?}",
2659            n.problems.warnings
2660        );
2661        assert!(norm(RUST_DEFAULT).is_valid());
2662    }
2663
2664    /// A target naming a package no manifest declares is unmatched, not contradicted:
2665    /// the cross-read stays silent rather than guessing which manifest governs it.
2666    #[test]
2667    fn cargo_publish_cross_read_ignores_an_unmatched_package() {
2668        let fs = FakeFs::with_file(
2669            "/repo/Cargo.toml",
2670            "[package]\nname = \"other\"\nversion = \"1.0.0\"\npublish = false\n",
2671        );
2672        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n  \
2673                    - ecosystem: rust\n    package: unrelated\n    registry: crates.io\n---\n";
2674        let n = norm_with(text, &fs);
2675        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2676    }
2677
2678    /// A non-rust contract is out of scope even with a `publish = false` manifest
2679    /// lying around: the key governs crates.io, and nothing else.
2680    #[test]
2681    fn cargo_publish_cross_read_does_not_touch_a_non_rust_contract() {
2682        let fs = FakeFs::with_file(
2683            "/repo/Cargo.toml",
2684            "[package]\nname = \"tool\"\nversion = \"1.0.0\"\npublish = false\n",
2685        );
2686        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [node]\n---\n";
2687        let n = norm_with(text, &fs);
2688        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2689        assert!(
2690            !n.problems
2691                .warnings
2692                .iter()
2693                .any(|w| w.contains("publish-none")),
2694            "warnings: {:?}",
2695            n.problems.warnings
2696        );
2697    }
2698
2699    #[test]
2700    fn node_monorepo_adapter_is_changesets() {
2701        let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\n\
2702                    release:\n  model: gated\n  layout: monorepo\n---\n";
2703        let c = norm(text).contract;
2704        assert_eq!(c.targets[0].adapter, Adapter::Changesets);
2705    }
2706
2707    #[test]
2708    fn ecosystems_dedup_to_canonical_order() {
2709        let c =
2710            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python, rust, python]\n---\n")
2711                .contract;
2712        assert_eq!(c.ecosystems, vec![Ecosystem::Rust, Ecosystem::Python]);
2713    }
2714
2715    #[test]
2716    fn calver_splits_base_and_pattern() {
2717        let c = norm(
2718            "---\nstatus: approved\nmaturity: mvp\nversioning: \"calver:YYYY.MM.MICRO\"\n---\n",
2719        )
2720        .contract;
2721        assert_eq!(c.versioning, VersioningBase::Calver);
2722        assert_eq!(c.versioning_pattern.as_deref(), Some("YYYY.MM.MICRO"));
2723    }
2724
2725    #[test]
2726    fn bare_calver_is_rejected() {
2727        assert_error_contains(
2728            &norm("---\nstatus: approved\nmaturity: mvp\nversioning: calver\n---\n"),
2729            "must carry its pattern",
2730        );
2731    }
2732
2733    #[test]
2734    fn floor_auto_on_spike() {
2735        let text = "---\nstatus: approved\nmaturity: spike\n\
2736                    release:\n  model: auto\n  layout: single\nhealth_badges: [license]\n---\n";
2737        assert_error_contains(&norm(text), "release.model 'auto' is not allowed");
2738    }
2739
2740    #[test]
2741    fn floor_slsa_l3_production_only() {
2742        assert_error_contains(
2743            &norm("---\nstatus: approved\nmaturity: mvp\nprovenance_level: slsa-l3\n---\n"),
2744            "slsa-l3' is production-only",
2745        );
2746    }
2747
2748    #[test]
2749    fn floor_registry_requires_valid_license() {
2750        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2751                    license: Proprietary-Acme\nhealth_badges: [ci, registry, license]\n---\n";
2752        let n = norm(text);
2753        // Both the SPDX-validity error and the registry-needs-license floor fire.
2754        assert_error_contains(&n, "not a valid SPDX expression");
2755        assert!(n
2756            .problems
2757            .errors
2758            .iter()
2759            .any(|e| e.contains("floor: a target has a registry")));
2760    }
2761
2762    #[test]
2763    fn floor_badge_without_producer() {
2764        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n\
2765                    health_badges: [ci, coverage]\n---\n";
2766        assert_error_contains(&norm(text), "health_badge 'coverage' has no producer");
2767    }
2768
2769    #[test]
2770    fn floor_schema_version_too_new() {
2771        assert_error_contains(
2772            &norm("---\nschema_version: 99\nstatus: approved\nmaturity: mvp\n---\n"),
2773            "exceeds what this tool knows",
2774        );
2775    }
2776
2777    #[test]
2778    fn floor_fragment_dir_escape() {
2779        let text = "---\nstatus: approved\nmaturity: mvp\n\
2780                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: /etc\n---\n";
2781        assert_error_contains(&norm(text), "must be a relative path inside the repo");
2782    }
2783
2784    #[test]
2785    fn floor_fragment_dir_escape_relative_root() {
2786        // Regression: with a *relative* repo root (the CLI's `--repo-root .`),
2787        // a `../`-escaping fragment_dir must still be rejected. The earlier
2788        // join-then-contain check accepted it because a `.` root normalizes to
2789        // an empty path that prefixes everything.
2790        let text = "---\nstatus: approved\nmaturity: mvp\n\
2791                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: ../etc\n---\n";
2792        let n = normalize_str(text, Path::new("."), &FakeFs::empty());
2793        assert_error_contains(&n, "must be a relative path inside the repo");
2794    }
2795
2796    #[test]
2797    fn path_inside_repo_verdicts() {
2798        // Inside — plain and `.`/`..`-collapsing relative paths that stay in.
2799        assert!(path_inside_repo("changelog/fragments"));
2800        assert!(path_inside_repo("./changelog/fragments"));
2801        assert!(path_inside_repo("a/../fragments"));
2802        assert!(path_inside_repo("")); // the repo root itself
2803                                       // Escapes — absolute, leading `..`, and mid-path `..` that pops out.
2804        assert!(!path_inside_repo("/etc"));
2805        assert!(!path_inside_repo("../etc"));
2806        assert!(!path_inside_repo("a/../../etc"));
2807    }
2808
2809    #[test]
2810    fn unknown_fields_preserved_and_warned() {
2811        let text =
2812            "---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://example.com/x\n---\n";
2813        let n = norm(text);
2814        assert!(n.is_valid());
2815        assert_eq!(
2816            n.contract
2817                .extra_fields
2818                .get("roadmap_url")
2819                .and_then(|v| v.as_str()),
2820            Some("https://example.com/x")
2821        );
2822        assert!(n
2823            .problems
2824            .warnings
2825            .iter()
2826            .any(|w| w.contains("roadmap_url") && w.contains("forward-compat")));
2827    }
2828
2829    #[test]
2830    fn duplicate_key_is_rejected() {
2831        assert_error_contains(
2832            &norm("---\nstatus: approved\nstatus: draft\nmaturity: mvp\n---\n"),
2833            "invalid YAML",
2834        );
2835    }
2836
2837    #[test]
2838    fn missing_frontmatter_is_rejected() {
2839        assert_error_contains(&norm("no frontmatter here\n"), "frontmatter missing");
2840    }
2841
2842    #[test]
2843    fn unclosed_frontmatter_is_rejected() {
2844        assert_error_contains(&norm("---\nstatus: approved\n"), "frontmatter not closed");
2845    }
2846
2847    #[test]
2848    fn invalid_enum_records_error_and_continues() {
2849        // A bad status AND a bad maturity: both surface (multi-error collection).
2850        let n = norm("---\nstatus: bogus\nmaturity: alsobogus\n---\n");
2851        assert!(n.problems.errors.iter().any(|e| e.contains("status")));
2852        assert!(n.problems.errors.iter().any(|e| e.contains("maturity")));
2853    }
2854
2855    #[test]
2856    fn fragment_dir_present_suppresses_advisory() {
2857        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2858                    changelog:\n  mode: fragment\n  source: manual\n---\n";
2859        // The default fragment dir exists → no advisory warning.
2860        let fs = FakeFs::with_dirs(["/repo/changelog/fragments"]);
2861        let n = norm_with(text, &fs);
2862        assert!(n.is_valid());
2863        assert!(
2864            !n.problems
2865                .warnings
2866                .iter()
2867                .any(|w| w.contains("does not exist yet")),
2868            "advisory should be suppressed when the dir exists: {:?}",
2869            n.problems.warnings
2870        );
2871    }
2872
2873    #[test]
2874    fn serializes_to_schema_v4_shape() {
2875        let json =
2876            serde_json::to_value(&norm("---\nstatus: approved\nmaturity: mvp\n---\n").contract)
2877                .unwrap();
2878        // Spot-check the §4 top-level keys that consumers read.
2879        for key in [
2880            "schema_version",
2881            "status",
2882            "maturity",
2883            "ecosystems",
2884            "targets",
2885            "distributions",
2886            "versioning",
2887            "versioning_pattern",
2888            "changelog",
2889            "conventional_commits",
2890            "release",
2891            "contribution_provenance",
2892            "provenance_level",
2893            "dependency_bot",
2894            "health_badges",
2895            "license",
2896            "docs_site",
2897            "warnings",
2898        ] {
2899            assert!(json.get(key).is_some(), "missing §4 key {key}");
2900        }
2901        assert!(json["versioning_pattern"].is_null());
2902        // A registry-only contract carries an explicit empty `distributions: []` —
2903        // the collection is always a JSON array (v2 canonical shape).
2904        assert_eq!(json["distributions"], serde_json::json!([]));
2905        // An EMPTY `extra_fields` is OMITTED from canonical JSON (Option A,
2906        // `skip_serializing_if`): a contract with no unknown keys carries no
2907        // `extra_fields` key at all. It reappears only when populated — see
2908        // [`empty_extra_fields_absent_populated_present`].
2909        assert!(
2910            json.get("extra_fields").is_none(),
2911            "empty extra_fields must be absent, got {:?}",
2912            json.get("extra_fields")
2913        );
2914    }
2915
2916    /// Option A (omit-when-empty), asserted SYMMETRICALLY on both the top-level
2917    /// [`Contract::extra_fields`] and the nested [`Distribution::extra_fields`]:
2918    /// an empty map is ABSENT from canonical JSON, a populated map is PRESENT and
2919    /// byte-for-shape unchanged from before the `skip_serializing_if`.
2920    #[test]
2921    fn empty_extra_fields_absent_populated_present() {
2922        // Empty (both levels): a contract with a distribution but no unknown keys.
2923        let empty = serde_json::to_value(
2924            norm(
2925                "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2926                 distribution:\n  adapter: cargo-dist\n---\n",
2927            )
2928            .contract,
2929        )
2930        .unwrap();
2931        assert!(
2932            empty.get("extra_fields").is_none(),
2933            "empty top-level extra_fields must be absent"
2934        );
2935        assert!(
2936            empty["distributions"][0].get("extra_fields").is_none(),
2937            "empty nested extra_fields must be absent"
2938        );
2939
2940        // Populated (both levels): an unknown top-level key and an unknown
2941        // distribution key are preserved and PRESENT.
2942        let populated = serde_json::to_value(
2943            norm(
2944                "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2945                 roadmap_url: https://example.com/roadmap\n\
2946                 distribution:\n  adapter: cargo-dist\n  future_x: 1\n---\n",
2947            )
2948            .contract,
2949        )
2950        .unwrap();
2951        assert_eq!(
2952            populated["extra_fields"]["roadmap_url"],
2953            "https://example.com/roadmap"
2954        );
2955        assert_eq!(populated["distributions"][0]["extra_fields"]["future_x"], 1);
2956    }
2957
2958    // ── distribution (cargo-dist binary layer) ───────────────────────────────
2959
2960    /// A registry-only contract has no distribution: it normalizes clean and
2961    /// `distributions` is empty.
2962    #[test]
2963    fn registry_only_contract_has_no_distribution() {
2964        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
2965        assert!(c.distributions.is_empty());
2966        assert_eq!(c.targets.len(), 1);
2967        assert_eq!(c.targets[0].registry, Registry::CratesIo);
2968    }
2969
2970    /// A cargo-dist repo: a `distribution` block (binaries + shell/Homebrew
2971    /// installers + a tap) coexisting with a crates.io registry target.
2972    #[test]
2973    fn cargo_dist_distribution_coexists_with_registry() {
2974        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2975                    targets:\n  - {ecosystem: rust, package: issuectl, registry: crates.io, adapter: cargo-publish}\n\
2976                    distribution:\n  adapter: cargo-dist\n  installers: [shell, homebrew]\n  \
2977                    homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
2978        let n = norm(text);
2979        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2980        let c = n.contract;
2981        // The registry publish is still a Target.
2982        assert_eq!(c.targets.len(), 1);
2983        assert_eq!(c.targets[0].registry, Registry::CratesIo);
2984        assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
2985        // The binary layer is the Distribution block.
2986        let d = c
2987            .distributions
2988            .into_iter()
2989            .next()
2990            .expect("distribution present");
2991        assert_eq!(d.adapter, DistributionAdapter::CargoDist);
2992        assert!(d.gh_releases); // default true
2993        assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
2994        assert_eq!(
2995            d.homebrew_tap.as_deref(),
2996            Some("jarimustonen/homebrew-issuectl")
2997        );
2998    }
2999
3000    /// Round-trip: the serialized JSON shape a downstream `/shipshape-*` member reads.
3001    #[test]
3002    fn distribution_json_round_trip_shape() {
3003        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3004                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
3005                    installers: [shell, homebrew]\n  homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
3006        let json = serde_json::to_value(&norm(text).contract).unwrap();
3007        let d = &json["distributions"][0];
3008        assert_eq!(d["adapter"], "cargo-dist");
3009        assert_eq!(d["gh_releases"], true);
3010        assert_eq!(d["installers"], serde_json::json!(["shell", "homebrew"]));
3011        assert_eq!(d["homebrew_tap"], "jarimustonen/homebrew-issuectl");
3012        // A bare (singular) block carries a `null` association key.
3013        assert!(d["package"].is_null());
3014    }
3015
3016    // ── cargo-dist Homebrew drift advisory ───────────────────────────────────
3017
3018    /// A Homebrew configuration in cargo-dist without the authoritative contract
3019    /// tap warns: release planning deliberately reads only the contract.
3020    #[test]
3021    fn dist_workspace_tap_without_contract_tap_warns() {
3022        let fs = FakeFs::with_file(
3023            "/repo/dist-workspace.toml",
3024            "[dist]\ntap = \"owner/homebrew-tool\"\n",
3025        );
3026        let n = norm_with(
3027            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n",
3028            &fs,
3029        );
3030        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3031        assert!(
3032            n.problems
3033                .warnings
3034                .iter()
3035                .any(|w| w.contains("distribution.homebrew_tap")
3036                    && w.contains("will not be planned")),
3037            "expected cargo-dist drift warning: {:?}",
3038            n.problems.warnings
3039        );
3040    }
3041
3042    /// A cargo-dist Homebrew publish job is a real destination and must be modelled
3043    /// as a delegated target so the mandatory verify barrier observes it.
3044    #[test]
3045    fn dist_workspace_homebrew_publish_job_without_target_is_a_floor() {
3046        let fs = FakeFs::with_file(
3047            "/repo/dist-workspace.toml",
3048            "[dist]\npublish-jobs = [\"homebrew\"]\n",
3049        );
3050        let n = norm_with(
3051            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n",
3052            &fs,
3053        );
3054        assert_error_contains(&n, "no delegated Homebrew target");
3055    }
3056
3057    /// A distribution without its own tap still warns when cargo-dist configures one.
3058    #[test]
3059    fn dist_workspace_tap_with_distribution_lacking_tap_warns() {
3060        let fs = FakeFs::with_file(
3061            "/repo/dist-workspace.toml",
3062            "[dist]\ntap = \"owner/homebrew-tool\"\n",
3063        );
3064        let n = norm_with(
3065            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ndistribution:\n  \
3066             adapter: cargo-dist\n  installers: [shell]\n---\n",
3067            &fs,
3068        );
3069        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3070        assert!(
3071            n.problems
3072                .warnings
3073                .iter()
3074                .any(|w| w.contains("distribution.homebrew_tap")),
3075            "expected cargo-dist drift warning: {:?}",
3076            n.problems.warnings
3077        );
3078    }
3079
3080    #[test]
3081    fn dist_workspace_homebrew_publish_job_refuses_an_engine_owned_tap_target() {
3082        let fs = FakeFs::with_file(
3083            "/repo/dist-workspace.toml",
3084            "[dist]\ntap = \"owner/homebrew-tool\"\npublish-jobs = [\"homebrew\"]\n",
3085        );
3086        let n = norm_with(
3087            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n  \
3088             - {ecosystem: rust, package: tool, registry: gh-releases, adapter: cargo-dist}\n  \
3089             - {ecosystem: rust, package: tool, registry: homebrew, adapter: homebrew-tap}\n\
3090             distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  \
3091             homebrew_tap: owner/homebrew-tool\n---\n",
3092            &fs,
3093        );
3094        assert_error_contains(
3095            &n,
3096            "cargo-dist CI and shipshape would both write the same tap",
3097        );
3098    }
3099
3100    #[test]
3101    fn dist_workspace_homebrew_publish_job_accepts_a_delegated_tap_target() {
3102        let fs = FakeFs::with_file(
3103            "/repo/dist-workspace.toml",
3104            "[dist]\ntap = \"owner/homebrew-tool\"\npublish-jobs = [\"homebrew\"]\n",
3105        );
3106        let n = norm_with(
3107            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n  \
3108             - {ecosystem: rust, package: tool, registry: gh-releases, adapter: cargo-dist}\n  \
3109             - {ecosystem: rust, package: tool, registry: homebrew, adapter: cargo-dist}\n\
3110             distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
3111             homebrew_tap: owner/homebrew-tool\n---\n",
3112            &fs,
3113        );
3114        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3115    }
3116
3117    /// When both configs declare the tap but cargo-dist does not publish it, the
3118    /// engine-owned target remains valid (shipshape's own release shape).
3119    #[test]
3120    fn dist_workspace_tap_matching_contract_is_silent() {
3121        let fs = FakeFs::with_file(
3122            "/repo/dist-workspace.toml",
3123            "[dist]\ntap = \"owner/homebrew-tool\"\n",
3124        );
3125        let n = norm_with(
3126            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n  \
3127             - {ecosystem: rust, package: tool, registry: crates.io, adapter: cargo-publish}\n  \
3128             - {ecosystem: rust, package: tool, registry: gh-releases, adapter: cargo-dist}\n  \
3129             - {ecosystem: rust, package: tool, registry: homebrew, adapter: homebrew-tap}\n\
3130             distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  homebrew_tap: owner/homebrew-tool\n---\n",
3131            &fs,
3132        );
3133        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3134        assert!(
3135            n.problems.warnings.is_empty(),
3136            "warnings: {:?}",
3137            n.problems.warnings
3138        );
3139    }
3140
3141    #[test]
3142    fn ci_delegated_homebrew_rejects_a_dist_workspace_tap_mismatch() {
3143        let fs = FakeFs::with_file(
3144            "/repo/dist-workspace.toml",
3145            "[dist]\ntap = \"owner/actual-tap\"\npublish-jobs = [\"homebrew\"]\n",
3146        );
3147        let n = norm_with(
3148            "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3149             - {ecosystem: rust, package: tool, registry: homebrew, adapter: cargo-dist}\n\
3150             distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
3151             homebrew_tap: owner/declared-tap\n---\n",
3152            &fs,
3153        );
3154        assert_error_contains(
3155            &n,
3156            "cargo-dist would write one tap while shipshape verifies another",
3157        );
3158    }
3159
3160    /// An absent cargo-dist config does not add a warning.
3161    #[test]
3162    fn absent_dist_workspace_is_silent() {
3163        let n = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n");
3164        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3165        assert!(
3166            n.problems.warnings.is_empty(),
3167            "warnings: {:?}",
3168            n.problems.warnings
3169        );
3170    }
3171
3172    /// A malformed cargo-dist file still proves that distribution infrastructure
3173    /// exists, so it is advisory rather than fatal but warns when GH Releases is
3174    /// absent from the contract.
3175    #[test]
3176    fn unparseable_dist_workspace_warns_about_missing_gh_releases() {
3177        let fs = FakeFs::with_file("/repo/dist-workspace.toml", "[dist\ntap = \"owner/tap\"");
3178        let n = norm_with(
3179            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n",
3180            &fs,
3181        );
3182        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3183        assert!(
3184            n.problems
3185                .warnings
3186                .iter()
3187                .any(|warning| warning.contains("no 'gh-releases' target")),
3188            "warnings: {:?}",
3189            n.problems.warnings
3190        );
3191    }
3192
3193    // ── homebrew cross-field consistency floors (truth table) ────────────────
3194
3195    /// Build a production contract exercising the three homebrew signals: a
3196    /// configured `tap`, an `installer` producer (a `homebrew` installer), and a
3197    /// `tap_target` producer (a `homebrew`-registry target with adapter
3198    /// `homebrew-tap`). A crates.io target is always present so the contract has a
3199    /// licensed publishable target.
3200    fn hb_case(tap: bool, installer: bool, tap_target: bool) -> String {
3201        let mut fm = String::from(
3202            "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3203             - {ecosystem: rust, package: shipshape, registry: crates.io, adapter: cargo-publish}\n",
3204        );
3205        if tap_target {
3206            fm.push_str(
3207                "  - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: homebrew-tap}\n",
3208            );
3209        }
3210        // A distribution block exists whenever we need to express an installer
3211        // producer or a configured tap; otherwise the contract is registry-only.
3212        if installer || tap {
3213            fm.push_str("distribution:\n  adapter: cargo-dist\n");
3214            if installer {
3215                fm.push_str("  installers: [homebrew]\n");
3216            } else {
3217                fm.push_str("  installers: [shell]\n");
3218            }
3219            if tap {
3220                fm.push_str("  homebrew_tap: owner/tap\n");
3221            }
3222        }
3223        fm.push_str("---\n");
3224        fm
3225    }
3226
3227    /// The full 8-row truth table (tap × installer-producer × tap-target-producer).
3228    /// Each row asserts the accept/reject verdict; the floor/advisory messages are
3229    /// pinned in the focused tests below.
3230    #[test]
3231    fn homebrew_truth_table_all_eight_rows() {
3232        // (tap, installer, tap_target, expect_valid)
3233        let rows = [
3234            (false, false, false, true), // 1: nothing homebrew → clean
3235            (false, false, true, false), // 2: tap-target, no tap → missing-tap floor
3236            (false, true, false, false), // 3: installer, no tap → per-block floor
3237            (false, true, true, false),  // 4: both producers, no tap → floors
3238            (true, false, false, true),  // 5: tap, no producer → cut-time refusal
3239            (true, false, true, true),   // 6: tap + tap-target → well-formed (shipshape's case)
3240            (true, true, false, true),   // 7: tap + installer → well-formed (cargo-dist)
3241            (true, true, true, false),   // 8: tap + both producers → double-publish floor
3242        ];
3243        for (tap, installer, tap_target, expect_valid) in rows {
3244            let n = norm(&hb_case(tap, installer, tap_target));
3245            assert_eq!(
3246                n.is_valid(),
3247                expect_valid,
3248                "row (tap={tap}, installer={installer}, tap_target={tap_target}) expected \
3249                 valid={expect_valid}; errors were {:?}",
3250                n.problems.errors
3251            );
3252        }
3253    }
3254
3255    /// Row 2: a `homebrew-tap` target with no tap anywhere is a hard error (the
3256    /// target-side counterpart of the per-block installer-without-tap floor).
3257    #[test]
3258    fn homebrew_tap_target_without_tap_is_a_floor() {
3259        assert_error_contains(
3260            &norm(&hb_case(false, false, true)),
3261            "needs distribution.homebrew_tap",
3262        );
3263    }
3264
3265    #[test]
3266    fn ci_delegated_homebrew_target_requires_tap_for_ci_and_verify() {
3267        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3268                    - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: cargo-dist}\n---\n";
3269        assert_error_contains(&norm(text), "needs distribution.homebrew_tap");
3270    }
3271
3272    #[test]
3273    fn ci_delegated_homebrew_target_requires_cargo_dist_homebrew_installer() {
3274        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3275                    - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: cargo-dist}\n\
3276                    distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  \
3277                    homebrew_tap: owner/tap\n---\n";
3278        assert_error_contains(
3279            &norm(text),
3280            "requires distribution.installers to include 'homebrew'",
3281        );
3282    }
3283
3284    /// Row 8: an installer producer AND a `homebrew-tap` target both push a formula
3285    /// to the tap — the double-publish collision is a hard error.
3286    #[test]
3287    fn homebrew_double_publish_is_a_floor() {
3288        assert_error_contains(
3289            &norm(&hb_case(true, true, true)),
3290            "they would collide; keep exactly one homebrew formula producer",
3291        );
3292    }
3293
3294    /// Regression for `intake-feature-shipshape-73e870268475`: the field-reported
3295    /// shape had two crates.io targets and a distribution tap, but no Homebrew
3296    /// target. Validation warns and the cut preflight refuses before it can omit
3297    /// the tap leg.
3298    #[test]
3299    fn distribution_tap_without_formula_producer_warns() {
3300        let text = concat!(
3301            "---\n",
3302            "status: approved\n",
3303            "maturity: production\n",
3304            "ecosystems: [rust]\n",
3305            "targets:\n",
3306            "  - {ecosystem: rust, package: project-canon-core, registry: crates.io, adapter: cargo-publish}\n",
3307            "  - {ecosystem: rust, package: project-canon-cli, registry: crates.io, adapter: cargo-publish}\n",
3308            "distribution:\n",
3309            "  adapter: cargo-dist\n",
3310            "  installers: [shell, powershell]\n",
3311            "  homebrew_tap: owner/homebrew-project-canon\n",
3312            "---\n",
3313        );
3314        let n = norm(text);
3315        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3316        assert!(n
3317            .problems
3318            .warnings
3319            .iter()
3320            .any(|warning| warning.contains("tap leg would be silently skipped")));
3321    }
3322
3323    /// Row 6: a `homebrew-tap` target with a configured tap and no installer
3324    /// producer is the well-formed case (shipshape's own shape) — clean.
3325    #[test]
3326    fn homebrew_tap_target_with_tap_is_clean() {
3327        let n = norm(&hb_case(true, false, true));
3328        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3329        assert!(
3330            !n.problems
3331                .warnings
3332                .iter()
3333                .any(|w| w.contains("the tap will never be updated")),
3334            "unexpected dead-tap advisory: {:?}",
3335            n.problems.warnings
3336        );
3337    }
3338
3339    /// The repository's own contract already carries the explicit target, so this
3340    /// safety floor must leave its four-target release path unchanged.
3341    #[test]
3342    fn shipshape_contract_keeps_its_four_explicit_targets() {
3343        let n = norm(include_str!("../../../../OSS-RELEASE.md"));
3344        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3345        assert_eq!(n.contract.targets.len(), 4);
3346        assert!(n.contract.targets.iter().any(|target| {
3347            target.registry == Registry::Homebrew && target.adapter == Adapter::HomebrewTap
3348        }));
3349    }
3350
3351    /// registry/adapter compatibility: a `homebrew`-registry target with a
3352    /// non-homebrew adapter (here the ecosystem default via an explicit `manual`)
3353    /// is a hard error — it has no homebrew formula path.
3354    #[test]
3355    fn homebrew_registry_requires_homebrew_adapter() {
3356        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3357                    - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: manual}\n\
3358                    distribution:\n  adapter: cargo-dist\n  homebrew_tap: owner/tap\n---\n";
3359        assert_error_contains(
3360            &norm(text),
3361            "requires adapter 'homebrew-tap' (personal tap), 'homebrew-core'",
3362        );
3363    }
3364
3365    /// A CI-delegated cargo-dist target declares a real Homebrew surface without
3366    /// making the engine a second tap writer.
3367    #[test]
3368    fn ci_delegated_homebrew_target_normalizes_and_round_trips() {
3369        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3370                    - {ecosystem: rust, package: shipshape, registry: crates.io, adapter: cargo-publish}\n  \
3371                    - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: cargo-dist}\n\
3372                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
3373                    homebrew_tap: owner/tap\n---\n";
3374        let n = norm(text);
3375        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3376        assert!(
3377            n.problems.warnings.is_empty(),
3378            "warnings: {:?}",
3379            n.problems.warnings
3380        );
3381        let target = n
3382            .contract
3383            .targets
3384            .iter()
3385            .find(|target| target.registry == Registry::Homebrew)
3386            .expect("normalized Homebrew target");
3387        assert_eq!(target.adapter, Adapter::CargoDist);
3388        let canonical = serde_json::to_value(&n.contract).unwrap();
3389        assert_eq!(canonical["targets"][1]["adapter"], "cargo-dist");
3390        assert_eq!(canonical["targets"][1]["registry"], "homebrew");
3391    }
3392
3393    /// A CI-delegated crates.io target (`cargo-publish-ci`) is a first-class,
3394    /// round-trippable contract shape: the repo whose crates.io publish runs in a
3395    /// tag-triggered workflow can declare its real publish surface, and the engine
3396    /// reads the delegation off the adapter identity.
3397    #[test]
3398    fn ci_delegated_crates_io_target_normalizes_and_round_trips() {
3399        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3400                    - {ecosystem: rust, package: glasspad, registry: crates.io, adapter: cargo-publish-ci}\n---\n";
3401        let n = norm(text);
3402        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3403        assert!(
3404            n.problems.warnings.is_empty(),
3405            "warnings: {:?}",
3406            n.problems.warnings
3407        );
3408        assert_eq!(n.contract.targets[0].adapter, Adapter::CargoPublishCi);
3409        let canonical = serde_json::to_value(&n.contract).unwrap();
3410        assert_eq!(canonical["targets"][0]["adapter"], "cargo-publish-ci");
3411        assert_eq!(canonical["targets"][0]["registry"], "crates.io");
3412    }
3413
3414    /// A mixed contract — one engine-published crate, one CI-published crate — is
3415    /// valid: delegation is a per-target property, not a repo-wide mode.
3416    #[test]
3417    fn a_mixed_local_and_ci_publish_contract_is_valid() {
3418        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3419                    - {ecosystem: rust, package: lib, registry: crates.io, adapter: cargo-publish}\n  \
3420                    - {ecosystem: rust, package: cli, registry: crates.io, adapter: cargo-publish-ci}\n---\n";
3421        let n = norm(text);
3422        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3423        assert_eq!(n.contract.targets[0].adapter, Adapter::CargoPublish);
3424        assert_eq!(n.contract.targets[1].adapter, Adapter::CargoPublishCi);
3425    }
3426
3427    /// One package, two publishers is a floor: the engine would publish it in
3428    /// publish-all and the tag push would trigger CI to publish it again.
3429    #[test]
3430    fn a_package_cannot_be_declared_with_both_publishers() {
3431        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3432                    - {ecosystem: rust, package: tool, registry: crates.io, adapter: cargo-publish}\n  \
3433                    - {ecosystem: rust, package: tool, registry: crates.io, adapter: cargo-publish-ci}\n---\n";
3434        assert_error_contains(&norm(text), "Keep exactly one publisher for a package");
3435    }
3436
3437    /// `cargo-publish-ci` on a non-crates.io registry is a floor: the delegated
3438    /// publish has no destination the verify barrier knows how to observe, so it
3439    /// would tag first and only then fail — refuse it while nothing has happened.
3440    #[test]
3441    fn ci_delegated_cargo_publish_requires_crates_io() {
3442        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3443                    - {ecosystem: rust, package: tool, registry: gh-releases, adapter: cargo-publish-ci}\n---\n";
3444        assert_error_contains(
3445            &norm(text),
3446            "the CI-delegated cargo publish targets crates.io only",
3447        );
3448    }
3449
3450    /// …and it is a rust adapter: `cargo publish` releases a crate.
3451    #[test]
3452    fn ci_delegated_cargo_publish_requires_the_rust_ecosystem() {
3453        let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\ntargets:\n  \
3454                    - {ecosystem: node, package: tool, registry: crates.io, adapter: cargo-publish-ci}\n---\n";
3455        assert_error_contains(&norm(text), "`cargo publish` releases a rust crate");
3456    }
3457
3458    /// A `homebrew-core` target is a valid homebrew adapter and needs NO personal
3459    /// tap (it bumps the central formula) — it is neither a missing-tap floor nor a
3460    /// dead-tap advisory, and does not collide with a `homebrew` installer.
3461    #[test]
3462    fn homebrew_core_target_needs_no_tap() {
3463        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3464                    - {ecosystem: rust, package: shipshape, registry: crates.io, adapter: cargo-publish}\n  \
3465                    - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: homebrew-core}\n---\n";
3466        let n = norm(text);
3467        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3468    }
3469
3470    /// registry/adapter compat, OMITTED adapter: a `homebrew`-registry target with
3471    /// no adapter resolves to the ecosystem default (`cargo-publish` for rust),
3472    /// which is non-homebrew — so it hits the same floor. The normalizer never
3473    /// registry-defaults a homebrew target to `homebrew-tap` (that would silently
3474    /// choose personal-tap publication over a homebrew-core PR); the author must
3475    /// spell the adapter. This locks the omitted-adapter path, not just explicit
3476    /// `manual`.
3477    #[test]
3478    fn homebrew_registry_omitted_adapter_is_a_floor() {
3479        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3480                    - {ecosystem: rust, package: shipshape, registry: homebrew}\n---\n";
3481        assert_error_contains(
3482            &norm(text),
3483            "requires adapter 'homebrew-tap' (personal tap), 'homebrew-core'",
3484        );
3485    }
3486
3487    /// The homebrew cross-field check reads the plural `distributions:` (Vec) path,
3488    /// not only the singular back-compat mapping: a one-entry `distributions:` list
3489    /// carrying the tap satisfies a `homebrew-tap` target (row 6 via the Vec shape).
3490    #[test]
3491    fn homebrew_tap_target_satisfied_via_plural_distributions() {
3492        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
3493                    - {ecosystem: rust, package: shipshape, registry: crates.io, adapter: cargo-publish}\n  \
3494                    - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: homebrew-tap}\n\
3495                    distributions:\n  \
3496                    - {package: shipshape, adapter: cargo-dist, installers: [shell], homebrew_tap: owner/tap}\n---\n";
3497        let n = norm(text);
3498        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3499        assert!(
3500            !n.problems
3501                .warnings
3502                .iter()
3503                .any(|w| w.contains("the tap will never be updated")),
3504            "unexpected dead-tap advisory: {:?}",
3505            n.problems.warnings
3506        );
3507    }
3508
3509    // ── monorepo: Vec<Distribution> + per-package association ─────────────────
3510
3511    /// Back-compat: a bare singular `distribution:` mapping deserializes as a
3512    /// one-element `distributions` list with a `null` package — the v1 author
3513    /// changes nothing.
3514    #[test]
3515    fn singular_distribution_parses_as_one_element_list() {
3516        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3517                    distribution:\n  adapter: cargo-dist\n---\n";
3518        let c = norm(text).contract;
3519        assert_eq!(c.distributions.len(), 1);
3520        assert_eq!(c.distributions[0].package, None);
3521        assert_eq!(c.distributions[0].adapter, DistributionAdapter::CargoDist);
3522    }
3523
3524    /// A monorepo: a plural `distributions:` sequence, each entry tagged with the
3525    /// package it builds, parses with the per-package association preserved in
3526    /// order (each distribution keeps its own installers/tap).
3527    #[test]
3528    fn plural_distributions_parse_with_per_package_association() {
3529        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3530                    targets:\n  - {ecosystem: rust, package: alpha, registry: crates.io}\n  \
3531                    - {ecosystem: rust, package: beta, registry: crates.io}\n\
3532                    distributions:\n  - {package: alpha, adapter: cargo-dist, installers: [shell]}\n  \
3533                    - {package: beta, adapter: cargo-dist, installers: [homebrew], homebrew_tap: owner/tap}\n---\n";
3534        let n = norm(text);
3535        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3536        let d = n.contract.distributions;
3537        assert_eq!(d.len(), 2);
3538        assert_eq!(d[0].package.as_deref(), Some("alpha"));
3539        assert_eq!(d[0].installers, vec![Installer::Shell]);
3540        assert_eq!(d[1].package.as_deref(), Some("beta"));
3541        assert_eq!(d[1].homebrew_tap.as_deref(), Some("owner/tap"));
3542    }
3543
3544    /// Canonical JSON round-trips for BOTH shapes: the emitted `distributions`
3545    /// array re-feeds as YAML frontmatter and normalizes to the same list — the
3546    /// single (bare `distribution:`) and the monorepo (`distributions:`) cases.
3547    #[test]
3548    fn distributions_canonical_json_round_trip() {
3549        for text in [
3550            "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3551             distribution:\n  adapter: cargo-dist\n  installers: [shell]\n---\n",
3552            "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3553             targets:\n  - {ecosystem: rust, package: a, registry: crates.io}\n  \
3554             - {ecosystem: rust, package: b, registry: crates.io}\n\
3555             distributions:\n  - {package: a, adapter: cargo-dist}\n  \
3556             - {package: b, adapter: goreleaser}\n---\n",
3557        ] {
3558            let first = norm(text).contract;
3559            assert!(!first.distributions.is_empty());
3560            // Re-feed the canonical JSON as the frontmatter of a fresh document.
3561            let json = serde_json::to_value(&first).unwrap();
3562            let refed = format!("---\n{}---\n", serde_yaml::to_string(&json).unwrap());
3563            let second = norm(&refed).contract;
3564            assert_eq!(
3565                first.distributions, second.distributions,
3566                "round-trip drift for: {text}"
3567            );
3568        }
3569    }
3570
3571    /// Declaring BOTH `distribution:` and `distributions:` is ambiguous → error.
3572    #[test]
3573    fn both_distribution_keys_is_an_error() {
3574        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3575                    distribution:\n  adapter: cargo-dist\n\
3576                    distributions:\n  - {package: a, adapter: cargo-dist}\n---\n";
3577        assert_error_contains(&norm(text), "not both");
3578    }
3579
3580    /// A monorepo (≥2 distributions) with an entry missing `package` → floor error
3581    /// (the entries would be indistinguishable).
3582    #[test]
3583    fn multi_distribution_missing_package_is_a_floor_error() {
3584        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3585                    targets:\n  - {ecosystem: rust, package: a, registry: crates.io}\n\
3586                    distributions:\n  - {package: a, adapter: cargo-dist}\n  \
3587                    - {adapter: cargo-dist}\n---\n";
3588        assert_error_contains(&norm(text), "must name the package it builds");
3589    }
3590
3591    /// A monorepo with a duplicate `package` across distributions → floor error.
3592    #[test]
3593    fn multi_distribution_duplicate_package_is_a_floor_error() {
3594        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3595                    distributions:\n  - {package: dup, adapter: cargo-dist}\n  \
3596                    - {package: dup, adapter: goreleaser}\n---\n";
3597        assert_error_contains(&norm(text), "distinct package");
3598    }
3599
3600    /// A v1 document (explicit `schema_version: 1`, singular `distribution:`)
3601    /// normalizes to the v2 canonical shape AND is re-labeled `schema_version: 2` —
3602    /// never a v2 body stamped with a v1 number. The tool reads v1, emits v2.
3603    #[test]
3604    fn v1_document_is_relabeled_to_current_schema_version_on_emit() {
3605        let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
3606                    ecosystems: [rust]\n\
3607                    distribution:\n  adapter: cargo-dist\n---\n";
3608        let n = norm(text);
3609        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3610        // Emitted version is the current one, not the declared 1.
3611        assert_eq!(n.contract.schema_version, 2);
3612        let json = serde_json::to_value(&n.contract).unwrap();
3613        assert_eq!(json["schema_version"], 2);
3614        // …and the shape is the v2 `distributions` array (the singular key parsed).
3615        assert_eq!(json["distributions"].as_array().map(Vec::len), Some(1));
3616    }
3617
3618    /// A whitespace-padded `package` is trimmed before storing — so `"  alpha "`
3619    /// and `"alpha"` are the SAME package to the uniqueness floor and association,
3620    /// not two distinct ones that would slip past the dup-check.
3621    #[test]
3622    fn distribution_package_is_trimmed() {
3623        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3624                    distributions:\n  - {package: '  alpha ', adapter: cargo-dist}\n  \
3625                    - {package: alpha, adapter: goreleaser}\n---\n";
3626        // The two trimmed packages collide → the duplicate-package floor fires.
3627        assert_error_contains(&norm(text), "distinct package");
3628    }
3629
3630    /// A single distribution MAY carry a `package` (no floor below the ≥2
3631    /// threshold) — the association key is optional, not forbidden, for one block.
3632    #[test]
3633    fn single_distribution_may_carry_a_package() {
3634        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3635                    targets:\n  - {ecosystem: rust, package: solo, registry: crates.io}\n\
3636                    distributions:\n  - {package: solo, adapter: cargo-dist}\n---\n";
3637        let n = norm(text);
3638        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3639        assert_eq!(n.contract.distributions[0].package.as_deref(), Some("solo"));
3640    }
3641
3642    /// Forward-compat: an unknown key inside the `distribution` block is preserved
3643    /// under `distribution.extra_fields` (not dropped) and survives a
3644    /// parse→serialize round-trip, mirroring the top-level `extra_fields` capture.
3645    /// A warning reports it once; the known distribution keys are unaffected.
3646    #[test]
3647    fn distribution_unknown_subkey_preserved_and_warned() {
3648        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3649                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
3650                    future_signing: {enabled: true, kms_key: alias/oss}\n---\n";
3651        let n = norm(text);
3652        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3653        let d = n
3654            .contract
3655            .clone()
3656            .distributions
3657            .into_iter()
3658            .next()
3659            .expect("distribution present");
3660        // The unknown sub-key is captured, with its nested value intact.
3661        assert_eq!(
3662            d.extra_fields
3663                .get("future_signing")
3664                .and_then(|v| v.get("kms_key"))
3665                .and_then(|v| v.as_str()),
3666            Some("alias/oss")
3667        );
3668        // Known keys are untouched by the capture.
3669        assert_eq!(d.adapter, DistributionAdapter::CargoDist);
3670        assert!(d.gh_releases);
3671        // It round-trips through the serialized JSON downstream members read.
3672        let json = serde_json::to_value(&n.contract).unwrap();
3673        assert_eq!(
3674            json["distributions"][0]["extra_fields"]["future_signing"]["enabled"],
3675            serde_json::json!(true)
3676        );
3677        // Reported once, scoped to the block, naming the key.
3678        assert!(
3679            n.problems.warnings.iter().any(|w| {
3680                w.contains("unknown distribution field(s) preserved")
3681                    && w.contains("future_signing")
3682            }),
3683            "expected a scoped forward-compat warning: {:?}",
3684            n.problems.warnings
3685        );
3686    }
3687
3688    // ── installer ↔ platform cross-check (warning, not a floor) ──────────────
3689
3690    /// `installers: [msi]` with no Windows triple in `platforms` warns — the MSI
3691    /// installer points at a binary the release never builds. Still valid (warning,
3692    /// not error): the contract is internally consistent, just wasteful.
3693    #[test]
3694    fn msi_installer_without_windows_platform_warns() {
3695        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3696                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
3697                    platforms: [x86_64-apple-darwin, x86_64-unknown-linux-musl]\n---\n";
3698        let n = norm(text);
3699        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3700        assert!(
3701            n.problems
3702                .warnings
3703                .iter()
3704                .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
3705            "expected an msi/Windows cross-check warning: {:?}",
3706            n.problems.warnings
3707        );
3708    }
3709
3710    /// `installers: [msi]` WITH a Windows triple present → no cross-check warning.
3711    #[test]
3712    fn msi_installer_with_windows_platform_no_warning() {
3713        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3714                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
3715                    platforms: [x86_64-pc-windows-msvc]\n---\n";
3716        let n = norm(text);
3717        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3718        assert!(
3719            !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
3720            "unexpected msi cross-check warning: {:?}",
3721            n.problems.warnings
3722        );
3723    }
3724
3725    /// `installers: [homebrew]` with NEITHER a macOS nor a Linux triple warns —
3726    /// the generated formula has nothing to install. (A Windows-only platform set
3727    /// is the only way to strand a `homebrew` installer, since Homebrew serves
3728    /// both macOS and Linux.)
3729    #[test]
3730    fn homebrew_installer_without_darwin_or_linux_warns() {
3731        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3732                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
3733                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
3734                    platforms: [x86_64-pc-windows-msvc]\n---\n";
3735        let n = norm(text);
3736        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3737        assert!(
3738            n.problems
3739                .warnings
3740                .iter()
3741                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
3742            "expected a homebrew/(macOS|Linux) cross-check warning: {:?}",
3743            n.problems.warnings
3744        );
3745    }
3746
3747    /// `installers: [homebrew]` is satisfied by a LINUX triple alone (Linuxbrew) —
3748    /// no darwin triple required. The chosen interpretation: homebrew needs macOS
3749    /// OR Linux, so a Linux-only platform set is coherent, not a warning.
3750    #[test]
3751    fn homebrew_installer_with_linux_only_no_warning() {
3752        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3753                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
3754                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
3755                    platforms: [x86_64-unknown-linux-musl]\n---\n";
3756        let n = norm(text);
3757        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3758        assert!(
3759            !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
3760            "unexpected homebrew cross-check warning for a Linux-only set: {:?}",
3761            n.problems.warnings
3762        );
3763    }
3764
3765    /// npm and shell installers are OS-agnostic: even a platform set that would
3766    /// strand an msi (no Windows) never warns for them.
3767    #[test]
3768    fn npm_and_shell_installers_never_cross_check_warn() {
3769        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust, node]\n\
3770                    distribution:\n  adapter: cargo-dist\n  installers: [shell, npm]\n  \
3771                    platforms: [x86_64-apple-darwin]\n---\n";
3772        let n = norm(text);
3773        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3774        assert!(
3775            !n.problems
3776                .warnings
3777                .iter()
3778                .any(|w| w.contains("nothing to install")),
3779            "OS-agnostic installers must not cross-check warn: {:?}",
3780            n.problems.warnings
3781        );
3782    }
3783
3784    /// A coherent installer/platform set (msi + Windows, homebrew + darwin) emits
3785    /// no cross-check warning.
3786    #[test]
3787    fn coherent_installer_platform_set_no_warning() {
3788        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3789                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, msi]\n  \
3790                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
3791                    platforms: [aarch64-apple-darwin, x86_64-pc-windows-msvc]\n---\n";
3792        let n = norm(text);
3793        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3794        assert!(
3795            !n.problems
3796                .warnings
3797                .iter()
3798                .any(|w| w.contains("nothing to install")),
3799            "coherent set must not warn: {:?}",
3800            n.problems.warnings
3801        );
3802    }
3803
3804    /// shipshape's own contract shape — installers `[shell, powershell]` with a
3805    /// platform set spanning Windows + macOS + Linux — produces no cross-check
3806    /// warning (both installers are agnostic here, and every OS is covered anyway).
3807    #[test]
3808    fn shipshape_own_contract_shape_no_cross_check_warning() {
3809        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3810                    distribution:\n  adapter: cargo-dist\n  installers: [shell, powershell]\n  \
3811                    platforms: [aarch64-apple-darwin, x86_64-apple-darwin, \
3812                    x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, \
3813                    x86_64-pc-windows-msvc]\n---\n";
3814        let n = norm(text);
3815        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3816        assert!(
3817            !n.problems
3818                .warnings
3819                .iter()
3820                .any(|w| w.contains("nothing to install")),
3821            "shipshape's own shape must not cross-check warn: {:?}",
3822            n.problems.warnings
3823        );
3824    }
3825
3826    /// `installers: [msi]` with `platforms` OMITTED warns: the default set
3827    /// (macOS + Linux) carries no Windows triple, so the MSI installs nothing.
3828    /// This is the common footgun — the author added msi but never listed a
3829    /// Windows target.
3830    #[test]
3831    fn msi_installer_with_defaulted_platforms_warns() {
3832        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3833                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n---\n";
3834        let n = norm(text);
3835        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3836        assert!(
3837            n.problems
3838                .warnings
3839                .iter()
3840                .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
3841            "expected an msi/Windows warning against the defaulted platform set: {:?}",
3842            n.problems.warnings
3843        );
3844    }
3845
3846    /// `installers: [msi]` is satisfied by a `*-windows-gnu` triple just as by
3847    /// `*-windows-msvc` — both target the Windows OS. No warning.
3848    #[test]
3849    fn msi_installer_with_windows_gnu_no_warning() {
3850        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3851                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
3852                    platforms: [x86_64-pc-windows-gnu]\n---\n";
3853        let n = norm(text);
3854        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3855        assert!(
3856            !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
3857            "windows-gnu must satisfy msi: {:?}",
3858            n.problems.warnings
3859        );
3860    }
3861
3862    /// `installers: [homebrew]` with an ANDROID-only platform set warns: Android
3863    /// triples (`aarch64-linux-android`) carry `linux` in the *vendor* slot but an
3864    /// `android` OS component — Homebrew/Linuxbrew does not serve Android, so the
3865    /// formula has nothing to install. Regression guard for the positional
3866    /// `triple_os` OS-component match (vs a naive any-component `== "linux"`).
3867    #[test]
3868    fn homebrew_installer_with_android_only_warns() {
3869        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3870                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
3871                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
3872                    platforms: [aarch64-linux-android]\n---\n";
3873        let n = norm(text);
3874        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3875        assert!(
3876            n.problems
3877                .warnings
3878                .iter()
3879                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
3880            "Android-only must strand a homebrew installer: {:?}",
3881            n.problems.warnings
3882        );
3883    }
3884
3885    /// `installers: [homebrew]` with an APPLE-iOS-only set warns: `*-apple-ios`
3886    /// carries an `ios` OS component, not `darwin`, so it is not a macOS target and
3887    /// Homebrew serves neither iOS nor (here) Linux.
3888    #[test]
3889    fn homebrew_installer_with_apple_ios_only_warns() {
3890        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3891                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
3892                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
3893                    platforms: [aarch64-apple-ios]\n---\n";
3894        let n = norm(text);
3895        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3896        assert!(
3897            n.problems
3898                .warnings
3899                .iter()
3900                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
3901            "apple-ios must not satisfy homebrew's macOS need: {:?}",
3902            n.problems.warnings
3903        );
3904    }
3905
3906    /// `installers: [homebrew]` with a macOS-only set (no Linux) is coherent — the
3907    /// isolated darwin case, distinct from the Linux-only test above.
3908    #[test]
3909    fn homebrew_installer_with_macos_only_no_warning() {
3910        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3911                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
3912                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
3913                    platforms: [aarch64-apple-darwin]\n---\n";
3914        let n = norm(text);
3915        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3916        assert!(
3917            !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
3918            "macOS-only must satisfy homebrew: {:?}",
3919            n.problems.warnings
3920        );
3921    }
3922
3923    /// Two stranded installers → two independent warnings. A wasm-only platform
3924    /// set has no OS component any installer supports, so both `msi` and `homebrew`
3925    /// warn (exactly once each — the installer list is de-duped and canonically
3926    /// ordered).
3927    #[test]
3928    fn both_installers_stranded_warn_once_each() {
3929        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3930                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, msi]\n  \
3931                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
3932                    platforms: [wasm32-unknown-unknown]\n---\n";
3933        let n = norm(text);
3934        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3935        let msi = n
3936            .problems
3937            .warnings
3938            .iter()
3939            .filter(|w| w.contains("includes 'msi'"))
3940            .count();
3941        let brew = n
3942            .problems
3943            .warnings
3944            .iter()
3945            .filter(|w| w.contains("includes 'homebrew'"))
3946            .count();
3947        assert_eq!((msi, brew), (1, 1), "warnings: {:?}", n.problems.warnings);
3948    }
3949
3950    /// A malformed triple that happens to contain an OS keyword must NOT drive the
3951    /// cross-check: the block has a parse error (uppercase triple), so the advisory
3952    /// is gated off entirely. Otherwise the misspelled `x86_64-PC-WINDOWS-MSVC`
3953    /// would silently "satisfy" msi and the warning would flip once the author
3954    /// fixed the typo.
3955    #[test]
3956    fn malformed_platform_triple_gates_off_cross_check() {
3957        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3958                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
3959                    platforms: [x86_64-PC-WINDOWS-MSVC]\n---\n";
3960        let n = norm(text);
3961        // The uppercase triple is a hard error → the document is invalid …
3962        assert!(!n.is_valid(), "expected a malformed-triple error");
3963        // … and the cross-check emitted no (misleading) installer/platform warning.
3964        assert!(
3965            !n.problems
3966                .warnings
3967                .iter()
3968                .any(|w| w.contains("nothing to install")),
3969            "cross-check must be gated off while platforms has errors: {:?}",
3970            n.problems.warnings
3971        );
3972    }
3973
3974    /// A distribution block setting EVERY known key carries an empty
3975    /// `extra_fields` map and emits no forward-compat warning — the additive field
3976    /// is shape-neutral for existing contracts. Exercising all of
3977    /// `KNOWN_DISTRIBUTION_KEYS` guards against the allowlist drifting out of sync
3978    /// with the struct (a new known key wrongly captured as "unknown").
3979    #[test]
3980    fn distribution_all_known_keys_has_empty_extra_fields() {
3981        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3982                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
3983                    installers: [shell, homebrew]\n  homebrew_tap: owner/tap\n  \
3984                    platforms: [x86_64-unknown-linux-musl]\n---\n";
3985        let n = norm(text);
3986        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3987        let d = n
3988            .contract
3989            .distributions
3990            .into_iter()
3991            .next()
3992            .expect("distribution present");
3993        assert!(d.extra_fields.is_empty());
3994        assert!(
3995            !n.problems
3996                .warnings
3997                .iter()
3998                .any(|w| w.contains("unknown distribution field(s) preserved")),
3999            "no forward-compat warning for an all-known-keys block: {:?}",
4000            n.problems.warnings
4001        );
4002    }
4003
4004    /// Top-level and nested `extra_fields` capture are independent: a contract
4005    /// with BOTH an unknown top-level key AND an unknown distribution sub-key
4006    /// populates both maps and warns once for each, with the correct
4007    /// `schema_version` in each message.
4008    #[test]
4009    fn distribution_and_top_level_extra_fields_coexist() {
4010        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4011                    roadmap_url: https://example.com/x\n\
4012                    distribution:\n  adapter: cargo-dist\n  future_x: 1\n---\n";
4013        let n = norm(text);
4014        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4015        let c = n.contract.clone();
4016        assert!(c.extra_fields.contains_key("roadmap_url"));
4017        let d = c
4018            .distributions
4019            .into_iter()
4020            .next()
4021            .expect("distribution present");
4022        assert_eq!(d.extra_fields.get("future_x"), Some(&serde_json::json!(1)));
4023        // Two independent forward-compat warnings, each naming schema_version 2
4024        // (the contract omits schema_version → defaults to KNOWN_SCHEMA_VERSION).
4025        let fc: Vec<&String> = n
4026            .problems
4027            .warnings
4028            .iter()
4029            .filter(|w| w.contains("forward-compat") && w.contains("schema_version 2"))
4030            .collect();
4031        assert_eq!(fc.len(), 2, "expected two versioned warnings: {fc:?}");
4032    }
4033
4034    // ── extra_fields capture hardening ───────────────────────────────────────
4035
4036    /// A non-string top-level mapping key (`42:`, legal YAML) is a STRUCTURAL
4037    /// error, not silently coerced/dropped: distinct non-string keys collapse onto
4038    /// the same JSON key (`42` and `"42"`; every list key onto `<list>`), so
4039    /// preserving them losslessly is impossible — the normalizer rejects instead,
4040    /// keeping the never-drop invariant vacuously.
4041    #[test]
4042    fn non_string_top_level_key_rejected() {
4043        let n = norm("---\nstatus: approved\nmaturity: mvp\n42: answer\n---\n");
4044        assert_error_contains(&n, "must be a string");
4045        assert!(
4046            n.problems.errors.iter().any(|e| e.contains("42")),
4047            "error should name the offending key: {:?}",
4048            n.problems.errors
4049        );
4050    }
4051
4052    /// The nested `distribution` scan rejects the same way, with the block scope in
4053    /// the message.
4054    #[test]
4055    fn non_string_distribution_key_rejected() {
4056        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4057                    distribution:\n  adapter: cargo-dist\n  true: enabled\n---\n";
4058        let n = norm(text);
4059        assert_error_contains(&n, "must be a string");
4060        assert!(
4061            n.problems
4062                .errors
4063                .iter()
4064                .any(|e| e.contains("distribution field key")),
4065            "error should be scoped to the distribution block: {:?}",
4066            n.problems.errors
4067        );
4068    }
4069
4070    /// Nested non-string YAML keys cannot be represented as canonical JSON object
4071    /// keys without loss, so a numeric/string collision fails closed instead of
4072    /// silently overwriting either preserved value.
4073    #[test]
4074    fn extra_fields_nested_numeric_string_key_collision_is_rejected() {
4075        let n =
4076            norm("---\nstatus: approved\nmaturity: mvp\nfuture_x:\n  42: a\n  \"42\": b\n---\n");
4077        assert_error_contains(&n, "non-string mapping key 42");
4078        assert!(
4079            n.problems
4080                .errors
4081                .iter()
4082                .any(|error| error.contains("extra field \"future_x\"")),
4083            "error should name the preserved field path: {:?}",
4084            n.problems.errors
4085        );
4086        assert!(
4087            !n.contract.extra_fields.contains_key("future_x"),
4088            "invalid preserved content must not be partially captured"
4089        );
4090    }
4091
4092    /// A sequence is legal YAML as a mapping key, but has no lossless JSON-object
4093    /// key representation. It must therefore fail closed like scalar non-string
4094    /// keys, with the preserved field path in the diagnostic.
4095    #[test]
4096    fn extra_fields_nested_list_key_is_rejected() {
4097        let n = norm(
4098            "---\nstatus: approved\nmaturity: mvp\nfuture_x:\n  ? [one, two]\n  : list-key\n---\n",
4099        );
4100        assert_error_contains(&n, "non-string mapping key <list>");
4101        assert!(
4102            n.problems
4103                .errors
4104                .iter()
4105                .any(|error| error.contains("extra field \"future_x\"")),
4106            "error should name the preserved field path: {:?}",
4107            n.problems.errors
4108        );
4109    }
4110
4111    /// A non-string key nested several levels down still names its complete path,
4112    /// allowing an AI caller to repair the exact mapping rather than hunting
4113    /// through an arbitrary preserved value.
4114    #[test]
4115    fn extra_fields_deeply_nested_non_string_key_reports_path() {
4116        let n = norm(
4117            "---\nstatus: approved\nmaturity: mvp\nfuture_x:\n  outer:\n    inner:\n      42: answer\n---\n",
4118        );
4119        assert!(
4120            n.problems.errors.iter().any(|error| {
4121                error.contains("extra field \"future_x\"[\"outer\"][\"inner\"]")
4122                    && error.contains("non-string mapping key 42")
4123            }),
4124            "error should name the complete nested path: {:?}",
4125            n.problems.errors
4126        );
4127    }
4128
4129    /// All-string mapping keys retain their existing JSON representation, including
4130    /// keys nested inside sequences, so the fail-closed guard is shape-neutral for
4131    /// valid forward-compatible content.
4132    #[test]
4133    fn extra_fields_nested_string_keys_round_trip_unchanged() {
4134        let n = norm(
4135            "---\nstatus: approved\nmaturity: mvp\nfuture_x:\n  outer:\n    answer: 42\n    items:\n      - name: first\n        enabled: true\n---\n",
4136        );
4137        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4138        assert_eq!(
4139            n.contract.extra_fields.get("future_x"),
4140            Some(&serde_json::json!({
4141                "outer": {
4142                    "answer": 42,
4143                    "items": [{"name": "first", "enabled": true}],
4144                }
4145            }))
4146        );
4147    }
4148
4149    /// The three capture contexts all propagate nested conversion failures with
4150    /// their own useful root path, including a sequence index where applicable.
4151    #[test]
4152    fn nested_non_string_key_paths_cover_all_capture_contexts() {
4153        let reserved = norm(
4154            "---\nstatus: approved\nmaturity: mvp\nextra_fields:\n  future_x:\n    42: answer\n---\n",
4155        );
4156        assert!(
4157            reserved.problems.errors.iter().any(|error| {
4158                error.contains("reserved 'extra_fields' field \"future_x\"")
4159                    && error.contains("non-string mapping key 42")
4160            }),
4161            "reserved-block path missing: {:?}",
4162            reserved.problems.errors
4163        );
4164
4165        let distribution = norm(
4166            "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ndistribution:\n  adapter: cargo-dist\n  future_x:\n    42: answer\n---\n",
4167        );
4168        assert!(
4169            distribution.problems.errors.iter().any(|error| {
4170                error.contains("distribution extra field \"future_x\"")
4171                    && error.contains("non-string mapping key 42")
4172            }),
4173            "distribution path missing: {:?}",
4174            distribution.problems.errors
4175        );
4176
4177        let sequence = norm(
4178            "---\nstatus: approved\nmaturity: mvp\nfuture_x:\n  - ok: first\n  - 42: answer\n---\n",
4179        );
4180        assert!(
4181            sequence.problems.errors.iter().any(|error| {
4182                error.contains("extra field \"future_x\"[1]")
4183                    && error.contains("non-string mapping key 42")
4184            }),
4185            "sequence index path missing: {:?}",
4186            sequence.problems.errors
4187        );
4188    }
4189
4190    /// A known key placed normally is parsed as its field and NOT double-captured
4191    /// into `extra_fields` — the dedupe guarantee (a key is never both a known
4192    /// field and an extra field).
4193    #[test]
4194    fn known_key_not_double_captured() {
4195        let n = norm("---\nstatus: approved\nmaturity: production\necosystems: [rust]\n---\n");
4196        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4197        assert!(!n.contract.extra_fields.contains_key("ecosystems"));
4198        assert!(!n.contract.extra_fields.contains_key("status"));
4199        assert!(n.contract.extra_fields.is_empty());
4200    }
4201
4202    /// The reserved `extra_fields` metadata key is not re-captured into a nested
4203    /// `extra_fields.extra_fields`; its mapping contents are MERGED back, so a
4204    /// hand-authored (or defensively re-fed canonical) block round-trips losslessly
4205    /// rather than being silently dropped. The derived `warnings` key is ignored
4206    /// (regenerated), not preserved — it is not user contract data.
4207    #[test]
4208    fn reserved_extra_fields_block_merged_warnings_ignored() {
4209        let text = "---\nstatus: approved\nmaturity: mvp\n\
4210                    extra_fields:\n  foo: 1\nwarnings:\n  - a prior note\n---\n";
4211        let n = norm(text);
4212        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4213        // `foo` is preserved (merged), not nested and not dropped.
4214        assert_eq!(
4215            n.contract.extra_fields.get("foo"),
4216            Some(&serde_json::json!(1))
4217        );
4218        assert!(!n.contract.extra_fields.contains_key("extra_fields"));
4219        // The stale input `warnings` list is not resurrected into the output.
4220        assert!(
4221            !n.contract
4222                .warnings
4223                .iter()
4224                .any(|w| w.contains("a prior note")),
4225            "input warnings must be regenerated, not preserved: {:?}",
4226            n.contract.warnings
4227        );
4228    }
4229
4230    /// The nested analogue: `distribution.extra_fields` is merged back, not nested
4231    /// and not dropped.
4232    #[test]
4233    fn distribution_reserved_extra_fields_block_merged() {
4234        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4235                    distribution:\n  adapter: cargo-dist\n  extra_fields:\n    foo: 1\n---\n";
4236        let n = norm(text);
4237        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4238        let d = n
4239            .contract
4240            .distributions
4241            .into_iter()
4242            .next()
4243            .expect("distribution present");
4244        assert_eq!(d.extra_fields.get("foo"), Some(&serde_json::json!(1)));
4245        assert!(!d.extra_fields.contains_key("extra_fields"));
4246    }
4247
4248    /// Idempotence: normalizing, serializing the canonical `extra_fields` map, and
4249    /// re-feeding it as an `extra_fields` block yields the identical map — the
4250    /// round-trip the reserve+merge design guarantees (no nesting, no loss).
4251    #[test]
4252    fn extra_fields_round_trip_is_idempotent() {
4253        let first = norm("---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://x/y\n---\n");
4254        assert!(first.is_valid(), "errors: {:?}", first.problems.errors);
4255        assert_eq!(first.contract.extra_fields.len(), 1);
4256        // Feed the captured extra_fields back under the reserved key.
4257        let inner = serde_yaml::to_string(&first.contract.extra_fields).unwrap();
4258        let indented = inner
4259            .lines()
4260            .map(|l| format!("  {l}"))
4261            .collect::<Vec<_>>()
4262            .join("\n");
4263        let text =
4264            format!("---\nstatus: approved\nmaturity: mvp\nextra_fields:\n{indented}\n---\n");
4265        let second = norm(&text);
4266        assert!(second.is_valid(), "errors: {:?}", second.problems.errors);
4267        assert_eq!(second.contract.extra_fields, first.contract.extra_fields);
4268    }
4269
4270    /// A key present BOTH inside the reserved `extra_fields` block AND as a sibling
4271    /// unknown top-level key is an ambiguity error — never a silent overwrite of
4272    /// either value (dedupe: a key resolves to exactly one source).
4273    #[test]
4274    fn extra_fields_block_sibling_collision_is_error() {
4275        let text = "---\nstatus: approved\nmaturity: mvp\n\
4276                    extra_fields:\n  dup: 1\ndup: 2\n---\n";
4277        let n = norm(text);
4278        assert_error_contains(&n, "appears both");
4279    }
4280
4281    /// A reserved `extra_fields` value that is not a mapping is a structural error
4282    /// (it can only carry preserved key/value pairs).
4283    #[test]
4284    fn reserved_extra_fields_non_mapping_is_error() {
4285        let n = norm("---\nstatus: approved\nmaturity: mvp\nextra_fields: nonsense\n---\n");
4286        assert_error_contains(&n, "must be a mapping");
4287    }
4288
4289    /// A contract setting EVERY parsed top-level known key carries an empty
4290    /// `extra_fields` and emits no forward-compat warning — the top-level analogue
4291    /// of `distribution_all_known_keys_has_empty_extra_fields`, guarding
4292    /// [`KNOWN_KEYS`] against drifting out of sync with the [`Contract`] struct (a
4293    /// new field whose key is missing here would be wrongly captured as unknown).
4294    #[test]
4295    fn top_level_all_known_keys_has_empty_extra_fields() {
4296        let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
4297                    ecosystems: [rust]\n\
4298                    targets:\n  - {ecosystem: rust, package: x, registry: crates.io, adapter: cargo-publish}\n\
4299                    distribution:\n  adapter: cargo-dist\n\
4300                    versioning: semver\n\
4301                    changelog:\n  mode: curated\n  source: manual\n\
4302                    conventional_commits: false\n\
4303                    release:\n  model: gated\n  layout: single\n\
4304                    contribution_provenance: none\n\
4305                    provenance_level: none\n\
4306                    dependency_bot: dependabot\n\
4307                    health_badges: [ci, registry, license]\n\
4308                    license: MIT\n\
4309                    docs_site: none\n---\n";
4310        let n = norm(text);
4311        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4312        assert!(
4313            n.contract.extra_fields.is_empty(),
4314            "unexpected extra_fields (KNOWN_KEYS drift?): {:?}",
4315            n.contract.extra_fields
4316        );
4317        assert!(
4318            !n.problems
4319                .warnings
4320                .iter()
4321                .any(|w| w.contains("forward-compat")),
4322            "no forward-compat warning for an all-known-keys contract: {:?}",
4323            n.problems.warnings
4324        );
4325    }
4326
4327    /// Installers de-dup into canonical order regardless of source order.
4328    #[test]
4329    fn distribution_installers_dedup_canonical_order() {
4330        let text = "---\nstatus: approved\nmaturity: mvp\n\
4331                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, shell, homebrew]\n  \
4332                    homebrew_tap: owner/tap\n---\n";
4333        let d = norm(text)
4334            .contract
4335            .distributions
4336            .into_iter()
4337            .next()
4338            .unwrap();
4339        assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
4340    }
4341
4342    /// A `homebrew` installer without a tap is a floor error.
4343    #[test]
4344    fn distribution_homebrew_installer_requires_tap() {
4345        let text = "---\nstatus: approved\nmaturity: mvp\n\
4346                    distribution:\n  adapter: cargo-dist\n  installers: [shell, homebrew]\n---\n";
4347        assert_error_contains(
4348            &norm(text),
4349            "includes 'homebrew' but no distribution.homebrew_tap",
4350        );
4351    }
4352
4353    /// A malformed tap slug (not `owner/repo`) is rejected AND, because the
4354    /// invalid value substitutes `None`, the homebrew-needs-tap floor still fires
4355    /// — a present-but-invalid tap must not slip a `homebrew` installer through.
4356    #[test]
4357    fn distribution_bad_tap_slug_rejected() {
4358        let text = "---\nstatus: approved\nmaturity: mvp\n\
4359                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
4360                    homebrew_tap: not-a-slug\n---\n";
4361        let n = norm(text);
4362        assert_error_contains(&n, "must be an 'owner/repo' slug");
4363        assert!(
4364            n.problems
4365                .errors
4366                .iter()
4367                .any(|e| e.contains("includes 'homebrew' but no distribution.homebrew_tap")),
4368            "the tap floor must still fire on an invalid (→None) tap: {:?}",
4369            n.problems.errors
4370        );
4371        // The malformed slug never leaks into the built block.
4372        assert_eq!(
4373            n.contract
4374                .distributions
4375                .into_iter()
4376                .next()
4377                .unwrap()
4378                .homebrew_tap,
4379            None
4380        );
4381    }
4382
4383    /// An unknown installer flavor surfaces an error listing the valid set.
4384    #[test]
4385    fn distribution_bad_installer_rejected() {
4386        let text = "---\nstatus: approved\nmaturity: mvp\n\
4387                    distribution:\n  adapter: cargo-dist\n  installers: [snap]\n---\n";
4388        assert_error_contains(&norm(text), "distribution.installers");
4389    }
4390
4391    /// `adapter` is required when a distribution block is present — a bare
4392    /// `distribution: {}` must not silently claim cargo-dist ownership.
4393    #[test]
4394    fn distribution_adapter_is_required() {
4395        assert_error_contains(
4396            &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: {}\n---\n"),
4397            "distribution.adapter is required",
4398        );
4399    }
4400
4401    /// A distribution block ships public binaries — forbidden at maturity 'spike'
4402    /// (mirrors the `release.model: auto`-on-spike floor).
4403    #[test]
4404    fn distribution_forbidden_on_spike() {
4405        let text = "---\nstatus: approved\nmaturity: spike\n\
4406                    distribution:\n  adapter: cargo-dist\n---\n";
4407        assert_error_contains(&norm(text), "not allowed on maturity 'spike'");
4408    }
4409
4410    /// A `homebrew_tap` without an engine-owned target is a warning at validation
4411    /// time and a hard refusal at cut time, so the author can see the remediation.
4412    #[test]
4413    fn distribution_tap_without_target_warns() {
4414        // `ecosystems` is declared so `targets` expands: a distribution block next to
4415        // an EMPTY target set is its own floor (publish-none cannot ship binaries).
4416        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
4417                    distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  \
4418                    homebrew_tap: owner/tap\n---\n";
4419        let n = norm(text);
4420        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4421        assert!(n
4422            .problems
4423            .warnings
4424            .iter()
4425            .any(|warning| warning.contains("no 'homebrew' target")));
4426    }
4427
4428    /// A `homebrew_tap` set with NO `homebrew` installer but WITH a
4429    /// `homebrew`-registry target (the release engine's homebrew-tap adapter, which
4430    /// pushes the formula in its `dist` phase) is NOT dead config — the tap IS
4431    /// updated by the engine, so the dead-config warning must NOT fire. This is
4432    /// shipshape's own (correct) contract shape.
4433    #[test]
4434    fn distribution_tap_with_homebrew_target_no_warning() {
4435        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
4436                    targets:\n  \
4437                    - {ecosystem: rust, package: shipshape, registry: crates.io, adapter: cargo-publish}\n  \
4438                    - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: homebrew-tap}\n\
4439                    distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  \
4440                    homebrew_tap: owner/tap\n---\n";
4441        let n = norm(text);
4442        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4443        // Stronger than a negative substring match: the whole contract is clean,
4444        // so it must produce NO warnings at all — this also catches any reworded
4445        // dead-tap advisory that a substring check would miss.
4446        assert!(
4447            n.problems.warnings.is_empty(),
4448            "homebrew-target contract must not warn: {:?}",
4449            n.problems.warnings
4450        );
4451    }
4452
4453    /// A goreleaser distribution with no installers and no tap is valid — the
4454    /// block is minimal and forward-compatible.
4455    #[test]
4456    fn distribution_goreleaser_minimal_is_valid() {
4457        let text = "---\nstatus: approved\nmaturity: production\necosystems: [go]\n\
4458                    distribution:\n  adapter: goreleaser\n  gh_releases: true\n---\n";
4459        let n = norm(text);
4460        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4461        let d = n.contract.distributions.into_iter().next().unwrap();
4462        assert_eq!(d.adapter, DistributionAdapter::Goreleaser);
4463        assert!(d.installers.is_empty());
4464        assert_eq!(d.homebrew_tap, None);
4465    }
4466
4467    /// A non-mapping `distribution` value is a structural error.
4468    #[test]
4469    fn distribution_non_mapping_rejected() {
4470        assert_error_contains(
4471            &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: [nope]\n---\n"),
4472            "distribution must be a mapping",
4473        );
4474    }
4475
4476    // ── distribution.platforms (cross-platform target set) ───────────────────
4477
4478    /// Helper: does a platform list contain any Linux triple? The cross-platform
4479    /// install requirement is "at least one Linux triple", inspected via the OS
4480    /// component of the triple (exactly how `audit` will read this field).
4481    fn has_linux(platforms: &[String]) -> bool {
4482        platforms.iter().any(|t| t.contains("-linux"))
4483    }
4484
4485    /// Omitted `platforms` → the cross-platform default (macOS + Linux). The
4486    /// KEYSTONE assertion: the DEFAULT covers Linux, so every distribution that
4487    /// omits the field does (an explicit set is the author's own choice, which the
4488    /// cross-platform `audit` — not this normalizer — checks).
4489    #[test]
4490    fn distribution_platforms_default_is_cross_platform() {
4491        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4492                    distribution:\n  adapter: cargo-dist\n---\n";
4493        let d = norm(text)
4494            .contract
4495            .distributions
4496            .into_iter()
4497            .next()
4498            .expect("distribution present");
4499        assert_eq!(
4500            d.platforms,
4501            vec![
4502                "aarch64-apple-darwin",
4503                "x86_64-apple-darwin",
4504                "aarch64-unknown-linux-musl",
4505                "x86_64-unknown-linux-musl",
4506            ]
4507        );
4508        assert!(
4509            has_linux(&d.platforms),
4510            "the default set MUST contain a Linux triple: {:?}",
4511            d.platforms
4512        );
4513    }
4514
4515    /// An explicit `platforms` list round-trips through normalization and the
4516    /// serialized JSON downstream members read, order + values preserved.
4517    #[test]
4518    fn distribution_platforms_explicit_round_trips() {
4519        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4520                    distribution:\n  adapter: cargo-dist\n  \
4521                    platforms: [x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc]\n---\n";
4522        let n = norm(text);
4523        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4524        let d = n.contract.clone().distributions.into_iter().next().unwrap();
4525        assert_eq!(
4526            d.platforms,
4527            vec!["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
4528        );
4529        let json = serde_json::to_value(&n.contract).unwrap();
4530        assert_eq!(
4531            json["distributions"][0]["platforms"],
4532            serde_json::json!(["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"])
4533        );
4534    }
4535
4536    /// An explicit empty `platforms: []` is a hard error — NOT silently defaulted.
4537    /// Only an omitted/null field yields the cross-platform default; an empty list
4538    /// is a mistake (a distribution with no platforms builds nothing) and, if
4539    /// silently defaulted, would surprise the author and erase the intent the
4540    /// cross-platform audit needs to see.
4541    #[test]
4542    fn distribution_platforms_empty_is_rejected() {
4543        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4544                    distribution:\n  adapter: cargo-dist\n  platforms: []\n---\n";
4545        assert_error_contains(&norm(text), "empty list — omit the key");
4546    }
4547
4548    /// Duplicate triples de-duplicate, preserving first-seen order.
4549    #[test]
4550    fn distribution_platforms_dedup_preserves_order() {
4551        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4552                    distribution:\n  adapter: cargo-dist\n  \
4553                    platforms: [aarch64-apple-darwin, x86_64-apple-darwin, aarch64-apple-darwin]\n---\n";
4554        let d = norm(text)
4555            .contract
4556            .distributions
4557            .into_iter()
4558            .next()
4559            .unwrap();
4560        assert_eq!(
4561            d.platforms,
4562            vec!["aarch64-apple-darwin", "x86_64-apple-darwin"]
4563        );
4564    }
4565
4566    /// A malformed triple is rejected with a message naming the field.
4567    #[test]
4568    fn distribution_platforms_bad_triple_rejected() {
4569        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4570                    distribution:\n  adapter: cargo-dist\n  platforms: [not_a_triple]\n---\n";
4571        assert_error_contains(&norm(text), "is not a well-formed target-triple");
4572    }
4573
4574    /// A non-string entry (a nested list) is rejected structurally.
4575    #[test]
4576    fn distribution_platforms_non_string_entry_rejected() {
4577        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4578                    distribution:\n  adapter: cargo-dist\n  platforms: [[nope]]\n---\n";
4579        assert_error_contains(&norm(text), "each entry must be a target-triple string");
4580    }
4581
4582    /// A `platforms` value that is not a list is a structural error.
4583    #[test]
4584    fn distribution_platforms_non_list_rejected() {
4585        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4586                    distribution:\n  adapter: cargo-dist\n  platforms: x86_64-apple-darwin\n---\n";
4587        assert_error_contains(&norm(text), "must be a list of target-triple strings");
4588    }
4589
4590    /// Regression: a registry-only contract (no distribution block at all) is
4591    /// wholly unaffected by the additive `platforms` field — no distribution, so
4592    /// no `platforms` in the emitted shape.
4593    #[test]
4594    fn registry_only_contract_unaffected_by_platforms() {
4595        let json = serde_json::to_value(
4596            &norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract,
4597        )
4598        .unwrap();
4599        assert_eq!(json["distributions"], serde_json::json!([]));
4600    }
4601
4602    #[test]
4603    fn looks_like_target_triple_verdicts() {
4604        // Standard triples across arch/vendor/os/env shapes.
4605        assert!(looks_like_target_triple("aarch64-apple-darwin"));
4606        assert!(looks_like_target_triple("x86_64-apple-darwin"));
4607        assert!(looks_like_target_triple("x86_64-unknown-linux-musl"));
4608        assert!(looks_like_target_triple("x86_64-unknown-linux-gnu"));
4609        assert!(looks_like_target_triple("x86_64-pc-windows-msvc"));
4610        assert!(looks_like_target_triple("armv7-unknown-linux-gnueabihf"));
4611        assert!(looks_like_target_triple("wasm32-wasi"));
4612        // Real dotted arch names must pass (regression: the `.` was rejected).
4613        assert!(looks_like_target_triple("thumbv8m.main-none-eabi"));
4614        assert!(looks_like_target_triple("thumbv8m.base-none-eabi"));
4615        // Rejects: too few/many components, empty parts, case, punctuation.
4616        assert!(!looks_like_target_triple("linux"));
4617        assert!(!looks_like_target_triple("a-b-c-d-e"));
4618        assert!(!looks_like_target_triple("x86_64--linux"));
4619        assert!(!looks_like_target_triple("-apple-darwin"));
4620        assert!(!looks_like_target_triple("X86_64-apple-darwin"));
4621        assert!(!looks_like_target_triple("x86_64-apple-darwin;rm"));
4622        assert!(!looks_like_target_triple("x86_64 apple darwin"));
4623        assert!(!looks_like_target_triple(""));
4624        // Structural-only: nonsense that happens to be well-formed IS accepted —
4625        // the toolchain, not the contract, is the authority on buildability.
4626        assert!(looks_like_target_triple("aa-bb"));
4627    }
4628
4629    #[test]
4630    fn is_tap_slug_verdicts() {
4631        // Valid GitHub-style slugs.
4632        assert!(is_tap_slug("owner/repo"));
4633        assert!(is_tap_slug("jarimustonen/homebrew-issuectl"));
4634        assert!(is_tap_slug("Owner_1/repo.rb"));
4635        // Structural rejects.
4636        assert!(!is_tap_slug("no-slash"));
4637        assert!(!is_tap_slug("/repo"));
4638        assert!(!is_tap_slug("owner/"));
4639        assert!(!is_tap_slug("owner/repo/extra"));
4640        assert!(!is_tap_slug("owner / repo"));
4641        // Strict-charset rejects: path traversal, punctuation, injection chars.
4642        assert!(!is_tap_slug("owner/.."));
4643        assert!(!is_tap_slug("../repo"));
4644        assert!(!is_tap_slug("owner/repo;rm -rf"));
4645        assert!(!is_tap_slug("owner/@repo"));
4646        assert!(!is_tap_slug("ownér/repo"));
4647    }
4648
4649    /// `quote_for_diagnostic` JSON-encodes: quotes/backslashes/newlines/control
4650    /// chars are escaped, ordinary text stays readable.
4651    #[test]
4652    fn quote_for_diagnostic_escapes_hostile_input() {
4653        assert_eq!(quote_for_diagnostic("foo"), "\"foo\"");
4654        assert_eq!(quote_for_diagnostic("a\"b"), "\"a\\\"b\"");
4655        assert_eq!(quote_for_diagnostic("a\nb"), "\"a\\nb\"");
4656        assert_eq!(quote_for_diagnostic("a\tb"), "\"a\\tb\"");
4657        // A bare C0 control char (0x01) escapes to , never a raw byte.
4658        assert_eq!(quote_for_diagnostic("\u{1}"), "\"\\u0001\"");
4659    }
4660
4661    /// Log-injection hardening: a user-controlled unknown-field KEY carrying a
4662    /// quote, newline, and control char cannot forge a diagnostic line or emit a
4663    /// raw control char — it is JSON-encoded onto a single intact line.
4664    #[test]
4665    fn unknown_field_key_is_escaped_in_warning() {
4666        // The key is `evil"key` + newline + a forged-looking line + a control char.
4667        // Quoted in YAML so the literal quote/newline/control byte are the KEY text.
4668        let text =
4669            "---\nstatus: approved\nmaturity: mvp\n\"evil\\\"key\\nforged: line\\u0001\": 1\n---\n";
4670        let n = norm(text);
4671        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4672        let warning = n
4673            .problems
4674            .warnings
4675            .iter()
4676            .find(|w| w.contains("unknown field(s) preserved"))
4677            .expect("expected an unknown-field warning");
4678        // The raw quote/newline/control char never appear unescaped in the message:
4679        // no forged second line, no bare control byte.
4680        assert!(
4681            !warning.contains('\n'),
4682            "warning must stay on one line: {warning:?}"
4683        );
4684        assert!(
4685            !warning.contains('\u{1}'),
4686            "warning must not carry a raw control char: {warning:?}"
4687        );
4688        assert!(
4689            !warning.contains("evil\"key"),
4690            "the raw unescaped key must not appear: {warning:?}"
4691        );
4692        // The escaped JSON form is present (quote → \", newline → \n, ctrl → ).
4693        assert!(
4694            warning.contains("\\\"") && warning.contains("\\n") && warning.contains("\\u0001"),
4695            "the key must be JSON-escaped: {warning:?}"
4696        );
4697    }
4698
4699    /// The same hardening on a user-controlled VALUE routed through `yaml_display`
4700    /// (an invalid enum): a newline in the rejected value cannot forge an error
4701    /// line.
4702    #[test]
4703    fn invalid_enum_value_is_escaped_in_error() {
4704        let text = "---\nstatus: approved\nmaturity: \"mvp\\nforged: line\"\n---\n";
4705        let n = norm(text);
4706        assert_error_contains(&n, "maturity");
4707        let err = n
4708            .problems
4709            .errors
4710            .iter()
4711            .find(|e| e.contains("maturity") && e.contains("invalid"))
4712            .expect("expected a maturity-invalid error");
4713        assert!(!err.contains('\n'), "error must stay on one line: {err:?}");
4714        assert!(
4715            err.contains("\\n"),
4716            "the rejected value's newline must be escaped: {err:?}"
4717        );
4718    }
4719}