From 25f60108093be02fcc6a5ade9431ab5c080f5914 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 09:11:43 +0000 Subject: [PATCH 1/8] 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 ed98bc60..38669172 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 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/reference/cli.org b/docs/orgmode/reference/cli.org index 055fe029..d172d480 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 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/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"), From 57757bd5e7e6af5c1f426d224e823629dde92f75 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 09:12:55 +0000 Subject: [PATCH 2/8] 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 38669172..3ccb293e 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 13ef27c5..475f9a59 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 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/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..f6f68645 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"); From 1342ae911ba2eb9078604b08f1a4094337249903 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 09:15:45 +0000 Subject: [PATCH 3/8] 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 3ccb293e..ece08206 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 475f9a59..625f5eea 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 d172d480..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 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/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/stack_gap.rs b/tests/stack_gap.rs index f6f68645..e317cf1c 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}" + ); } From 02aec4222996c1188b7d00916a095f560a69932c Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 09:16:06 +0000 Subject: [PATCH 4/8] 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 a0858441..3eb83387 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) From 3ada26fd06841aa776b2b84b4abe502a53aaa741 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 09:17:10 +0000 Subject: [PATCH 5/8] 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 625f5eea..ff79390e 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 3eb83387..668bfaa3 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) From 55b5a7345d4175ba549fb94edad32f6459cd444b Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 09:18:01 +0000 Subject: [PATCH 6/8] 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 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 \ From 682ea6513577bafe7ee40cde49de40b38716ef7a Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 09:22:27 +0000 Subject: [PATCH 7/8] 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 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() { From 8af969c77b98f0d284a453611decea1374af4b2a Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 09:26:38 +0000 Subject: [PATCH 8/8] chore: add U3-U5 delivery artifacts --- artifacts/REPORT.md | 195 ++++++++ artifacts/eb-stack-u3-u5.bundle | Bin 0 -> 12776 bytes ...stack-report-build-list-line-numbers.patch | 147 ++++++ ...tack-capture-live-module-inventories.patch | 290 +++++++++++ ...t-stack-write-module-version-changes.patch | 466 ++++++++++++++++++ ...-docs-stack-refresh-module-gap-index.patch | 123 +++++ ...-stack-use-configured-upstream-trees.patch | 51 ++ ...ci-stack-run-module-dump-helper-test.patch | 24 + ...007-docs-stack-fix-shard-API-literal.patch | 29 ++ 9 files changed, 1325 insertions(+) create mode 100644 artifacts/REPORT.md create mode 100644 artifacts/eb-stack-u3-u5.bundle create mode 100644 artifacts/patches/0001-fix-stack-report-build-list-line-numbers.patch create mode 100644 artifacts/patches/0002-feat-stack-capture-live-module-inventories.patch create mode 100644 artifacts/patches/0003-feat-stack-write-module-version-changes.patch create mode 100644 artifacts/patches/0004-docs-stack-refresh-module-gap-index.patch create mode 100644 artifacts/patches/0005-docs-stack-use-configured-upstream-trees.patch create mode 100644 artifacts/patches/0006-ci-stack-run-module-dump-helper-test.patch create mode 100644 artifacts/patches/0007-docs-stack-fix-shard-API-literal.patch 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 0000000000000000000000000000000000000000..6d8c02172cab2e2b6acc8f61ee83b7565d96bd40 GIT binary patch literal 12776 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 +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 +