diff --git a/.github/workflows/ci_test.yml b/.github/workflows/ci_test.yml index a193079c..e400acdf 100644 --- a/.github/workflows/ci_test.yml +++ b/.github/workflows/ci_test.yml @@ -185,6 +185,7 @@ jobs: --test llms_txt \ --test mcp_registry \ --test mcp_stdio \ + --test module_tree_dump_script \ --test oom_profile \ --test optimisation_criteria \ --test pr_merge_order \ diff --git a/CHANGELOG.md b/CHANGELOG.md index ed98bc60..ece08206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to this project are documented here. ### Added +- `stack provided` includes the one-based build-list line on every + `provided` and `unparsed` finding in its text report, as it already did for + hook, toolchain, family-lock, and licensed findings. +- `scripts/dump-module-trees.sh` captures the live site, older EESSI, and + target EESSI `module -t avail` inventories in isolated, purged shells for + `stack gap`. +- `stack gap` writes `version-change.tsv`, one row per version in the site + and older-EESSI inventories. A module in the target is `moved` and names + its EESSI version; an excluded module is `site-only`; every other missing + route is `gone` until it lands. - `stack shard` splits one Jenkins build list into shards that can build at the same time. A line's set is its closure over `--easyconfigs` minus the modules in `--installed-modules-dir` Lmod diff --git a/artifacts/REPORT.md b/artifacts/REPORT.md new file mode 100644 index 00000000..65d8d941 --- /dev/null +++ b/artifacts/REPORT.md @@ -0,0 +1,195 @@ +# U3–U5 delivery report + +Base: `da07561a0c430957c2d014f31855bbe973064197` + +Feature tip: `682ea65` (`cursor/stack-gap-lint-5553`) + +## Baseline coverage + +The requested baseline already contained the core U3 and U4 commands: + +- `stack provided` already rejected names from an installed listing or + baseline lock and rejected per-line `--hooks`/`--hook`. +- `stack gap` already read live module trees and dumps, accepted both + open-pull-request inputs, and applied the ns-006 route priority. + +Following the brief's skip rule, this series does not duplicate those +implementations. It closes the remaining differences: default-text line +numbers for every U3 error, the U4 dump helper and exact one-row-per-route +fixture, and all of U5. + +## Commits + +1. `25f6010 fix(stack): report build-list line numbers` + - Reuses `shard::parse_lines` for source line identity and + `jenkins::parse_build_list` for filenames/flags. + - Adds one-based line numbers to `provided` and `unparsed` text findings, + matching the existing hook/toolchain diagnostics. +2. `57757bd feat(stack): capture live module inventories` + - Adds `scripts/dump-module-trees.sh`, which captures the site, older + EESSI, and target EESSI trees in separately purged shells. + - Adds an executable fixture test and a seven-name fixture with exactly + one row in each route TSV. +3. `1342ae9 feat(stack): write module version changes` + - Preserves versions from `module -t avail`, spider-style text, and Lmod + trees. + - Writes `version-change.tsv`: `in-stack` becomes `moved`, `excluded` + becomes `site-only`, and every other missing route is `gone` until it + lands. +4. `02aec42 docs(stack): refresh module gap index` + - Regenerates the checked-in long-form documentation index. +5. `3ada26f docs(stack): use configured upstream trees` + - Makes the pull-request inventory walkthrough use the configured pinned + checkout variables. +6. `55b5a73 ci(stack): run module dump helper test` + - Adds the helper test to the explicit integration-test CI slice. +7. `682ea65 docs(stack): fix shard API literal` + - Fixes a pre-existing malformed generated RST literal so the required + Sphinx warnings-as-errors build is clean. + +## Minimal working examples + +### U3: build-list lint + +Command: + +```sh +eb-stack stack provided \ + --build-list tests/provided_fixtures/site.list \ + --installed tests/provided_fixtures/installed.txt \ + --baseline-lock tests/provided_fixtures/baseline.lock.json +``` + +Before (`da07561a`, exit 1), baseline and unparsed findings had no source +line: + +```text +provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list +unparsed not-a-version.eb list=tests/provided_fixtures/site.list +hooks FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 sets=--hooks=/sw/hooks/site.py; which replaces EESSI's hooks +``` + +After (exit 1), every failing line is located: + +```text +provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list line=3 +unparsed not-a-version.eb list=tests/provided_fixtures/site.list line=7 +hooks FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 sets=--hooks=/sw/hooks/site.py; which replaces EESSI's hooks +``` + +### U4: live dumps and one row per route + +Portable helper test: + +```sh +cargo test --locked --test module_tree_dump_script +``` + +Before: + +```text +error: no test target named `module_tree_dump_script` +exit=101 +``` + +After: + +```text +running 1 test +test helper_dumps_site_older_and_target_module_trees ... ok +test result: ok. 1 passed; 0 failed +``` + +The site command documented for a real initialized Lmod shell is: + +```sh +scripts/dump-module-trees.sh \ + /tmp/module-dumps - EESSI/2025.06 EESSI/2026.06 +``` + +The exact route fixture: + +```sh +eb-stack stack gap \ + --site tests/gap_routes/one-per-route.txt \ + --installed tests/gap_routes/installed.txt \ + --easyconfigs tests/gap_routes/easyconfigs \ + --generation 2026.1 \ + --exclude-file tests/gap_routes/site-only.tsv \ + --filter-deps zlib \ + --open-layer tests/gap_routes/open-layer.json \ + --open-easyconfigs tests/gap_routes/open-easyconfigs.json \ + --out-dir /tmp/gap-one-per-route +``` + +Output: + +```text +site=7 in-stack=1 filtered=1 open-layer=1 recipe=1 open-easyconfigs=1 no-recipe=1 excluded=1 out=/tmp/gap-one-per-route +``` + +### U5: version-change table + +Run the live-tree fixture from `docs/orgmode/howto/module-gap.org`, using +`/tmp/gap-mwe` as `--out-dir`, then inspect +`/tmp/gap-mwe/version-change.tsv`. + +Before: + +```text +site=7 in-stack=2 filtered=1 open-layer=1 recipe=0 open-easyconfigs=0 no-recipe=1 excluded=2 out=/tmp/gap-mwe +version-change.tsv missing +``` + +After: + +```text +name old_version eessi_version status +CUDA 12.6.0 12.6.0 moved +FFTW 3.3.10 3.3.10 moved +LAMMPS 2023.0 - gone +LAMMPS 2024.0 - gone +MATLAB 2024b - site-only +QuantumESPRESSO 7.3 - gone +binutils 2.42 - gone +code-server 4.96.2 - site-only +``` + +## Verification + +All final gates passed with Rust stable 1.99.1 (the checkout's default Rust +1.83 is below the declared Rust 1.88 minimum): + +```sh +cargo fmt --all --check +cargo clippy --locked --all-targets -- -D warnings +cargo test --locked --all-targets +cargo package --locked +uvx prek run -a +pixi run -e docs docbld +scripts/check-doc-links.sh docs/build +python3 scripts/gen-llms-txt.py --check +``` + +The Sphinx build completed with `build succeeded.` under `-W --keep-going`. +All eight prek hooks passed. + +## Reproduction + +```sh +git clone /path/to/eb-stack eb-stack-u3-u5 +cd eb-stack-u3-u5 +git fetch artifacts/eb-stack-u3-u5.bundle \ + cursor/stack-gap-lint-5553:u3-u5 +git switch u3-u5 +``` + +Alternatively, apply `artifacts/patches/*.patch` in numeric order to +`da07561a`. + +## Left undone + +No requested code or fixture work remains. The cloud runner has no site Lmod +service, so the helper was verified with a deterministic `module` fixture +rather than a production site tree. No upstream EasyBuild or EESSI repository +was mutated. diff --git a/artifacts/eb-stack-u3-u5.bundle b/artifacts/eb-stack-u3-u5.bundle new file mode 100644 index 00000000..6d8c0217 Binary files /dev/null and b/artifacts/eb-stack-u3-u5.bundle differ diff --git a/artifacts/patches/0001-fix-stack-report-build-list-line-numbers.patch b/artifacts/patches/0001-fix-stack-report-build-list-line-numbers.patch new file mode 100644 index 00000000..3830ad0b --- /dev/null +++ b/artifacts/patches/0001-fix-stack-report-build-list-line-numbers.patch @@ -0,0 +1,147 @@ +From 25f60108093be02fcc6a5ade9431ab5c080f5914 Mon Sep 17 00:00:00 2001 +From: Rohit Goswami +Date: Tue, 3 Nov 2026 09:11:43 +0000 +Subject: [PATCH 1/7] fix(stack): report build-list line numbers + +--- + CHANGELOG.md | 3 +++ + docs/orgmode/howto/build-list-provided.org | 8 ++++---- + docs/orgmode/reference/cli.org | 2 +- + src/provided.rs | 24 +++++++++++----------- + tests/stack_provided.rs | 19 +++++++++++++---- + 5 files changed, 35 insertions(+), 21 deletions(-) + +diff --git a/CHANGELOG.md b/CHANGELOG.md +index ed98bc6..3866917 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -6,6 +6,9 @@ All notable changes to this project are documented here. + + ### Added + ++- `stack provided` includes the one-based build-list line on every ++ `provided` and `unparsed` finding in its text report, as it already did for ++ hook, toolchain, family-lock, and licensed findings. + - `stack shard` splits one Jenkins build list into shards that can + build at the same time. A line's set is its closure over + `--easyconfigs` minus the modules in `--installed-modules-dir` Lmod +diff --git a/docs/orgmode/howto/build-list-provided.org b/docs/orgmode/howto/build-list-provided.org +index 77c1d99..b0cc7d6 100644 +--- a/docs/orgmode/howto/build-list-provided.org ++++ b/docs/orgmode/howto/build-list-provided.org +@@ -29,10 +29,10 @@ eb-stack stack provided \ + That command printed: + + #+begin_example +-provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list +-provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list +-provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list +-unparsed not-a-version.eb list=tests/provided_fixtures/site.list ++provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list line=3 ++provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 ++provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 ++unparsed not-a-version.eb list=tests/provided_fixtures/site.list line=7 + toolchain fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 uses=foss-2025b; a site-layer line on EESSI 2026.06 must use a 2026.1 toolchain or GCCcore-15.2.0 below it (GCCcore-15.2.0, GCC-15.2.0, gfbf-2026.1, gompi-2026.1, foss-2026.1) + hooks FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 sets=--hooks=/sw/hooks/site.py; which replaces EESSI's hooks + hits=3 unparsed=1 allowed=0 provided=3 toolchain=1 hooks=1 family=0 licensed=0 +diff --git a/docs/orgmode/reference/cli.org b/docs/orgmode/reference/cli.org +index 055fe02..d172d48 100644 +--- a/docs/orgmode/reference/cli.org ++++ b/docs/orgmode/reference/cli.org +@@ -694,7 +694,7 @@ path, not left as site layer. ~--licensed~ and ~--licensed-file~ warn + for group-restricted names; ~data/site-licensed.txt~ is that list for + VASP, MATLAB, ANSYS, ORCA, and Gurobi. With neither flag there is no + warning. ~--strict~ makes a remaining warning exit 1. Stdout lists each +-hit as ~provided name= list=~ and ends with ++hit as ~provided name= list= line=~ and ends with + ~hits= unparsed= allowed= provided= toolchain= hooks= family= licensed=~. + ~--format~ is ~text~ by default, ~github~ for workflow commands, or + ~json~. ~--json~ is the same as ~--format json~. See [[file:../howto/build-list-provided.org][fail a build list that names software the baseline provides]] +diff --git a/src/provided.rs b/src/provided.rs +index 816d5e5..1ea83b8 100644 +--- a/src/provided.rs ++++ b/src/provided.rs +@@ -19,6 +19,7 @@ use crate::easystack::filename_toolchain; + use crate::gap::{names_from_listing, software_name}; + use crate::hierarchy::known_hierarchy; + use crate::jenkins::parse_build_list; ++use crate::shard::parse_lines; + use anyhow::{Context, Result}; + use serde::Serialize; + use std::collections::{BTreeMap, BTreeSet}; +@@ -437,16 +438,12 @@ struct Entry { + } + + fn entries(text: &str) -> Vec { +- text.lines() +- .enumerate() +- .filter_map(|(index, raw)| { +- let trimmed = raw.trim(); +- if trimmed.is_empty() || trimmed.starts_with('#') { +- return None; +- } +- let line = parse_build_list(trimmed).into_iter().next()?; ++ parse_lines(text) ++ .into_iter() ++ .filter_map(|shard_line| { ++ let line = parse_build_list(&shard_line.text).into_iter().next()?; + Some(Entry { +- line: index + 1, ++ line: shard_line.number, + file: line.file, + flags: line.flags, + }) +@@ -636,12 +633,15 @@ fn render_text(report: &ProvidedReport) -> String { + let mut out = String::new(); + for hit in &report.hits { + out.push_str(&format!( +- "provided {} name={} list={}\n", +- hit.file, hit.name, hit.list ++ "provided {} name={} list={} line={}\n", ++ hit.file, hit.name, hit.list, hit.line + )); + } + for line in &report.unparsed { +- out.push_str(&format!("unparsed {} list={}\n", line.file, line.list)); ++ out.push_str(&format!( ++ "unparsed {} list={} line={}\n", ++ line.file, line.list, line.line ++ )); + } + for note in report + .toolchain +diff --git a/tests/stack_provided.rs b/tests/stack_provided.rs +index 9b8f4f1..182812a 100644 +--- a/tests/stack_provided.rs ++++ b/tests/stack_provided.rs +@@ -32,12 +32,23 @@ fn a_line_the_baseline_provides_exits_1_and_an_allow_keeps_it() { + let stderr = String::from_utf8_lossy(&failed.stderr); + assert_eq!(failed.status.code(), Some(1), "{stdout}{stderr}"); + assert!( +- stdout.contains("provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW "), ++ stdout.contains( ++ "provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4" ++ ), ++ "{stdout}" ++ ); ++ assert!( ++ stdout.contains("name=Python list=tests/provided_fixtures/site.list line=5"), ++ "{stdout}" ++ ); ++ assert!( ++ stdout.contains("name=GROMACS list=tests/provided_fixtures/site.list line=3"), ++ "{stdout}" ++ ); ++ assert!( ++ stdout.contains("unparsed not-a-version.eb list=tests/provided_fixtures/site.list line=7"), + "{stdout}" + ); +- assert!(stdout.contains("name=Python "), "{stdout}"); +- assert!(stdout.contains("name=GROMACS "), "{stdout}"); +- assert!(stdout.contains("unparsed not-a-version.eb "), "{stdout}"); + assert!(!stdout.contains("VASP"), "{stdout}"); + assert!( + stderr.contains("3 build-list lines name software the baseline already provides"), +-- +2.43.0 + diff --git a/artifacts/patches/0002-feat-stack-capture-live-module-inventories.patch b/artifacts/patches/0002-feat-stack-capture-live-module-inventories.patch new file mode 100644 index 00000000..a7fce9ac --- /dev/null +++ b/artifacts/patches/0002-feat-stack-capture-live-module-inventories.patch @@ -0,0 +1,290 @@ +From 57757bd5e7e6af5c1f426d224e823629dde92f75 Mon Sep 17 00:00:00 2001 +From: Rohit Goswami +Date: Tue, 3 Nov 2026 09:12:55 +0000 +Subject: [PATCH 2/7] feat(stack): capture live module inventories + +--- + CHANGELOG.md | 3 ++ + docs/orgmode/howto/module-gap.org | 49 +++++++++++++++++++++++++ + scripts/dump-module-trees.sh | 48 ++++++++++++++++++++++++ + tests/gap_routes/one-per-route.txt | 7 ++++ + tests/module_tree_dump_script.rs | 59 ++++++++++++++++++++++++++++++ + tests/stack_gap.rs | 53 +++++++++++++++++++++++++++ + 6 files changed, 219 insertions(+) + create mode 100755 scripts/dump-module-trees.sh + create mode 100644 tests/gap_routes/one-per-route.txt + create mode 100644 tests/module_tree_dump_script.rs + +diff --git a/CHANGELOG.md b/CHANGELOG.md +index 3866917..3ccb293 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -9,6 +9,9 @@ All notable changes to this project are documented here. + - `stack provided` includes the one-based build-list line on every + `provided` and `unparsed` finding in its text report, as it already did for + hook, toolchain, family-lock, and licensed findings. ++- `scripts/dump-module-trees.sh` captures the live site, older EESSI, and ++ target EESSI `module -t avail` inventories in isolated, purged shells for ++ `stack gap`. + - `stack shard` splits one Jenkins build list into shards that can + build at the same time. A line's set is its closure over + `--easyconfigs` minus the modules in `--installed-modules-dir` Lmod +diff --git a/docs/orgmode/howto/module-gap.org b/docs/orgmode/howto/module-gap.org +index 13ef27c..475f9a5 100644 +--- a/docs/orgmode/howto/module-gap.org ++++ b/docs/orgmode/howto/module-gap.org +@@ -20,6 +20,55 @@ A toolchain whose version is the generation counts as on it, so + generation in ~data/eessi-generations.toml~, such as + ~llvm-compilers-21.1.8~. + ++* Capture the live inventories ++ ++Run the helper from a shell where the ~module~ command is initialized. ++Its four arguments are the output directory, the site meta-module, the ++older EESSI module, and the target EESSI module. Use ~-~ for the site ++argument when the base module path is the live site tree. ++ ++#+begin_src bash ++scripts/dump-module-trees.sh \ ++ /tmp/module-dumps - EESSI/2025.06 EESSI/2026.06 ++#+end_src ++ ++Each inventory is captured in a separately purged subshell, so loading ++one EESSI version cannot add paths to the next dump. The files are ++~/tmp/module-dumps/site.txt~, ~eessi-older.txt~, and ++~eessi-target.txt~. Pass them as ~--site~, ~--older~, and ++~--installed~, respectively. They are the raw ~module -t avail~ ++output; ~stack gap~ ignores path headers and default markers. ++ ++* Read the open-pull-request inventories ++ ++The two ~fetch-prs.sh~ examples produce the JSON accepted by ++~--open-layer~ and ~--open-easyconfigs~. Run each script in the clone ++named in its comment: ++ ++#+begin_src bash ++EB_STACK=/path/to/eb-stack ++(cd /path/to/software-layer && \ ++ "$EB_STACK/examples/eessi-pr-overlap/fetch-prs.sh" \ ++ EESSI/software-layer 2026.06 /tmp/open-layer) ++(cd /path/to/easybuild-easyconfigs && \ ++ "$EB_STACK/examples/easyconfigs-pr-overlap/fetch-prs.sh" \ ++ easybuilders/easybuild-easyconfigs /tmp/open-easyconfigs) ++ ++layer_args=() ++for file in /tmp/open-layer/prs/*.json; do ++ layer_args+=(--open-layer "$file") ++done ++easyconfig_args=() ++for file in /tmp/open-easyconfigs/prs/*.json; do ++ easyconfig_args+=(--open-easyconfigs "$file") ++done ++#+end_src ++ ++Add ~"${layer_args[@]}"~ and ~"${easyconfig_args[@]}"~ to the ++~stack gap~ command. The fetch scripts only read pull requests. A ++closed pull request disappears from their rewritten ~prs/~ directory ++on the next run. ++ + * Run the fixture + + The nine names are in ~tests/gap_routes/site.txt~. The easyconfig tree +diff --git a/scripts/dump-module-trees.sh b/scripts/dump-module-trees.sh +new file mode 100755 +index 0000000..421c853 +--- /dev/null ++++ b/scripts/dump-module-trees.sh +@@ -0,0 +1,48 @@ ++#!/usr/bin/env bash ++# Capture the live module inventories that `eb-stack stack gap` compares. ++# ++# Usage: ++# dump-module-trees.sh OUT_DIR SITE_MODULE OLDER_EESSI_MODULE TARGET_EESSI_MODULE ++# ++# Pass - for SITE_MODULE to capture the base site tree without loading a ++# meta-module. Run this from a shell where the `module` command is initialized. ++set -euo pipefail ++ ++if [[ $# -ne 4 ]]; then ++ echo "usage: $0 OUT_DIR SITE_MODULE OLDER_EESSI_MODULE TARGET_EESSI_MODULE" >&2 ++ exit 2 ++fi ++ ++readonly out_dir=$1 ++readonly site_module=$2 ++readonly older_module=$3 ++readonly target_module=$4 ++ ++if ! type module >/dev/null 2>&1; then ++ echo "dump-module-trees: the module command is not initialized" >&2 ++ exit 2 ++fi ++ ++mkdir -p "$out_dir" ++ ++dump_tree() { ++ local label=$1 ++ local selector=$2 ++ local output=$3 ++ local temporary="${output}.tmp" ++ ++ ( ++ module --force purge >/dev/null 2>&1 ++ if [[ $selector != - ]]; then ++ module load "$selector" >/dev/null 2>&1 ++ fi ++ module -t avail ++ ) >"$temporary" 2>&1 ++ mv "$temporary" "$output" ++ printf '%s\t%s\n' "$label" "$output" ++} ++ ++trap 'rm -f "$out_dir"/*.tmp' EXIT ++dump_tree site "$site_module" "$out_dir/site.txt" ++dump_tree older "$older_module" "$out_dir/eessi-older.txt" ++dump_tree target "$target_module" "$out_dir/eessi-target.txt" +diff --git a/tests/gap_routes/one-per-route.txt b/tests/gap_routes/one-per-route.txt +new file mode 100644 +index 0000000..9f96c6c +--- /dev/null ++++ b/tests/gap_routes/one-per-route.txt +@@ -0,0 +1,7 @@ ++BLAST+/2.16.0-gompi-2024a ++CP2K/2025.2-foss-2025a ++FFTW/3.3.10-gompi-2026.1 ++LAMMPS/2024.2 ++MATLAB/2024b ++QuantumESPRESSO/7.4 ++zlib/1.3.1 +diff --git a/tests/module_tree_dump_script.rs b/tests/module_tree_dump_script.rs +new file mode 100644 +index 0000000..24d8052 +--- /dev/null ++++ b/tests/module_tree_dump_script.rs +@@ -0,0 +1,59 @@ ++//! The live-module dump helper isolates and records all three inventories. ++ ++use std::path::Path; ++use std::process::Command; ++ ++fn root() -> &'static Path { ++ Path::new(env!("CARGO_MANIFEST_DIR")) ++} ++ ++#[test] ++fn helper_dumps_site_older_and_target_module_trees() { ++ let temporary = tempfile::tempdir().expect("temp"); ++ let bash_env = temporary.path().join("module-init.sh"); ++ std::fs::write( ++ &bash_env, ++ r#" ++module() { ++ case "$1 $2" in ++ "--force purge") unset TEST_MODULE ;; ++ "load EESSI/2025.06") export TEST_MODULE=older ;; ++ "load EESSI/2026.06") export TEST_MODULE=target ;; ++ "-t avail") ++ case "${TEST_MODULE:-site}" in ++ site) printf 'SiteApp/1.0\n' ;; ++ older) printf 'LAMMPS/2024.0\n' ;; ++ target) printf 'FFTW/3.3.10-gompi-2026.1\n' ;; ++ esac ++ ;; ++ *) return 2 ;; ++ esac ++} ++"#, ++ ) ++ .expect("module init"); ++ let output_dir = temporary.path().join("dumps"); ++ let output = Command::new(root().join("scripts/dump-module-trees.sh")) ++ .env("BASH_ENV", &bash_env) ++ .arg(&output_dir) ++ .args(["-", "EESSI/2025.06", "EESSI/2026.06"]) ++ .output() ++ .expect("dump helper"); ++ assert!( ++ output.status.success(), ++ "{}", ++ String::from_utf8_lossy(&output.stderr) ++ ); ++ assert_eq!( ++ std::fs::read_to_string(output_dir.join("site.txt")).unwrap(), ++ "SiteApp/1.0\n" ++ ); ++ assert_eq!( ++ std::fs::read_to_string(output_dir.join("eessi-older.txt")).unwrap(), ++ "LAMMPS/2024.0\n" ++ ); ++ assert_eq!( ++ std::fs::read_to_string(output_dir.join("eessi-target.txt")).unwrap(), ++ "FFTW/3.3.10-gompi-2026.1\n" ++ ); ++} +diff --git a/tests/stack_gap.rs b/tests/stack_gap.rs +index c5181a9..f6f6864 100644 +--- a/tests/stack_gap.rs ++++ b/tests/stack_gap.rs +@@ -96,6 +96,59 @@ fn routes_are_written_one_file_per_set() { + } + } + ++#[test] ++fn route_fixture_has_one_row_for_every_route() { ++ let out = tempfile::tempdir().expect("temp"); ++ let output = Command::new(env!("CARGO_BIN_EXE_eb-stack")) ++ .current_dir(root()) ++ .env_remove("EASYBUILD_FILTER_DEPS") ++ .args([ ++ "stack", ++ "gap", ++ "--site", ++ "tests/gap_routes/one-per-route.txt", ++ "--installed", ++ "tests/gap_routes/installed.txt", ++ "--easyconfigs", ++ "tests/gap_routes/easyconfigs", ++ "--generation", ++ "2026.1", ++ "--exclude-file", ++ "tests/gap_routes/site-only.tsv", ++ "--filter-deps", ++ "zlib", ++ "--open-layer", ++ "tests/gap_routes/open-layer.json", ++ "--open-easyconfigs", ++ "tests/gap_routes/open-easyconfigs.json", ++ "--out-dir", ++ ]) ++ .arg(out.path()) ++ .output() ++ .expect("stack gap"); ++ let stdout = String::from_utf8(output.stdout).unwrap(); ++ let stderr = String::from_utf8_lossy(&output.stderr); ++ assert!(output.status.success(), "{stdout}{stderr}"); ++ assert!( ++ stdout.contains( ++ "site=7 in-stack=1 filtered=1 open-layer=1 recipe=1 open-easyconfigs=1 no-recipe=1 excluded=1" ++ ), ++ "{stdout}" ++ ); ++ for name in [ ++ "in-stack.tsv", ++ "site-only.tsv", ++ "filtered.tsv", ++ "open-layer.tsv", ++ "recipe.tsv", ++ "open-easyconfigs.tsv", ++ "bump.tsv", ++ ] { ++ let text = std::fs::read_to_string(out.path().join(name)).unwrap(); ++ assert_eq!(text.lines().count(), 2, "{name}:\n{text}"); ++ } ++} ++ + #[test] + fn live_module_trees_union_the_site_an_older_eessi_and_both_target_dirs() { + let out = tempfile::tempdir().expect("temp"); +-- +2.43.0 + diff --git a/artifacts/patches/0003-feat-stack-write-module-version-changes.patch b/artifacts/patches/0003-feat-stack-write-module-version-changes.patch new file mode 100644 index 00000000..99b4acbb --- /dev/null +++ b/artifacts/patches/0003-feat-stack-write-module-version-changes.patch @@ -0,0 +1,466 @@ +From 1342ae911ba2eb9078604b08f1a4094337249903 Mon Sep 17 00:00:00 2001 +From: Rohit Goswami +Date: Tue, 3 Nov 2026 09:15:45 +0000 +Subject: [PATCH 3/7] feat(stack): write module version changes + +--- + CHANGELOG.md | 4 + + docs/orgmode/howto/module-gap.org | 8 ++ + docs/orgmode/reference/cli.org | 5 +- + src/gap.rs | 177 +++++++++++++++++++++++++++--- + src/main.rs | 3 +- + src/mcp.rs | 2 +- + src/site_commands.rs | 38 +++++-- + tests/stack_gap.rs | 23 ++++ + 8 files changed, 233 insertions(+), 27 deletions(-) + +diff --git a/CHANGELOG.md b/CHANGELOG.md +index 3ccb293..ece0820 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -12,6 +12,10 @@ All notable changes to this project are documented here. + - `scripts/dump-module-trees.sh` captures the live site, older EESSI, and + target EESSI `module -t avail` inventories in isolated, purged shells for + `stack gap`. ++- `stack gap` writes `version-change.tsv`, one row per version in the site ++ and older-EESSI inventories. A module in the target is `moved` and names ++ its EESSI version; an excluded module is `site-only`; every other missing ++ route is `gone` until it lands. + - `stack shard` splits one Jenkins build list into shards that can + build at the same time. A line's set is its closure over + `--easyconfigs` minus the modules in `--installed-modules-dir` Lmod +diff --git a/docs/orgmode/howto/module-gap.org b/docs/orgmode/howto/module-gap.org +index 475f9a5..625f5ee 100644 +--- a/docs/orgmode/howto/module-gap.org ++++ b/docs/orgmode/howto/module-gap.org +@@ -123,6 +123,14 @@ Each route also has its own file: ~in-stack.tsv~, ~site-only.tsv~, + and ~bump.tsv~. An ~open-layer~ match is the easystack line the pull + request adds, not a dependency that line would build. + ++~version-change.tsv~ is the user migration table. It keeps the module ++versions exactly as the live dumps spell them, including a toolchain ++suffix. An ~in-stack~ name is ~moved~ and names the target EESSI ++version. An ~excluded~ name is ~site-only~. Every other route is ++~gone~ from the opened stack until that route lands. A name with two ++old versions has two rows; multiple target versions are comma-separated. ++The columns are ~name~, ~old_version~, ~eessi_version~, and ~status~. ++ + ~eb_stack_gap~ writes the same files. ~open_layer~ and + ~open_easyconfigs~ are arrays of the same pull-request JSON + ~pr-overlap --pr~ reads. +diff --git a/docs/orgmode/reference/cli.org b/docs/orgmode/reference/cli.org +index d172d48..741a5ea 100644 +--- a/docs/orgmode/reference/cli.org ++++ b/docs/orgmode/reference/cli.org +@@ -677,7 +677,10 @@ version counts. The command writes ~gap.tsv~, one TSV per + route (~site-only.tsv~ for ~excluded~, ~bump.tsv~ for ~no-recipe~, and + ~in-stack.tsv~, ~filtered.tsv~, ~open-layer.tsv~, ~recipe.tsv~, + ~open-easyconfigs.tsv~), ~roots.txt~ for ~stack batch --roots-file~ (the +-~recipe~ rows only), and ~bump-sources.txt~ for ~package bump --latest~. ++~recipe~ rows only), ~bump-sources.txt~ for ~package bump --latest~, and ++~version-change.tsv~. The last file has one row per old module version: ++~moved~ names the target EESSI version, ~site-only~ is an ~excluded~ ++name, and every other missing route is ~gone~ until it lands. + + ~stack provided~ reads each ~--build-list~ (a Jenkins list or one + easyconfig path per line; ~#~ comments are skipped) and fails when a +diff --git a/src/gap.rs b/src/gap.rs +index 1b152b1..98ddbc7 100644 +--- a/src/gap.rs ++++ b/src/gap.rs +@@ -53,6 +53,12 @@ pub struct OpenCarry { + pub label: String, + } + ++/// Module names and the versions the live listing exposes. ++/// ++/// A name with an empty set came from a bare module name, so its version is ++/// unknown rather than absent from the inventory. ++pub type ModuleInventory = BTreeMap>; ++ + /// The lists [`gap`] reads. + pub struct GapInput<'a> { + /// Site package names. +@@ -100,11 +106,19 @@ pub fn names_from_listing(text: &str) -> BTreeSet { + /// not. A file is `module -t avail`, `module spider -t`, or the same + /// `Name/version` lines as [`names_from_listing`]. + pub fn names_from_module_source(path: &Path) -> Result> { ++ Ok(inventory_from_module_source(path)?.into_keys().collect()) ++} ++ ++/// Module names and versions from a listing file or an Lmod module tree. ++/// ++/// Versions are kept as Lmod spells them, including any toolchain suffix. ++/// Bare names have an empty version set. ++pub fn inventory_from_module_source(path: &Path) -> Result { + if path.is_dir() { +- names_from_module_tree(path).with_context(|| path.display().to_string()) ++ inventory_from_module_tree(path).with_context(|| path.display().to_string()) + } else { + let text = std::fs::read_to_string(path).with_context(|| path.display().to_string())?; +- Ok(names_from_live_listing(&text)) ++ Ok(inventory_from_live_listing(&text)) + } + } + +@@ -113,24 +127,35 @@ pub fn names_from_module_source(path: &Path) -> Result> { + /// Path headers, `(default)`, and a `Name: Name/version, ...` spider line + /// are read. A plain `Name/version` or bare name is unchanged. + pub fn names_from_live_listing(text: &str) -> BTreeSet { +- let mut names = BTreeSet::new(); ++ inventory_from_live_listing(text).into_keys().collect() ++} ++ ++/// Module names and versions in `module -t avail` or `module spider -t` ++/// output. ++pub fn inventory_from_live_listing(text: &str) -> ModuleInventory { ++ let mut inventory = ModuleInventory::new(); + for line in text.lines() { + for module in live_module_tokens(line) { +- let Some(name) = module.split('/').next() else { ++ let mut parts = module.splitn(2, '/'); ++ let Some(name) = parts.next() else { + continue; + }; +- if !name.is_empty() { +- names.insert(name.to_string()); ++ if name.is_empty() { ++ continue; ++ } ++ let versions = inventory.entry(name.to_string()).or_default(); ++ if let Some(version) = parts.next().filter(|version| !version.is_empty()) { ++ versions.insert(version.to_string()); + } + } + } +- names ++ inventory + } + +-fn names_from_module_tree(root: &Path) -> std::io::Result> { +- let mut names = BTreeSet::new(); +- walk_module_tree(root, &mut names)?; +- Ok(names) ++fn inventory_from_module_tree(root: &Path) -> std::io::Result { ++ let mut inventory = ModuleInventory::new(); ++ walk_module_tree(root, &mut inventory)?; ++ Ok(inventory) + } + + /// One tab-separated file per route `stack gap` writes, besides `gap.tsv`. +@@ -318,6 +343,93 @@ pub fn render_tsv(rows: &[GapRow]) -> String { + out + } + ++/// One previous-stack module version and where users find it in the new ++/// stack. ++#[derive(Debug, Clone, PartialEq, Eq, Serialize)] ++pub struct VersionChangeRow { ++ /// EasyBuild software name. ++ pub name: String, ++ /// Version in the site or older-EESSI inventory. `None` for a bare name. ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub old_version: Option, ++ /// Version in the target EESSI inventory. Multiple target versions are ++ /// comma-separated. Set only for `moved`. ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub eessi_version: Option, ++ /// `moved`, `gone`, or `site-only`. ++ pub status: String, ++} ++ ++fn inventory_versions<'a>( ++ inventory: &'a ModuleInventory, ++ name: &str, ++) -> Option<&'a BTreeSet> { ++ inventory.get(name).or_else(|| { ++ inventory ++ .iter() ++ .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) ++ .map(|(_, versions)| versions) ++ }) ++} ++ ++/// Turn gap routes into the user-facing version change table. ++/// ++/// An `in-stack` module moved to EESSI, an `excluded` module remains ++/// site-only, and every other route is gone from the opened stack until that ++/// route lands. One row is written per old module version; a bare old name ++/// still contributes one row with no version. ++pub fn version_changes( ++ rows: &[GapRow], ++ old: &ModuleInventory, ++ eessi: &ModuleInventory, ++) -> Vec { ++ let mut changes = Vec::new(); ++ for row in rows { ++ let status = match row.route.as_str() { ++ "in-stack" => "moved", ++ "excluded" => "site-only", ++ _ => "gone", ++ }; ++ let eessi_version = (status == "moved") ++ .then(|| inventory_versions(eessi, &row.name)) ++ .flatten() ++ .filter(|versions| !versions.is_empty()) ++ .map(|versions| versions.iter().cloned().collect::>().join(",")); ++ let old_versions = inventory_versions(old, &row.name); ++ if let Some(versions) = old_versions.filter(|versions| !versions.is_empty()) { ++ changes.extend(versions.iter().map(|version| VersionChangeRow { ++ name: row.name.clone(), ++ old_version: Some(version.clone()), ++ eessi_version: eessi_version.clone(), ++ status: status.to_string(), ++ })); ++ } else { ++ changes.push(VersionChangeRow { ++ name: row.name.clone(), ++ old_version: None, ++ eessi_version, ++ status: status.to_string(), ++ }); ++ } ++ } ++ changes ++} ++ ++/// A tab-separated version change table with a header line. ++pub fn render_version_changes(rows: &[VersionChangeRow]) -> String { ++ let mut out = String::from("name\told_version\teessi_version\tstatus\n"); ++ for row in rows { ++ out.push_str(&format!( ++ "{}\t{}\t{}\t{}\n", ++ row.name, ++ row.old_version.as_deref().unwrap_or("-"), ++ row.eessi_version.as_deref().unwrap_or("-"), ++ row.status ++ )); ++ } ++ out ++} ++ + /// The package name an easyconfig filename names. + /// + /// `hwloc-CUDA-2.11.2-GCCcore-15.2.0.eb` is `hwloc-CUDA`. A path contributes +@@ -388,7 +500,7 @@ pub fn carries_from_layer_version( + out + } + +-fn walk_module_tree(dir: &Path, names: &mut BTreeSet) -> std::io::Result<()> { ++fn walk_module_tree(dir: &Path, inventory: &mut ModuleInventory) -> std::io::Result<()> { + let entries = std::fs::read_dir(dir).map_err(|error| io_at(dir, error))?; + for entry in entries { + let entry = entry.map_err(|error| io_at(dir, error))?; +@@ -406,7 +518,7 @@ fn walk_module_tree(dir: &Path, names: &mut BTreeSet) -> std::io::Result + Err(_) => continue, + } + } else if file_type.is_dir() { +- walk_module_tree(&path, names)?; ++ walk_module_tree(&path, inventory)?; + continue; + } + if !is_module_file(&file_name) { +@@ -423,7 +535,10 @@ fn walk_module_tree(dir: &Path, names: &mut BTreeSet) -> std::io::Result + if stem.is_empty() || stem == "default" { + continue; + } +- names.insert(software.to_string()); ++ inventory ++ .entry(software.to_string()) ++ .or_default() ++ .insert(stem.to_string()); + } + Ok(()) + } +@@ -866,6 +981,35 @@ Where: + "LAMMPS".to_string(), + ]) + ); ++ let inventory = inventory_from_live_listing(text); ++ assert_eq!( ++ inventory["zlib"], ++ BTreeSet::from(["1.2.13".to_string(), "1.3.1".to_string()]) ++ ); ++ assert_eq!(inventory["GCCcore"], BTreeSet::from(["14.2.0".to_string()])); ++ assert!(inventory["LAMMPS"].is_empty()); ++ } ++ ++ #[test] ++ fn version_changes_name_moved_gone_and_site_only_rows() { ++ let old = inventory_from_live_listing( ++ "FFTW/3.3.10-gompi-2025b\nLAMMPS/2024.0-foss-2025b\nMATLAB/2024b\n", ++ ); ++ let eessi = inventory_from_live_listing("FFTW/3.3.10-gompi-2026.1\n"); ++ let rows = vec![ ++ row("FFTW", "in-stack", None, None, None), ++ row("LAMMPS", "open-layer", None, None, None), ++ row("MATLAB", "excluded", None, None, None), ++ ]; ++ assert_eq!( ++ render_version_changes(&version_changes(&rows, &old, &eessi)), ++ "\ ++name\told_version\teessi_version\tstatus ++FFTW\t3.3.10-gompi-2025b\t3.3.10-gompi-2026.1\tmoved ++LAMMPS\t2024.0-foss-2025b\t-\tgone ++MATLAB\t2024b\t-\tsite-only ++" ++ ); + } + + #[test] +@@ -883,7 +1027,10 @@ Where: + let binutils = root.path().join("Compiler/GCCcore/13.2.0/binutils"); + std::fs::create_dir_all(&binutils).unwrap(); + std::fs::write(binutils.join("2.42.lua"), "").unwrap(); +- let names = names_from_module_tree(root.path()).unwrap(); ++ let names: BTreeSet = inventory_from_module_tree(root.path()) ++ .unwrap() ++ .into_keys() ++ .collect(); + assert_eq!( + names, + BTreeSet::from([ +diff --git a/src/main.rs b/src/main.rs +index 779e30a..e37960d 100644 +--- a/src/main.rs ++++ b/src/main.rs +@@ -1136,7 +1136,8 @@ enum StackCommand { + /// this generation), or `excluded`. A directory is an Lmod tree. A file + /// is `module -t avail`, `module spider -t`, or `Name/version`. Writes + /// `gap.tsv`, one TSV per route, `roots.txt` for `stack batch +- /// --roots-file`, and `bump-sources.txt`. ++ /// --roots-file`, `bump-sources.txt`, and `version-change.tsv` with old ++ /// versions moved to EESSI, gone, or kept site-only. + #[command( + after_long_help = "Examples:\n eb-stack stack gap --site tests/rebuild_set_fixtures/installed.txt --installed tests/pr_overlap_layer/installed.txt --easyconfigs tests/rebuild_set_fixtures/release --generation 2026.1 --out-dir gap\n\nThose paths are files in this repository. A missing --site file exits 1 and names the path." + )] +diff --git a/src/mcp.rs b/src/mcp.rs +index bf8df83..7484f61 100644 +--- a/src/mcp.rs ++++ b/src/mcp.rs +@@ -380,7 +380,7 @@ fn tool_catalog() -> Vec { + ), + tool_with_optional( + "eb_stack_gap", +- "Classify every package in site and older module trees against a target stack, as `stack gap` does: in-stack, excluded (site-only), filtered, open-layer, recipe, open-easyconfigs, or no-recipe (a bump). A path may be an Lmod directory or module -t avail / module spider -t text. installed repeats for a CPU tree and an accelerator tree. eessi_version keeps open software-layer easystacks of that version. Writes gap.tsv, one TSV per route, roots.txt and bump-sources.txt under out_dir.", ++ "Classify every package in site and older module trees against a target stack, as `stack gap` does: in-stack, excluded (site-only), filtered, open-layer, recipe, open-easyconfigs, or no-recipe (a bump). A path may be an Lmod directory or module -t avail / module spider -t text. installed repeats for a CPU tree and an accelerator tree. eessi_version keeps open software-layer easystacks of that version. Writes gap.tsv, one TSV per route, roots.txt, bump-sources.txt, and version-change.tsv (old versions moved to EESSI, gone, or site-only) under out_dir.", + &[ + ("installed", "array"), + ("easyconfigs", "array"), +diff --git a/src/site_commands.rs b/src/site_commands.rs +index 4e82a7a..30a9646 100644 +--- a/src/site_commands.rs ++++ b/src/site_commands.rs +@@ -1205,7 +1205,8 @@ impl StackGapRun { + /// each gap. Writes `gap.tsv`, one TSV per route, `roots.txt` (the recipes + /// on the generation, the input of `stack batch --roots-file`), and + /// `bump-sources.txt` (the newest recipe on any toolchain for each name +-/// without one). ++/// without one), and `version-change.tsv` (old module versions moved to the ++/// target EESSI version, gone, or kept site-only). + pub fn run_stack_gap(args: &StackGapRequest) -> Result { + use std::collections::{BTreeMap, BTreeSet}; + if args.site.is_empty() && args.older.is_empty() { +@@ -1214,26 +1215,31 @@ pub fn run_stack_gap(args: &StackGapRequest) -> Result { + if args.installed.is_empty() { + return Err(usage("pass at least one --installed target tree")); + } +- let mut site = BTreeSet::new(); ++ let mut site_inventory = crate::gap::ModuleInventory::new(); + for path in &args.site { +- site.extend( +- crate::gap::names_from_module_source(path) ++ merge_module_inventory( ++ &mut site_inventory, ++ crate::gap::inventory_from_module_source(path) + .with_context(|| format!("--site {}", path.display()))?, + ); + } + for path in &args.older { +- site.extend( +- crate::gap::names_from_module_source(path) ++ merge_module_inventory( ++ &mut site_inventory, ++ crate::gap::inventory_from_module_source(path) + .with_context(|| format!("--older {}", path.display()))?, + ); + } +- let mut stack = BTreeSet::new(); ++ let site: BTreeSet = site_inventory.keys().cloned().collect(); ++ let mut stack_inventory = crate::gap::ModuleInventory::new(); + for path in &args.installed { +- stack.extend( +- crate::gap::names_from_module_source(path) ++ merge_module_inventory( ++ &mut stack_inventory, ++ crate::gap::inventory_from_module_source(path) + .with_context(|| format!("--installed {}", path.display()))?, + ); + } ++ let stack: BTreeSet = stack_inventory.keys().cloned().collect(); + let filter_specs = dep_filter::specs_from_cli_or_env(&args.filter_deps); + let filter = FilterDeps::parse(&filter_specs) + .map_err(|error| usage(format!("--filter-deps: {error}")))?; +@@ -1313,6 +1319,11 @@ pub fn run_stack_gap(args: &StackGapRequest) -> Result { + &args.out_dir.join("gap.tsv"), + &crate::gap::render_tsv(&rows), + )?; ++ let version_changes = crate::gap::version_changes(&rows, &site_inventory, &stack_inventory); ++ crate::write_text( ++ &args.out_dir.join("version-change.tsv"), ++ &crate::gap::render_version_changes(&version_changes), ++ )?; + for (route, filename) in crate::gap::ROUTE_FILES { + let subset: Vec<_> = rows + .iter() +@@ -1347,6 +1358,15 @@ pub fn run_stack_gap(args: &StackGapRequest) -> Result { + }) + } + ++fn merge_module_inventory( ++ into: &mut crate::gap::ModuleInventory, ++ from: crate::gap::ModuleInventory, ++) { ++ for (name, versions) in from { ++ into.entry(name).or_default().extend(versions); ++ } ++} ++ + /// Arguments for `stack batch`. + pub struct StackBatchRequest { + /// Root easyconfig file names. +diff --git a/tests/stack_gap.rs b/tests/stack_gap.rs +index f6f6864..e317cf1 100644 +--- a/tests/stack_gap.rs ++++ b/tests/stack_gap.rs +@@ -212,4 +212,27 @@ fn live_module_trees_union_the_site_an_older_eessi_and_both_target_dirs() { + ); + assert!(gap.contains("QuantumESPRESSO\tno-recipe\t"), "{gap}"); + assert!(!gap.contains("software-layer#1600"), "{gap}"); ++ let changes = std::fs::read_to_string(out.path().join("version-change.tsv")).unwrap(); ++ assert!( ++ changes.starts_with("name\told_version\teessi_version\tstatus\n"), ++ "{changes}" ++ ); ++ assert!( ++ changes.contains("CUDA\t12.6.0\t12.6.0\tmoved\n"), ++ "{changes}" ++ ); ++ assert!( ++ changes.contains("FFTW\t3.3.10\t3.3.10\tmoved\n"), ++ "{changes}" ++ ); ++ assert!( ++ changes.contains("LAMMPS\t2023.0\t-\tgone\n") ++ && changes.contains("LAMMPS\t2024.0\t-\tgone\n"), ++ "{changes}" ++ ); ++ assert!( ++ changes.contains("MATLAB\t2024b\t-\tsite-only\n") ++ && changes.contains("code-server\t4.96.2\t-\tsite-only\n"), ++ "{changes}" ++ ); + } +-- +2.43.0 + diff --git a/artifacts/patches/0004-docs-stack-refresh-module-gap-index.patch b/artifacts/patches/0004-docs-stack-refresh-module-gap-index.patch new file mode 100644 index 00000000..dc4ccbeb --- /dev/null +++ b/artifacts/patches/0004-docs-stack-refresh-module-gap-index.patch @@ -0,0 +1,123 @@ +From 02aec4222996c1188b7d00916a095f560a69932c Mon Sep 17 00:00:00 2001 +From: Rohit Goswami +Date: Tue, 3 Nov 2026 09:16:06 +0000 +Subject: [PATCH 4/7] docs(stack): refresh module gap index + +--- + llms-full.txt | 72 ++++++++++++++++++++++++++++++++++++++++++++++----- + 1 file changed, 66 insertions(+), 6 deletions(-) + +diff --git a/llms-full.txt b/llms-full.txt +index a085844..3eb8338 100644 +--- a/llms-full.txt ++++ b/llms-full.txt +@@ -4051,10 +4051,10 @@ eb-stack stack provided \ + That command printed: + + ``` +-provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list +-provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list +-provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list +-unparsed not-a-version.eb list=tests/provided_fixtures/site.list ++provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list line=3 ++provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 ++provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 ++unparsed not-a-version.eb list=tests/provided_fixtures/site.list line=7 + toolchain fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 uses=foss-2025b; a site-layer line on EESSI 2026.06 must use a 2026.1 toolchain or GCCcore-15.2.0 below it (GCCcore-15.2.0, GCC-15.2.0, gfbf-2026.1, gompi-2026.1, foss-2026.1) + hooks FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 sets=--hooks=/sw/hooks/site.py; which replaces EESSI's hooks + hits=3 unparsed=1 allowed=0 provided=3 toolchain=1 hooks=1 family=0 licensed=0 +@@ -4167,6 +4167,55 @@ A toolchain whose version is the generation counts as on it, so + generation in `data/eessi-generations.toml`, such as + `llvm-compilers-21.1.8`. + ++# Capture the live inventories ++ ++Run the helper from a shell where the `module` command is initialized. ++Its four arguments are the output directory, the site meta-module, the ++older EESSI module, and the target EESSI module. Use `-` for the site ++argument when the base module path is the live site tree. ++ ++```bash ++scripts/dump-module-trees.sh \ ++ /tmp/module-dumps - EESSI/2025.06 EESSI/2026.06 ++``` ++ ++Each inventory is captured in a separately purged subshell, so loading ++one EESSI version cannot add paths to the next dump. The files are ++`/tmp/module-dumps/site.txt`, `eessi-older.txt`, and ++`eessi-target.txt`. Pass them as `--site`, `--older`, and ++`--installed`, respectively. They are the raw `module -t avail` ++output; `stack gap` ignores path headers and default markers. ++ ++# Read the open-pull-request inventories ++ ++The two `fetch-prs.sh` examples produce the JSON accepted by ++`--open-layer` and `--open-easyconfigs`. Run each script in the clone ++named in its comment: ++ ++```bash ++EB_STACK=/path/to/eb-stack ++(cd /path/to/software-layer && \ ++ "$EB_STACK/examples/eessi-pr-overlap/fetch-prs.sh" \ ++ EESSI/software-layer 2026.06 /tmp/open-layer) ++(cd /path/to/easybuild-easyconfigs && \ ++ "$EB_STACK/examples/easyconfigs-pr-overlap/fetch-prs.sh" \ ++ easybuilders/easybuild-easyconfigs /tmp/open-easyconfigs) ++ ++layer_args=() ++for file in /tmp/open-layer/prs/*.json; do ++ layer_args+=(--open-layer "$file") ++done ++easyconfig_args=() ++for file in /tmp/open-easyconfigs/prs/*.json; do ++ easyconfig_args+=(--open-easyconfigs "$file") ++done ++``` ++ ++Add `"${layer_args[@]}"` and `"${easyconfig_args[@]}"` to the ++`stack gap` command. The fetch scripts only read pull requests. A ++closed pull request disappears from their rewritten `prs/` directory ++on the next run. ++ + # Run the fixture + + The nine names are in `tests/gap_routes/site.txt`. The easyconfig tree +@@ -4221,6 +4270,14 @@ Each route also has its own file: `in-stack.tsv`, `site-only.tsv`, + and `bump.tsv`. An `open-layer` match is the easystack line the pull + request adds, not a dependency that line would build. + ++`version-change.tsv` is the user migration table. It keeps the module ++versions exactly as the live dumps spell them, including a toolchain ++suffix. An `in-stack` name is `moved` and names the target EESSI ++version. An `excluded` name is `site-only`. Every other route is ++`gone` from the opened stack until that route lands. A name with two ++old versions has two rows; multiple target versions are comma-separated. ++The columns are `name`, `old_version`, `eessi_version`, and `status`. ++ + `eb_stack_gap` writes the same files. `open_layer` and + `open_easyconfigs` are arrays of the same pull-request JSON + `pr-overlap --pr` reads. +@@ -5821,7 +5878,10 @@ version counts. The command writes `gap.tsv`, one TSV per + route (`site-only.tsv` for `excluded`, `bump.tsv` for `no-recipe`, and + `in-stack.tsv`, `filtered.tsv`, `open-layer.tsv`, `recipe.tsv`, + `open-easyconfigs.tsv`), `roots.txt` for `stack batch --roots-file` (the +-`recipe` rows only), and `bump-sources.txt` for `package bump --latest`. ++`recipe` rows only), `bump-sources.txt` for `package bump --latest`, and ++`version-change.tsv`. The last file has one row per old module version: ++`moved` names the target EESSI version, `site-only` is an `excluded` ++name, and every other missing route is `gone` until it lands. + + `stack provided` reads each `--build-list` (a Jenkins list or one + easyconfig path per line; `#` comments are skipped) and fails when a +@@ -5838,7 +5898,7 @@ path, not left as site layer. `--licensed` and `--licensed-file` warn + for group-restricted names; `data/site-licensed.txt` is that list for + VASP, MATLAB, ANSYS, ORCA, and Gurobi. With neither flag there is no + warning. `--strict` makes a remaining warning exit 1. Stdout lists each +-hit as `provided name= list=` and ends with ++hit as `provided name= list= line=` and ends with + `hits= unparsed= allowed= provided= toolchain= hooks= family= licensed=`. + `--format` is `text` by default, `github` for workflow commands, or + `json`. `--json` is the same as `--format json`. See [fail a build list that names software the baseline provides](https://eb-stack.rgoswami.me/../howto/build-list-provided.html.md) +-- +2.43.0 + diff --git a/artifacts/patches/0005-docs-stack-use-configured-upstream-trees.patch b/artifacts/patches/0005-docs-stack-use-configured-upstream-trees.patch new file mode 100644 index 00000000..ca7ea6b0 --- /dev/null +++ b/artifacts/patches/0005-docs-stack-use-configured-upstream-trees.patch @@ -0,0 +1,51 @@ +From 3ada26fd06841aa776b2b84b4abe502a53aaa741 Mon Sep 17 00:00:00 2001 +From: Rohit Goswami +Date: Tue, 3 Nov 2026 09:17:10 +0000 +Subject: [PATCH 5/7] docs(stack): use configured upstream trees + +--- + docs/orgmode/howto/module-gap.org | 6 +++--- + llms-full.txt | 6 +++--- + 2 files changed, 6 insertions(+), 6 deletions(-) + +diff --git a/docs/orgmode/howto/module-gap.org b/docs/orgmode/howto/module-gap.org +index 625f5ee..ff79390 100644 +--- a/docs/orgmode/howto/module-gap.org ++++ b/docs/orgmode/howto/module-gap.org +@@ -46,11 +46,11 @@ The two ~fetch-prs.sh~ examples produce the JSON accepted by + named in its comment: + + #+begin_src bash +-EB_STACK=/path/to/eb-stack +-(cd /path/to/software-layer && \ ++EB_STACK=$PWD ++(cd "$EB_EESSI_SOFTWARE_LAYER" && \ + "$EB_STACK/examples/eessi-pr-overlap/fetch-prs.sh" \ + EESSI/software-layer 2026.06 /tmp/open-layer) +-(cd /path/to/easybuild-easyconfigs && \ ++(cd "$EB_EASYCONFIGS_DEVELOP" && \ + "$EB_STACK/examples/easyconfigs-pr-overlap/fetch-prs.sh" \ + easybuilders/easybuild-easyconfigs /tmp/open-easyconfigs) + +diff --git a/llms-full.txt b/llms-full.txt +index 3eb8338..668bfaa 100644 +--- a/llms-full.txt ++++ b/llms-full.txt +@@ -4193,11 +4193,11 @@ The two `fetch-prs.sh` examples produce the JSON accepted by + named in its comment: + + ```bash +-EB_STACK=/path/to/eb-stack +-(cd /path/to/software-layer && \ ++EB_STACK=$PWD ++(cd "$EB_EESSI_SOFTWARE_LAYER" && \ + "$EB_STACK/examples/eessi-pr-overlap/fetch-prs.sh" \ + EESSI/software-layer 2026.06 /tmp/open-layer) +-(cd /path/to/easybuild-easyconfigs && \ ++(cd "$EB_EASYCONFIGS_DEVELOP" && \ + "$EB_STACK/examples/easyconfigs-pr-overlap/fetch-prs.sh" \ + easybuilders/easybuild-easyconfigs /tmp/open-easyconfigs) + +-- +2.43.0 + diff --git a/artifacts/patches/0006-ci-stack-run-module-dump-helper-test.patch b/artifacts/patches/0006-ci-stack-run-module-dump-helper-test.patch new file mode 100644 index 00000000..16cf3fa2 --- /dev/null +++ b/artifacts/patches/0006-ci-stack-run-module-dump-helper-test.patch @@ -0,0 +1,24 @@ +From 55b5a7345d4175ba549fb94edad32f6459cd444b Mon Sep 17 00:00:00 2001 +From: Rohit Goswami +Date: Tue, 3 Nov 2026 09:18:01 +0000 +Subject: [PATCH 6/7] ci(stack): run module dump helper test + +--- + .github/workflows/ci_test.yml | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/.github/workflows/ci_test.yml b/.github/workflows/ci_test.yml +index a193079..e400acd 100644 +--- a/.github/workflows/ci_test.yml ++++ b/.github/workflows/ci_test.yml +@@ -185,6 +185,7 @@ jobs: + --test llms_txt \ + --test mcp_registry \ + --test mcp_stdio \ ++ --test module_tree_dump_script \ + --test oom_profile \ + --test optimisation_criteria \ + --test pr_merge_order \ +-- +2.43.0 + diff --git a/artifacts/patches/0007-docs-stack-fix-shard-API-literal.patch b/artifacts/patches/0007-docs-stack-fix-shard-API-literal.patch new file mode 100644 index 00000000..fa8a3c43 --- /dev/null +++ b/artifacts/patches/0007-docs-stack-fix-shard-API-literal.patch @@ -0,0 +1,29 @@ +From 682ea6513577bafe7ee40cde49de40b38716ef7a Mon Sep 17 00:00:00 2001 +From: Rohit Goswami +Date: Tue, 3 Nov 2026 09:22:27 +0000 +Subject: [PATCH 7/7] docs(stack): fix shard API literal + +--- + src/shard.rs | 6 +++--- + 1 file changed, 3 insertions(+), 3 deletions(-) + +diff --git a/src/shard.rs b/src/shard.rs +index 8e60a46..870c59d 100644 +--- a/src/shard.rs ++++ b/src/shard.rs +@@ -267,9 +267,9 @@ pub fn plan( + } + } + +-/// Read a weights file: `moduleseconds` per line, `#` comments and blank +-/// lines skipped. A fractional value rounds up to whole seconds. A later line +-/// for the same module replaces an earlier one. ++/// Read a weights file: `module`, a tab, then `seconds` per line. `#` comments ++/// and blank lines are skipped. A fractional value rounds up to whole seconds. ++/// A later line for the same module replaces an earlier one. + pub fn parse_weights(text: &str) -> Result, String> { + let mut out = BTreeMap::new(); + for (i, raw) in text.lines().enumerate() { +-- +2.43.0 + diff --git a/docs/orgmode/howto/build-list-provided.org b/docs/orgmode/howto/build-list-provided.org index 77c1d99b..b0cc7d64 100644 --- a/docs/orgmode/howto/build-list-provided.org +++ b/docs/orgmode/howto/build-list-provided.org @@ -29,10 +29,10 @@ eb-stack stack provided \ That command printed: #+begin_example -provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list -provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list -provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list -unparsed not-a-version.eb list=tests/provided_fixtures/site.list +provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list line=3 +provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 +provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 +unparsed not-a-version.eb list=tests/provided_fixtures/site.list line=7 toolchain fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 uses=foss-2025b; a site-layer line on EESSI 2026.06 must use a 2026.1 toolchain or GCCcore-15.2.0 below it (GCCcore-15.2.0, GCC-15.2.0, gfbf-2026.1, gompi-2026.1, foss-2026.1) hooks FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 sets=--hooks=/sw/hooks/site.py; which replaces EESSI's hooks hits=3 unparsed=1 allowed=0 provided=3 toolchain=1 hooks=1 family=0 licensed=0 diff --git a/docs/orgmode/howto/module-gap.org b/docs/orgmode/howto/module-gap.org index 13ef27c5..ff79390e 100644 --- a/docs/orgmode/howto/module-gap.org +++ b/docs/orgmode/howto/module-gap.org @@ -20,6 +20,55 @@ A toolchain whose version is the generation counts as on it, so generation in ~data/eessi-generations.toml~, such as ~llvm-compilers-21.1.8~. +* Capture the live inventories + +Run the helper from a shell where the ~module~ command is initialized. +Its four arguments are the output directory, the site meta-module, the +older EESSI module, and the target EESSI module. Use ~-~ for the site +argument when the base module path is the live site tree. + +#+begin_src bash +scripts/dump-module-trees.sh \ + /tmp/module-dumps - EESSI/2025.06 EESSI/2026.06 +#+end_src + +Each inventory is captured in a separately purged subshell, so loading +one EESSI version cannot add paths to the next dump. The files are +~/tmp/module-dumps/site.txt~, ~eessi-older.txt~, and +~eessi-target.txt~. Pass them as ~--site~, ~--older~, and +~--installed~, respectively. They are the raw ~module -t avail~ +output; ~stack gap~ ignores path headers and default markers. + +* Read the open-pull-request inventories + +The two ~fetch-prs.sh~ examples produce the JSON accepted by +~--open-layer~ and ~--open-easyconfigs~. Run each script in the clone +named in its comment: + +#+begin_src bash +EB_STACK=$PWD +(cd "$EB_EESSI_SOFTWARE_LAYER" && \ + "$EB_STACK/examples/eessi-pr-overlap/fetch-prs.sh" \ + EESSI/software-layer 2026.06 /tmp/open-layer) +(cd "$EB_EASYCONFIGS_DEVELOP" && \ + "$EB_STACK/examples/easyconfigs-pr-overlap/fetch-prs.sh" \ + easybuilders/easybuild-easyconfigs /tmp/open-easyconfigs) + +layer_args=() +for file in /tmp/open-layer/prs/*.json; do + layer_args+=(--open-layer "$file") +done +easyconfig_args=() +for file in /tmp/open-easyconfigs/prs/*.json; do + easyconfig_args+=(--open-easyconfigs "$file") +done +#+end_src + +Add ~"${layer_args[@]}"~ and ~"${easyconfig_args[@]}"~ to the +~stack gap~ command. The fetch scripts only read pull requests. A +closed pull request disappears from their rewritten ~prs/~ directory +on the next run. + * Run the fixture The nine names are in ~tests/gap_routes/site.txt~. The easyconfig tree @@ -74,6 +123,14 @@ Each route also has its own file: ~in-stack.tsv~, ~site-only.tsv~, and ~bump.tsv~. An ~open-layer~ match is the easystack line the pull request adds, not a dependency that line would build. +~version-change.tsv~ is the user migration table. It keeps the module +versions exactly as the live dumps spell them, including a toolchain +suffix. An ~in-stack~ name is ~moved~ and names the target EESSI +version. An ~excluded~ name is ~site-only~. Every other route is +~gone~ from the opened stack until that route lands. A name with two +old versions has two rows; multiple target versions are comma-separated. +The columns are ~name~, ~old_version~, ~eessi_version~, and ~status~. + ~eb_stack_gap~ writes the same files. ~open_layer~ and ~open_easyconfigs~ are arrays of the same pull-request JSON ~pr-overlap --pr~ reads. diff --git a/docs/orgmode/reference/cli.org b/docs/orgmode/reference/cli.org index 055fe029..741a5eab 100644 --- a/docs/orgmode/reference/cli.org +++ b/docs/orgmode/reference/cli.org @@ -677,7 +677,10 @@ version counts. The command writes ~gap.tsv~, one TSV per route (~site-only.tsv~ for ~excluded~, ~bump.tsv~ for ~no-recipe~, and ~in-stack.tsv~, ~filtered.tsv~, ~open-layer.tsv~, ~recipe.tsv~, ~open-easyconfigs.tsv~), ~roots.txt~ for ~stack batch --roots-file~ (the -~recipe~ rows only), and ~bump-sources.txt~ for ~package bump --latest~. +~recipe~ rows only), ~bump-sources.txt~ for ~package bump --latest~, and +~version-change.tsv~. The last file has one row per old module version: +~moved~ names the target EESSI version, ~site-only~ is an ~excluded~ +name, and every other missing route is ~gone~ until it lands. ~stack provided~ reads each ~--build-list~ (a Jenkins list or one easyconfig path per line; ~#~ comments are skipped) and fails when a @@ -694,7 +697,7 @@ path, not left as site layer. ~--licensed~ and ~--licensed-file~ warn for group-restricted names; ~data/site-licensed.txt~ is that list for VASP, MATLAB, ANSYS, ORCA, and Gurobi. With neither flag there is no warning. ~--strict~ makes a remaining warning exit 1. Stdout lists each -hit as ~provided name= list=~ and ends with +hit as ~provided name= list= line=~ and ends with ~hits= unparsed= allowed= provided= toolchain= hooks= family= licensed=~. ~--format~ is ~text~ by default, ~github~ for workflow commands, or ~json~. ~--json~ is the same as ~--format json~. See [[file:../howto/build-list-provided.org][fail a build list that names software the baseline provides]] diff --git a/llms-full.txt b/llms-full.txt index a0858441..668bfaa3 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4051,10 +4051,10 @@ eb-stack stack provided \ That command printed: ``` -provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list -provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list -provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list -unparsed not-a-version.eb list=tests/provided_fixtures/site.list +provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list line=3 +provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 +provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 +unparsed not-a-version.eb list=tests/provided_fixtures/site.list line=7 toolchain fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 uses=foss-2025b; a site-layer line on EESSI 2026.06 must use a 2026.1 toolchain or GCCcore-15.2.0 below it (GCCcore-15.2.0, GCC-15.2.0, gfbf-2026.1, gompi-2026.1, foss-2026.1) hooks FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 sets=--hooks=/sw/hooks/site.py; which replaces EESSI's hooks hits=3 unparsed=1 allowed=0 provided=3 toolchain=1 hooks=1 family=0 licensed=0 @@ -4167,6 +4167,55 @@ A toolchain whose version is the generation counts as on it, so generation in `data/eessi-generations.toml`, such as `llvm-compilers-21.1.8`. +# Capture the live inventories + +Run the helper from a shell where the `module` command is initialized. +Its four arguments are the output directory, the site meta-module, the +older EESSI module, and the target EESSI module. Use `-` for the site +argument when the base module path is the live site tree. + +```bash +scripts/dump-module-trees.sh \ + /tmp/module-dumps - EESSI/2025.06 EESSI/2026.06 +``` + +Each inventory is captured in a separately purged subshell, so loading +one EESSI version cannot add paths to the next dump. The files are +`/tmp/module-dumps/site.txt`, `eessi-older.txt`, and +`eessi-target.txt`. Pass them as `--site`, `--older`, and +`--installed`, respectively. They are the raw `module -t avail` +output; `stack gap` ignores path headers and default markers. + +# Read the open-pull-request inventories + +The two `fetch-prs.sh` examples produce the JSON accepted by +`--open-layer` and `--open-easyconfigs`. Run each script in the clone +named in its comment: + +```bash +EB_STACK=$PWD +(cd "$EB_EESSI_SOFTWARE_LAYER" && \ + "$EB_STACK/examples/eessi-pr-overlap/fetch-prs.sh" \ + EESSI/software-layer 2026.06 /tmp/open-layer) +(cd "$EB_EASYCONFIGS_DEVELOP" && \ + "$EB_STACK/examples/easyconfigs-pr-overlap/fetch-prs.sh" \ + easybuilders/easybuild-easyconfigs /tmp/open-easyconfigs) + +layer_args=() +for file in /tmp/open-layer/prs/*.json; do + layer_args+=(--open-layer "$file") +done +easyconfig_args=() +for file in /tmp/open-easyconfigs/prs/*.json; do + easyconfig_args+=(--open-easyconfigs "$file") +done +``` + +Add `"${layer_args[@]}"` and `"${easyconfig_args[@]}"` to the +`stack gap` command. The fetch scripts only read pull requests. A +closed pull request disappears from their rewritten `prs/` directory +on the next run. + # Run the fixture The nine names are in `tests/gap_routes/site.txt`. The easyconfig tree @@ -4221,6 +4270,14 @@ Each route also has its own file: `in-stack.tsv`, `site-only.tsv`, and `bump.tsv`. An `open-layer` match is the easystack line the pull request adds, not a dependency that line would build. +`version-change.tsv` is the user migration table. It keeps the module +versions exactly as the live dumps spell them, including a toolchain +suffix. An `in-stack` name is `moved` and names the target EESSI +version. An `excluded` name is `site-only`. Every other route is +`gone` from the opened stack until that route lands. A name with two +old versions has two rows; multiple target versions are comma-separated. +The columns are `name`, `old_version`, `eessi_version`, and `status`. + `eb_stack_gap` writes the same files. `open_layer` and `open_easyconfigs` are arrays of the same pull-request JSON `pr-overlap --pr` reads. @@ -5821,7 +5878,10 @@ version counts. The command writes `gap.tsv`, one TSV per route (`site-only.tsv` for `excluded`, `bump.tsv` for `no-recipe`, and `in-stack.tsv`, `filtered.tsv`, `open-layer.tsv`, `recipe.tsv`, `open-easyconfigs.tsv`), `roots.txt` for `stack batch --roots-file` (the -`recipe` rows only), and `bump-sources.txt` for `package bump --latest`. +`recipe` rows only), `bump-sources.txt` for `package bump --latest`, and +`version-change.tsv`. The last file has one row per old module version: +`moved` names the target EESSI version, `site-only` is an `excluded` +name, and every other missing route is `gone` until it lands. `stack provided` reads each `--build-list` (a Jenkins list or one easyconfig path per line; `#` comments are skipped) and fails when a @@ -5838,7 +5898,7 @@ path, not left as site layer. `--licensed` and `--licensed-file` warn for group-restricted names; `data/site-licensed.txt` is that list for VASP, MATLAB, ANSYS, ORCA, and Gurobi. With neither flag there is no warning. `--strict` makes a remaining warning exit 1. Stdout lists each -hit as `provided name= list=` and ends with +hit as `provided name= list= line=` and ends with `hits= unparsed= allowed= provided= toolchain= hooks= family= licensed=`. `--format` is `text` by default, `github` for workflow commands, or `json`. `--json` is the same as `--format json`. See [fail a build list that names software the baseline provides](https://eb-stack.rgoswami.me/../howto/build-list-provided.html.md) diff --git a/scripts/dump-module-trees.sh b/scripts/dump-module-trees.sh new file mode 100755 index 00000000..421c853c --- /dev/null +++ b/scripts/dump-module-trees.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Capture the live module inventories that `eb-stack stack gap` compares. +# +# Usage: +# dump-module-trees.sh OUT_DIR SITE_MODULE OLDER_EESSI_MODULE TARGET_EESSI_MODULE +# +# Pass - for SITE_MODULE to capture the base site tree without loading a +# meta-module. Run this from a shell where the `module` command is initialized. +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "usage: $0 OUT_DIR SITE_MODULE OLDER_EESSI_MODULE TARGET_EESSI_MODULE" >&2 + exit 2 +fi + +readonly out_dir=$1 +readonly site_module=$2 +readonly older_module=$3 +readonly target_module=$4 + +if ! type module >/dev/null 2>&1; then + echo "dump-module-trees: the module command is not initialized" >&2 + exit 2 +fi + +mkdir -p "$out_dir" + +dump_tree() { + local label=$1 + local selector=$2 + local output=$3 + local temporary="${output}.tmp" + + ( + module --force purge >/dev/null 2>&1 + if [[ $selector != - ]]; then + module load "$selector" >/dev/null 2>&1 + fi + module -t avail + ) >"$temporary" 2>&1 + mv "$temporary" "$output" + printf '%s\t%s\n' "$label" "$output" +} + +trap 'rm -f "$out_dir"/*.tmp' EXIT +dump_tree site "$site_module" "$out_dir/site.txt" +dump_tree older "$older_module" "$out_dir/eessi-older.txt" +dump_tree target "$target_module" "$out_dir/eessi-target.txt" diff --git a/src/gap.rs b/src/gap.rs index 1b152b18..98ddbc78 100644 --- a/src/gap.rs +++ b/src/gap.rs @@ -53,6 +53,12 @@ pub struct OpenCarry { pub label: String, } +/// Module names and the versions the live listing exposes. +/// +/// A name with an empty set came from a bare module name, so its version is +/// unknown rather than absent from the inventory. +pub type ModuleInventory = BTreeMap>; + /// The lists [`gap`] reads. pub struct GapInput<'a> { /// Site package names. @@ -100,11 +106,19 @@ pub fn names_from_listing(text: &str) -> BTreeSet { /// not. A file is `module -t avail`, `module spider -t`, or the same /// `Name/version` lines as [`names_from_listing`]. pub fn names_from_module_source(path: &Path) -> Result> { + Ok(inventory_from_module_source(path)?.into_keys().collect()) +} + +/// Module names and versions from a listing file or an Lmod module tree. +/// +/// Versions are kept as Lmod spells them, including any toolchain suffix. +/// Bare names have an empty version set. +pub fn inventory_from_module_source(path: &Path) -> Result { if path.is_dir() { - names_from_module_tree(path).with_context(|| path.display().to_string()) + inventory_from_module_tree(path).with_context(|| path.display().to_string()) } else { let text = std::fs::read_to_string(path).with_context(|| path.display().to_string())?; - Ok(names_from_live_listing(&text)) + Ok(inventory_from_live_listing(&text)) } } @@ -113,24 +127,35 @@ pub fn names_from_module_source(path: &Path) -> Result> { /// Path headers, `(default)`, and a `Name: Name/version, ...` spider line /// are read. A plain `Name/version` or bare name is unchanged. pub fn names_from_live_listing(text: &str) -> BTreeSet { - let mut names = BTreeSet::new(); + inventory_from_live_listing(text).into_keys().collect() +} + +/// Module names and versions in `module -t avail` or `module spider -t` +/// output. +pub fn inventory_from_live_listing(text: &str) -> ModuleInventory { + let mut inventory = ModuleInventory::new(); for line in text.lines() { for module in live_module_tokens(line) { - let Some(name) = module.split('/').next() else { + let mut parts = module.splitn(2, '/'); + let Some(name) = parts.next() else { continue; }; - if !name.is_empty() { - names.insert(name.to_string()); + if name.is_empty() { + continue; + } + let versions = inventory.entry(name.to_string()).or_default(); + if let Some(version) = parts.next().filter(|version| !version.is_empty()) { + versions.insert(version.to_string()); } } } - names + inventory } -fn names_from_module_tree(root: &Path) -> std::io::Result> { - let mut names = BTreeSet::new(); - walk_module_tree(root, &mut names)?; - Ok(names) +fn inventory_from_module_tree(root: &Path) -> std::io::Result { + let mut inventory = ModuleInventory::new(); + walk_module_tree(root, &mut inventory)?; + Ok(inventory) } /// One tab-separated file per route `stack gap` writes, besides `gap.tsv`. @@ -318,6 +343,93 @@ pub fn render_tsv(rows: &[GapRow]) -> String { out } +/// One previous-stack module version and where users find it in the new +/// stack. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct VersionChangeRow { + /// EasyBuild software name. + pub name: String, + /// Version in the site or older-EESSI inventory. `None` for a bare name. + #[serde(skip_serializing_if = "Option::is_none")] + pub old_version: Option, + /// Version in the target EESSI inventory. Multiple target versions are + /// comma-separated. Set only for `moved`. + #[serde(skip_serializing_if = "Option::is_none")] + pub eessi_version: Option, + /// `moved`, `gone`, or `site-only`. + pub status: String, +} + +fn inventory_versions<'a>( + inventory: &'a ModuleInventory, + name: &str, +) -> Option<&'a BTreeSet> { + inventory.get(name).or_else(|| { + inventory + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, versions)| versions) + }) +} + +/// Turn gap routes into the user-facing version change table. +/// +/// An `in-stack` module moved to EESSI, an `excluded` module remains +/// site-only, and every other route is gone from the opened stack until that +/// route lands. One row is written per old module version; a bare old name +/// still contributes one row with no version. +pub fn version_changes( + rows: &[GapRow], + old: &ModuleInventory, + eessi: &ModuleInventory, +) -> Vec { + let mut changes = Vec::new(); + for row in rows { + let status = match row.route.as_str() { + "in-stack" => "moved", + "excluded" => "site-only", + _ => "gone", + }; + let eessi_version = (status == "moved") + .then(|| inventory_versions(eessi, &row.name)) + .flatten() + .filter(|versions| !versions.is_empty()) + .map(|versions| versions.iter().cloned().collect::>().join(",")); + let old_versions = inventory_versions(old, &row.name); + if let Some(versions) = old_versions.filter(|versions| !versions.is_empty()) { + changes.extend(versions.iter().map(|version| VersionChangeRow { + name: row.name.clone(), + old_version: Some(version.clone()), + eessi_version: eessi_version.clone(), + status: status.to_string(), + })); + } else { + changes.push(VersionChangeRow { + name: row.name.clone(), + old_version: None, + eessi_version, + status: status.to_string(), + }); + } + } + changes +} + +/// A tab-separated version change table with a header line. +pub fn render_version_changes(rows: &[VersionChangeRow]) -> String { + let mut out = String::from("name\told_version\teessi_version\tstatus\n"); + for row in rows { + out.push_str(&format!( + "{}\t{}\t{}\t{}\n", + row.name, + row.old_version.as_deref().unwrap_or("-"), + row.eessi_version.as_deref().unwrap_or("-"), + row.status + )); + } + out +} + /// The package name an easyconfig filename names. /// /// `hwloc-CUDA-2.11.2-GCCcore-15.2.0.eb` is `hwloc-CUDA`. A path contributes @@ -388,7 +500,7 @@ pub fn carries_from_layer_version( out } -fn walk_module_tree(dir: &Path, names: &mut BTreeSet) -> std::io::Result<()> { +fn walk_module_tree(dir: &Path, inventory: &mut ModuleInventory) -> std::io::Result<()> { let entries = std::fs::read_dir(dir).map_err(|error| io_at(dir, error))?; for entry in entries { let entry = entry.map_err(|error| io_at(dir, error))?; @@ -406,7 +518,7 @@ fn walk_module_tree(dir: &Path, names: &mut BTreeSet) -> std::io::Result Err(_) => continue, } } else if file_type.is_dir() { - walk_module_tree(&path, names)?; + walk_module_tree(&path, inventory)?; continue; } if !is_module_file(&file_name) { @@ -423,7 +535,10 @@ fn walk_module_tree(dir: &Path, names: &mut BTreeSet) -> std::io::Result if stem.is_empty() || stem == "default" { continue; } - names.insert(software.to_string()); + inventory + .entry(software.to_string()) + .or_default() + .insert(stem.to_string()); } Ok(()) } @@ -866,6 +981,35 @@ Where: "LAMMPS".to_string(), ]) ); + let inventory = inventory_from_live_listing(text); + assert_eq!( + inventory["zlib"], + BTreeSet::from(["1.2.13".to_string(), "1.3.1".to_string()]) + ); + assert_eq!(inventory["GCCcore"], BTreeSet::from(["14.2.0".to_string()])); + assert!(inventory["LAMMPS"].is_empty()); + } + + #[test] + fn version_changes_name_moved_gone_and_site_only_rows() { + let old = inventory_from_live_listing( + "FFTW/3.3.10-gompi-2025b\nLAMMPS/2024.0-foss-2025b\nMATLAB/2024b\n", + ); + let eessi = inventory_from_live_listing("FFTW/3.3.10-gompi-2026.1\n"); + let rows = vec![ + row("FFTW", "in-stack", None, None, None), + row("LAMMPS", "open-layer", None, None, None), + row("MATLAB", "excluded", None, None, None), + ]; + assert_eq!( + render_version_changes(&version_changes(&rows, &old, &eessi)), + "\ +name\told_version\teessi_version\tstatus +FFTW\t3.3.10-gompi-2025b\t3.3.10-gompi-2026.1\tmoved +LAMMPS\t2024.0-foss-2025b\t-\tgone +MATLAB\t2024b\t-\tsite-only +" + ); } #[test] @@ -883,7 +1027,10 @@ Where: let binutils = root.path().join("Compiler/GCCcore/13.2.0/binutils"); std::fs::create_dir_all(&binutils).unwrap(); std::fs::write(binutils.join("2.42.lua"), "").unwrap(); - let names = names_from_module_tree(root.path()).unwrap(); + let names: BTreeSet = inventory_from_module_tree(root.path()) + .unwrap() + .into_keys() + .collect(); assert_eq!( names, BTreeSet::from([ diff --git a/src/main.rs b/src/main.rs index 779e30a9..e37960da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1136,7 +1136,8 @@ enum StackCommand { /// this generation), or `excluded`. A directory is an Lmod tree. A file /// is `module -t avail`, `module spider -t`, or `Name/version`. Writes /// `gap.tsv`, one TSV per route, `roots.txt` for `stack batch - /// --roots-file`, and `bump-sources.txt`. + /// --roots-file`, `bump-sources.txt`, and `version-change.tsv` with old + /// versions moved to EESSI, gone, or kept site-only. #[command( after_long_help = "Examples:\n eb-stack stack gap --site tests/rebuild_set_fixtures/installed.txt --installed tests/pr_overlap_layer/installed.txt --easyconfigs tests/rebuild_set_fixtures/release --generation 2026.1 --out-dir gap\n\nThose paths are files in this repository. A missing --site file exits 1 and names the path." )] diff --git a/src/mcp.rs b/src/mcp.rs index bf8df831..7484f61b 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -380,7 +380,7 @@ fn tool_catalog() -> Vec { ), tool_with_optional( "eb_stack_gap", - "Classify every package in site and older module trees against a target stack, as `stack gap` does: in-stack, excluded (site-only), filtered, open-layer, recipe, open-easyconfigs, or no-recipe (a bump). A path may be an Lmod directory or module -t avail / module spider -t text. installed repeats for a CPU tree and an accelerator tree. eessi_version keeps open software-layer easystacks of that version. Writes gap.tsv, one TSV per route, roots.txt and bump-sources.txt under out_dir.", + "Classify every package in site and older module trees against a target stack, as `stack gap` does: in-stack, excluded (site-only), filtered, open-layer, recipe, open-easyconfigs, or no-recipe (a bump). A path may be an Lmod directory or module -t avail / module spider -t text. installed repeats for a CPU tree and an accelerator tree. eessi_version keeps open software-layer easystacks of that version. Writes gap.tsv, one TSV per route, roots.txt, bump-sources.txt, and version-change.tsv (old versions moved to EESSI, gone, or site-only) under out_dir.", &[ ("installed", "array"), ("easyconfigs", "array"), diff --git a/src/provided.rs b/src/provided.rs index 816d5e51..1ea83b83 100644 --- a/src/provided.rs +++ b/src/provided.rs @@ -19,6 +19,7 @@ use crate::easystack::filename_toolchain; use crate::gap::{names_from_listing, software_name}; use crate::hierarchy::known_hierarchy; use crate::jenkins::parse_build_list; +use crate::shard::parse_lines; use anyhow::{Context, Result}; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; @@ -437,16 +438,12 @@ struct Entry { } fn entries(text: &str) -> Vec { - text.lines() - .enumerate() - .filter_map(|(index, raw)| { - let trimmed = raw.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - return None; - } - let line = parse_build_list(trimmed).into_iter().next()?; + parse_lines(text) + .into_iter() + .filter_map(|shard_line| { + let line = parse_build_list(&shard_line.text).into_iter().next()?; Some(Entry { - line: index + 1, + line: shard_line.number, file: line.file, flags: line.flags, }) @@ -636,12 +633,15 @@ fn render_text(report: &ProvidedReport) -> String { let mut out = String::new(); for hit in &report.hits { out.push_str(&format!( - "provided {} name={} list={}\n", - hit.file, hit.name, hit.list + "provided {} name={} list={} line={}\n", + hit.file, hit.name, hit.list, hit.line )); } for line in &report.unparsed { - out.push_str(&format!("unparsed {} list={}\n", line.file, line.list)); + out.push_str(&format!( + "unparsed {} list={} line={}\n", + line.file, line.list, line.line + )); } for note in report .toolchain diff --git a/src/shard.rs b/src/shard.rs index 8e60a461..870c59da 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -267,9 +267,9 @@ pub fn plan( } } -/// Read a weights file: `moduleseconds` per line, `#` comments and blank -/// lines skipped. A fractional value rounds up to whole seconds. A later line -/// for the same module replaces an earlier one. +/// Read a weights file: `module`, a tab, then `seconds` per line. `#` comments +/// and blank lines are skipped. A fractional value rounds up to whole seconds. +/// A later line for the same module replaces an earlier one. pub fn parse_weights(text: &str) -> Result, String> { let mut out = BTreeMap::new(); for (i, raw) in text.lines().enumerate() { diff --git a/src/site_commands.rs b/src/site_commands.rs index 4e82a7a3..30a9646c 100644 --- a/src/site_commands.rs +++ b/src/site_commands.rs @@ -1205,7 +1205,8 @@ impl StackGapRun { /// each gap. Writes `gap.tsv`, one TSV per route, `roots.txt` (the recipes /// on the generation, the input of `stack batch --roots-file`), and /// `bump-sources.txt` (the newest recipe on any toolchain for each name -/// without one). +/// without one), and `version-change.tsv` (old module versions moved to the +/// target EESSI version, gone, or kept site-only). pub fn run_stack_gap(args: &StackGapRequest) -> Result { use std::collections::{BTreeMap, BTreeSet}; if args.site.is_empty() && args.older.is_empty() { @@ -1214,26 +1215,31 @@ pub fn run_stack_gap(args: &StackGapRequest) -> Result { if args.installed.is_empty() { return Err(usage("pass at least one --installed target tree")); } - let mut site = BTreeSet::new(); + let mut site_inventory = crate::gap::ModuleInventory::new(); for path in &args.site { - site.extend( - crate::gap::names_from_module_source(path) + merge_module_inventory( + &mut site_inventory, + crate::gap::inventory_from_module_source(path) .with_context(|| format!("--site {}", path.display()))?, ); } for path in &args.older { - site.extend( - crate::gap::names_from_module_source(path) + merge_module_inventory( + &mut site_inventory, + crate::gap::inventory_from_module_source(path) .with_context(|| format!("--older {}", path.display()))?, ); } - let mut stack = BTreeSet::new(); + let site: BTreeSet = site_inventory.keys().cloned().collect(); + let mut stack_inventory = crate::gap::ModuleInventory::new(); for path in &args.installed { - stack.extend( - crate::gap::names_from_module_source(path) + merge_module_inventory( + &mut stack_inventory, + crate::gap::inventory_from_module_source(path) .with_context(|| format!("--installed {}", path.display()))?, ); } + let stack: BTreeSet = stack_inventory.keys().cloned().collect(); let filter_specs = dep_filter::specs_from_cli_or_env(&args.filter_deps); let filter = FilterDeps::parse(&filter_specs) .map_err(|error| usage(format!("--filter-deps: {error}")))?; @@ -1313,6 +1319,11 @@ pub fn run_stack_gap(args: &StackGapRequest) -> Result { &args.out_dir.join("gap.tsv"), &crate::gap::render_tsv(&rows), )?; + let version_changes = crate::gap::version_changes(&rows, &site_inventory, &stack_inventory); + crate::write_text( + &args.out_dir.join("version-change.tsv"), + &crate::gap::render_version_changes(&version_changes), + )?; for (route, filename) in crate::gap::ROUTE_FILES { let subset: Vec<_> = rows .iter() @@ -1347,6 +1358,15 @@ pub fn run_stack_gap(args: &StackGapRequest) -> Result { }) } +fn merge_module_inventory( + into: &mut crate::gap::ModuleInventory, + from: crate::gap::ModuleInventory, +) { + for (name, versions) in from { + into.entry(name).or_default().extend(versions); + } +} + /// Arguments for `stack batch`. pub struct StackBatchRequest { /// Root easyconfig file names. diff --git a/tests/gap_routes/one-per-route.txt b/tests/gap_routes/one-per-route.txt new file mode 100644 index 00000000..9f96c6cc --- /dev/null +++ b/tests/gap_routes/one-per-route.txt @@ -0,0 +1,7 @@ +BLAST+/2.16.0-gompi-2024a +CP2K/2025.2-foss-2025a +FFTW/3.3.10-gompi-2026.1 +LAMMPS/2024.2 +MATLAB/2024b +QuantumESPRESSO/7.4 +zlib/1.3.1 diff --git a/tests/module_tree_dump_script.rs b/tests/module_tree_dump_script.rs new file mode 100644 index 00000000..24d80527 --- /dev/null +++ b/tests/module_tree_dump_script.rs @@ -0,0 +1,59 @@ +//! The live-module dump helper isolates and records all three inventories. + +use std::path::Path; +use std::process::Command; + +fn root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) +} + +#[test] +fn helper_dumps_site_older_and_target_module_trees() { + let temporary = tempfile::tempdir().expect("temp"); + let bash_env = temporary.path().join("module-init.sh"); + std::fs::write( + &bash_env, + r#" +module() { + case "$1 $2" in + "--force purge") unset TEST_MODULE ;; + "load EESSI/2025.06") export TEST_MODULE=older ;; + "load EESSI/2026.06") export TEST_MODULE=target ;; + "-t avail") + case "${TEST_MODULE:-site}" in + site) printf 'SiteApp/1.0\n' ;; + older) printf 'LAMMPS/2024.0\n' ;; + target) printf 'FFTW/3.3.10-gompi-2026.1\n' ;; + esac + ;; + *) return 2 ;; + esac +} +"#, + ) + .expect("module init"); + let output_dir = temporary.path().join("dumps"); + let output = Command::new(root().join("scripts/dump-module-trees.sh")) + .env("BASH_ENV", &bash_env) + .arg(&output_dir) + .args(["-", "EESSI/2025.06", "EESSI/2026.06"]) + .output() + .expect("dump helper"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + std::fs::read_to_string(output_dir.join("site.txt")).unwrap(), + "SiteApp/1.0\n" + ); + assert_eq!( + std::fs::read_to_string(output_dir.join("eessi-older.txt")).unwrap(), + "LAMMPS/2024.0\n" + ); + assert_eq!( + std::fs::read_to_string(output_dir.join("eessi-target.txt")).unwrap(), + "FFTW/3.3.10-gompi-2026.1\n" + ); +} diff --git a/tests/stack_gap.rs b/tests/stack_gap.rs index c5181a9a..e317cf1c 100644 --- a/tests/stack_gap.rs +++ b/tests/stack_gap.rs @@ -96,6 +96,59 @@ fn routes_are_written_one_file_per_set() { } } +#[test] +fn route_fixture_has_one_row_for_every_route() { + let out = tempfile::tempdir().expect("temp"); + let output = Command::new(env!("CARGO_BIN_EXE_eb-stack")) + .current_dir(root()) + .env_remove("EASYBUILD_FILTER_DEPS") + .args([ + "stack", + "gap", + "--site", + "tests/gap_routes/one-per-route.txt", + "--installed", + "tests/gap_routes/installed.txt", + "--easyconfigs", + "tests/gap_routes/easyconfigs", + "--generation", + "2026.1", + "--exclude-file", + "tests/gap_routes/site-only.tsv", + "--filter-deps", + "zlib", + "--open-layer", + "tests/gap_routes/open-layer.json", + "--open-easyconfigs", + "tests/gap_routes/open-easyconfigs.json", + "--out-dir", + ]) + .arg(out.path()) + .output() + .expect("stack gap"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stdout}{stderr}"); + assert!( + stdout.contains( + "site=7 in-stack=1 filtered=1 open-layer=1 recipe=1 open-easyconfigs=1 no-recipe=1 excluded=1" + ), + "{stdout}" + ); + for name in [ + "in-stack.tsv", + "site-only.tsv", + "filtered.tsv", + "open-layer.tsv", + "recipe.tsv", + "open-easyconfigs.tsv", + "bump.tsv", + ] { + let text = std::fs::read_to_string(out.path().join(name)).unwrap(); + assert_eq!(text.lines().count(), 2, "{name}:\n{text}"); + } +} + #[test] fn live_module_trees_union_the_site_an_older_eessi_and_both_target_dirs() { let out = tempfile::tempdir().expect("temp"); @@ -159,4 +212,27 @@ fn live_module_trees_union_the_site_an_older_eessi_and_both_target_dirs() { ); assert!(gap.contains("QuantumESPRESSO\tno-recipe\t"), "{gap}"); assert!(!gap.contains("software-layer#1600"), "{gap}"); + let changes = std::fs::read_to_string(out.path().join("version-change.tsv")).unwrap(); + assert!( + changes.starts_with("name\told_version\teessi_version\tstatus\n"), + "{changes}" + ); + assert!( + changes.contains("CUDA\t12.6.0\t12.6.0\tmoved\n"), + "{changes}" + ); + assert!( + changes.contains("FFTW\t3.3.10\t3.3.10\tmoved\n"), + "{changes}" + ); + assert!( + changes.contains("LAMMPS\t2023.0\t-\tgone\n") + && changes.contains("LAMMPS\t2024.0\t-\tgone\n"), + "{changes}" + ); + assert!( + changes.contains("MATLAB\t2024b\t-\tsite-only\n") + && changes.contains("code-server\t4.96.2\t-\tsite-only\n"), + "{changes}" + ); } diff --git a/tests/stack_provided.rs b/tests/stack_provided.rs index 9b8f4f1d..182812a9 100644 --- a/tests/stack_provided.rs +++ b/tests/stack_provided.rs @@ -32,12 +32,23 @@ fn a_line_the_baseline_provides_exits_1_and_an_allow_keeps_it() { let stderr = String::from_utf8_lossy(&failed.stderr); assert_eq!(failed.status.code(), Some(1), "{stdout}{stderr}"); assert!( - stdout.contains("provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW "), + stdout.contains( + "provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4" + ), + "{stdout}" + ); + assert!( + stdout.contains("name=Python list=tests/provided_fixtures/site.list line=5"), + "{stdout}" + ); + assert!( + stdout.contains("name=GROMACS list=tests/provided_fixtures/site.list line=3"), + "{stdout}" + ); + assert!( + stdout.contains("unparsed not-a-version.eb list=tests/provided_fixtures/site.list line=7"), "{stdout}" ); - assert!(stdout.contains("name=Python "), "{stdout}"); - assert!(stdout.contains("name=GROMACS "), "{stdout}"); - assert!(stdout.contains("unparsed not-a-version.eb "), "{stdout}"); assert!(!stdout.contains("VASP"), "{stdout}"); assert!( stderr.contains("3 build-list lines name software the baseline already provides"),