Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

nornir-build-thing-skew/tests/recipe_skew.rs

//! **THE GUARD: the oldest supported reader and the newest, over every repo's
//! recipe, failing by name when they disagree in a way the file never declared.**
//!
//! # What it is for
//!
//! `.nornir/build-variants.toml` has more than one reader at more than one
//! version by construction. The FORGE (`dwarves` CLI and the ⚒ pane) reads every
//! repo's recipe with ITS `nornir-build-thing` pin; the repo's own
//! `<repo>-build-thing` crate — and that crate's `toml_parity` test — reads the
//! same bytes with THEIRS. Measured 2026-08-30 across the constellation, those
//! pins were 0.1.0 · 0.1.3 · 0.1.4 · 0.1.8 · 0.1.9 · 0.1.10 · 0.1.11 (two
//! DIFFERENT crates under that last number), and **nothing compared them.**
//!
//! # The measurement it was written from
//!
//! ```text
//! nornir-build-thing 0.1.10 (published) vs gunnar's committed recipe:
//!
//!   REFUSED  unknown field `kernel_requires`, expected one of `name`, `label`,
//!            `default`, `feature`, `params_when_on`, `params_when_off`,
//!            `param_kinds`, `forbid_symbols`, `bakes`
//!            at [[build.iso.options.kernel_requires]] — the WHOLE recipe.
//!
//!   with that key removed so the load can finish, the SAME reader:
//!   DROPS    build.installer.0.data   build.installer.0.target
//!            build.installer.1.data   build.installer.1.target
//! ```
//!
//! Four declarations read by nobody for four generations, with gunnar's
//! `tests/toml_parity.rs` GREEN over them — green because it compares the file
//! against the same loader that dropped the rows.
//!
//! # The property, stated exactly
//!
//! It is NOT "every reader must read every recipe": that is false and should be,
//! because a recipe is allowed to declare vocabulary a 2026-08 pin never had.
//! It is:
//!
//! > **A reader's failure must be explained by the recipe's DECLARED schema.**
//!
//! An old reader refusing or dropping is fine when the file says
//! `[nornir-build-thing] schema = N` with `N` past what that reader knew — that
//! is the format working, and a reader from 0.1.12 on turns it into a refusal
//! that names the fix. An old reader refusing or dropping a file that declares
//! **nothing** is undeclared skew, and it is the exact defect above.
//!
//! # Seen RED
//!
//! Permanently, and against the real file rather than a fixture:
//! [`the_guard_reports_the_2026_08_30_skew_when_the_header_is_taken_away`] strips
//! gunnar's `[nornir-build-thing]` header from the bytes in memory and asserts
//! the guard names the skew. Delete the comparison and that test goes green.
//! Its output, run 2026-08-30:
//!
//! ```text
//! gunnar, header removed: 0.1.10: REFUSED — TOML parse error at line 1685, column 21
//! ```
//! and with the `kernel_requires` blocks also removed, so the load can finish:
//! ```text
//! gunnar, header + kernel_requires removed: 0.1.10: SILENTLY DROPPED 4
//!         build.installer.0.data
//!         build.installer.0.target
//!         build.installer.1.data
//!         build.installer.1.target
//! ```

use std::path::{Path, PathBuf};

use nornir_build_thing_skew::{Reading, reader};

reader!(read_new, nbt_new, "0.1.12 (edda tree)");
reader!(read_old, nbt_old, "0.1.10 (published)");

/// **The schema the OLD reader's vocabulary corresponds to.** 0.1.10 predates
/// the header entirely, so everything it can read is schema 1 — and a file that
/// declares more than 1 is a file it is ENTITLED to refuse.
const OLD_READER_SCHEMA: u32 = 1;

/// Every repo in the constellation that ships a recipe (measured 2026-08-30).
const ROSTER: &[&str] = &[
    "gunnar",
    "korp",
    "holger",
    "skade",
    "nornir",
    "tunnr",
    "znippy",
    "znippy-zoomies",
    "modgunn",
    "ordning",
    "dwarves",
    "edda",
    "knut",
    "Skidbladnir",
    "draupnir",
];

fn constellation_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .ancestors()
        .nth(2)
        .expect("edda/nornir-build-thing-skew has two ancestors up to ~/git")
        .to_path_buf()
}

fn recipe_of(repo: &str) -> Option<(PathBuf, String)> {
    let p = constellation_root().join(repo).join(".nornir/build-variants.toml");
    std::fs::read_to_string(&p).ok().map(|t| (p, t))
}

/// What the recipe declares it needs — read with the NEW reader, which is the
/// only one that knows the header exists.
fn declared_schema(text: &str) -> u32 {
    nbt_new::schema::declared_schema(text).unwrap_or(nbt_new::schema::RECIPE_SCHEMA_FLOOR)
}

/// The disagreement, or `None`.
fn skew(text: &str) -> Option<(u32, Reading, Reading)> {
    let declared = declared_schema(text);
    let new = read_new(text);
    let old = read_old(text);
    if declared > OLD_READER_SCHEMA {
        // The old reader is ENTITLED to fail: the file says so. What is not
        // allowed is the NEW reader failing on a file at its own level.
        return (!new.is_faithful()).then_some((declared, new, old));
    }
    (!new.is_faithful() || !old.is_faithful()).then_some((declared, new, old))
}

fn report(repo: &str, path: &Path, declared: u32, new: &Reading, old: &Reading) -> String {
    format!(
        "{repo} ({})\n  the recipe declares schema {declared}\n    {}\n    {}",
        path.display(),
        new.line(),
        old.line(),
    )
}

#[test]
fn the_guard_has_a_subject_on_disk() {
    let missing: Vec<&str> = ROSTER.iter().copied().filter(|r| recipe_of(r).is_none()).collect();
    assert!(
        missing.is_empty(),
        "the guard's subject is the CONSTELLATION and these checkouts are missing under {}: \
         {missing:?}. A cross-version guard that skips its subjects compares nothing.",
        constellation_root().display()
    );
}

/// **THE GUARD.**
#[test]
fn no_repos_recipe_skews_between_the_oldest_and_the_newest_reader() {
    let mut problems: Vec<String> = Vec::new();
    let mut compared = 0usize;

    for repo in ROSTER {
        let Some((path, text)) = recipe_of(repo) else { continue };
        compared += 1;
        if let Some((declared, new, old)) = skew(&text) {
            problems.push(report(repo, &path, declared, &new, &old));
        }
    }

    assert!(compared >= 3, "compared {compared} recipes — the roster found nothing to judge");
    assert!(
        problems.is_empty(),
        "these recipes are read DIFFERENTLY by the oldest supported reader and the \
         newest, and the file does not declare a schema that explains it:\n\n{}\n\n\
         Every silent drop above is a group of the build that reader will not carry. \
         The fix is one of two, never a third: raise the repo's `nornir-build-thing` \
         pin to a reader that understands the recipe, or — if the recipe genuinely \
         needs newer vocabulary — make it declare `[nornir-build-thing] schema = N` \
         so the old reader refuses BY NAME instead of dropping rows.",
        problems.join("\n\n")
    );
}

/// **The guard, seen RED — against the real file, not a fixture.**
///
/// gunnar's recipe declares `schema = 2` today, so the guard correctly forgives
/// 0.1.10 for refusing it. Take the header away and the SAME bytes become the
/// 2026-08-30 defect: an old reader that fails, and a file that never said it
/// would. If this test ever goes green, the comparison above has stopped
/// comparing.
#[test]
fn the_guard_reports_the_2026_08_30_skew_when_the_header_is_taken_away() {
    let (_, text) = recipe_of("gunnar").expect("gunnar is checked out");
    assert_eq!(
        declared_schema(&text),
        2,
        "gunnar's recipe must DECLARE the schema it needs, or this probe is \
         measuring a file that was never fixed"
    );
    assert!(
        skew(&text).is_none(),
        "with the header, the old reader's refusal is explained and the guard is quiet"
    );

    let headerless = strip_schema_header(&text);
    assert_eq!(declared_schema(&headerless), 1, "the probe removed the header");
    let (declared, new, old) = skew(&headerless).expect(
        "WITHOUT the header this is exactly the 2026-08-30 defect: an old reader that \
         fails on a file declaring nothing. A guard that stays quiet here would have \
         watched gunnar lose four declarations for four generations, which is what \
         happened.",
    );
    assert_eq!(declared, 1);
    assert!(new.is_faithful(), "the NEW reader reads gunnar in full: {}", new.line());
    assert!(
        old.refused.is_some(),
        "0.1.10 refuses gunnar's recipe outright on `kernel_requires`: {}",
        old.line()
    );
    assert!(
        old.refused.as_deref().unwrap_or_default().contains("kernel_requires"),
        "and the refusal is the measured one: {}",
        old.line()
    );

    // And the SILENT half — the one a refusal hides. With the key that stops the
    // load also removed, 0.1.10 finishes and throws two whole groups away.
    let quiet = strip_kernel_requires(&headerless);
    let old = read_old(&quiet);
    assert_eq!(
        old.dropped,
        vec![
            "build.installer.0.data".to_string(),
            "build.installer.0.target".to_string(),
            "build.installer.1.data".to_string(),
            "build.installer.1.target".to_string(),
        ],
        "the four paths published 0.1.10 swallowed out of gunnar's recipe while \
         tests/toml_parity.rs was green: {}",
        old.line()
    );
    assert!(read_new(&quiet).is_faithful(), "the new reader keeps them");
}

/// Remove the `[nornir-build-thing]` root table (header to the next table).
fn strip_schema_header(text: &str) -> String {
    let mut out = String::new();
    let mut skipping = false;
    for line in text.lines() {
        let t = line.trim_start();
        if t.starts_with('[') {
            skipping = t.starts_with("[nornir-build-thing]");
        }
        if !skipping {
            out.push_str(line);
            out.push('\n');
        }
    }
    out
}

/// Remove every `[[….kernel_requires]]` table-array block, so a reader that
/// would refuse on the key gets far enough to show what it drops instead.
fn strip_kernel_requires(text: &str) -> String {
    let mut out = String::new();
    let mut skipping = false;
    for line in text.lines() {
        let t = line.trim_start();
        if t.starts_with('[') {
            skipping = t.contains("kernel_requires");
        }
        if !skipping {
            out.push_str(line);
            out.push('\n');
        }
    }
    out
}