diff --git a/.github/workflows/migration-sentinels.yml b/.github/workflows/migration-sentinels.yml new file mode 100644 index 00000000000..8a23aaf763d --- /dev/null +++ b/.github/workflows/migration-sentinels.yml @@ -0,0 +1,28 @@ +name: Migration sentinels + +# A malformed sentinel is silently ignored by the CMake glob, so it would never +# be published to downstream projects. Validate on every PR that touches one. + +on: + pull_request: + paths: + - 'CMake/MigrationSentinels/**' + - 'Utilities/Maintenance/MigrationSentinels.py' + - '.github/workflows/migration-sentinels.yml' + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Validate migration sentinels + run: | + python3 Utilities/Maintenance/MigrationSentinels.py validate \ + || { + echo "::error::A migration sentinel is malformed. See" \ + "Documentation/docs/contributing/migration_sentinels.md" + exit 1 + } diff --git a/CMake/ITKConfig.cmake.in b/CMake/ITKConfig.cmake.in index 367b1b096ae..7379f576dc4 100644 --- a/CMake/ITKConfig.cmake.in +++ b/CMake/ITKConfig.cmake.in @@ -26,6 +26,12 @@ set(ITK_VERSION_MAJOR "@ITK_VERSION_MAJOR@") set(ITK_VERSION_MINOR "@ITK_VERSION_MINOR@") set(ITK_VERSION_PATCH "@ITK_VERSION_PATCH@") +# Migration sentinels present in this ITK. Each entry asserts that one +# downstream-visible change is in the code. Intended only for fine-grained +# configure-time decisions between tagged releases; see +# Documentation/docs/contributing/migration_sentinels.md +set(ITK_MIGRATION_SENTINELS "@ITK_MIGRATION_SENTINELS@") + # Remove all legacy code completely. set(ITK_LEGACY_REMOVE "@ITK_LEGACY_REMOVE@") diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR4704.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR4704.md new file mode 100644 index 00000000000..4cf01791234 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR4704.md @@ -0,0 +1 @@ +ENH: Finalize ITKv5 const function API change (PR #4704). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR4715.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR4715.md new file mode 100644 index 00000000000..86389bc57aa --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR4715.md @@ -0,0 +1 @@ +ENH: Remove ITKv4 compatibility support (PR #4715). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR4729.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR4729.md new file mode 100644 index 00000000000..153a8e8f521 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR4729.md @@ -0,0 +1 @@ +ENH: Remove features that were deprecated in ITKv5 (PR #4729). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR4737.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR4737.md new file mode 100644 index 00000000000..5788717cc8a --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR4737.md @@ -0,0 +1 @@ +Remove cxx aliases (PR #4737). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR4744.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR4744.md new file mode 100644 index 00000000000..d15bd3f24c2 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR4744.md @@ -0,0 +1 @@ +STYLE: Remove outdated maintenance scripts (PR #4744). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR4925.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR4925.md new file mode 100644 index 00000000000..93b5176e062 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR4925.md @@ -0,0 +1 @@ +ENH: Let `PointSet::Clone()` do a "deep copy", instead of no copy at all (PR #4925). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR5076.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR5076.md new file mode 100644 index 00000000000..cc7fab0f131 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR5076.md @@ -0,0 +1 @@ +DOC: Note migration guide replacing `CoordinateType` with `CoordRepType` (PR #5076). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR5286.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR5286.md new file mode 100644 index 00000000000..0a6bf415223 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR5286.md @@ -0,0 +1 @@ +ENH: Remove dummy VNLInstantiation library (PR #5286). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR5382.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR5382.md new file mode 100644 index 00000000000..e6d935dca03 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR5382.md @@ -0,0 +1 @@ +DOC: Add migration documentation for AnatomicalOrientation (PR #5382). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR5721.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR5721.md new file mode 100644 index 00000000000..45774cbbc83 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR5721.md @@ -0,0 +1 @@ +Modernize ITK Module System with CMake Interface Libraries (PR #5721). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR5752.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR5752.md new file mode 100644 index 00000000000..0bea773e51e --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR5752.md @@ -0,0 +1 @@ +COMP: Remove undefined long double swig wrapping (PR #5752). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR5775.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR5775.md new file mode 100644 index 00000000000..c33ca7a5999 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR5775.md @@ -0,0 +1 @@ +BUG: Fix image_from_array shape handling for F-contiguous arrays (PR #5775). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR5776.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR5776.md new file mode 100644 index 00000000000..340171ae2c3 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR5776.md @@ -0,0 +1 @@ +ENH: Release Python GIL during ITK operations (PR #5776). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR5791.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR5791.md new file mode 100644 index 00000000000..0316e30d7b8 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR5791.md @@ -0,0 +1 @@ +DOC: Add migration guide to updated GTest target names (PR #5791). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR5803.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR5803.md new file mode 100644 index 00000000000..4ed003554a8 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR5803.md @@ -0,0 +1 @@ +Deprecate `ImageConstIterator::GetIndex()`, use ComputeIndex() in tests and examples (PR #5803). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR5995.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR5995.md new file mode 100644 index 00000000000..a6ebc074067 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR5995.md @@ -0,0 +1 @@ +COMP: Use std::unique_ptr in FEMLinearSystemWrapperDenseVNL (PR #5995). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6003.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6003.md new file mode 100644 index 00000000000..5f0da201671 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6003.md @@ -0,0 +1 @@ +ENH: Use NumberToString for lossless float metadata in NIfTI reader (PR #6003). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6144.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6144.md new file mode 100644 index 00000000000..cd43cf5c996 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6144.md @@ -0,0 +1 @@ +COMP: Require VTK >= 9.1 in ITKVtkGlue; drop legacy module-system shims (PR #6144). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6183.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6183.md new file mode 100644 index 00000000000..bba77c5f33a --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6183.md @@ -0,0 +1 @@ +ENH: Replace custom LazyITKModule with native PEP 562 lazy loading (PR #6183). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6427.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6427.md new file mode 100644 index 00000000000..89cc4c66d0f --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6427.md @@ -0,0 +1 @@ +ENH: Decouple itk::Math from vnl_math (constants and functions) (PR #6427). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6441.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6441.md new file mode 100644 index 00000000000..ce0d7b37330 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6441.md @@ -0,0 +1 @@ +ENH: PocketFFT as a permissive, dependency-free default FFT backend (PR #6441). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6469.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6469.md new file mode 100644 index 00000000000..0cfc2d644ea --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6469.md @@ -0,0 +1 @@ +ENH: Reimplement GDCMSeriesFileNames on gdcm::IPPSorter/Scanner (drop deprecated SerieHelper) (PR #6469). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6475.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6475.md new file mode 100644 index 00000000000..38d3e6c3674 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6475.md @@ -0,0 +1 @@ +ENH: Eigen-backed itk eigendecompositions; deprecate vnl_*_eigensystem and eispack (PR #6475). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6487.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6487.md new file mode 100644 index 00000000000..f48baa451d0 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6487.md @@ -0,0 +1 @@ +ENH: Add Eigen-backed itk::Math::SVD and migrate ITK's vnl_svd consumers (PR #6487). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6504.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6504.md new file mode 100644 index 00000000000..ff27d758390 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6504.md @@ -0,0 +1 @@ +COMP: Modernize deprecated macros in ingested filter/IO modules (PR #6504). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6524.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6524.md new file mode 100644 index 00000000000..b96ea0b06cc --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6524.md @@ -0,0 +1 @@ +ENH: Retire FEM modules to InsightSoftwareConsortium/ITKFEM remote (PR #6524). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6553.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6553.md new file mode 100644 index 00000000000..1e4c9831c74 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6553.md @@ -0,0 +1 @@ +DOC: Document itkDCMTKFileReader privatization in ITKv6 migration guide (PR #6553). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6569.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6569.md new file mode 100644 index 00000000000..c88b7ea83dc --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6569.md @@ -0,0 +1 @@ +BUG: Fix JointHistogramMutualInformation metric marginals and derivative (PR #6569). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6573.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6573.md new file mode 100644 index 00000000000..d99b7890140 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6573.md @@ -0,0 +1 @@ +Deprecate mpl::IsNumber (PR #6573). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6594.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6594.md new file mode 100644 index 00000000000..c13a41997a2 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6594.md @@ -0,0 +1 @@ +BUG: Center collapsed-axis origin in projection and accumulate filters (PR #6594). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6602.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6602.md new file mode 100644 index 00000000000..b57e92f40d8 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6602.md @@ -0,0 +1 @@ +BUG: Smooth structure tensor with an isotropic Gaussian kernel (PR #6602). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6635.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6635.md new file mode 100644 index 00000000000..f89693eeeff --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6635.md @@ -0,0 +1 @@ +BUG: Seed entropy threshold calculators with NonpositiveMin (PR #6635). diff --git a/CMake/MigrationSentinels/ITK_MIGRATION_PR6649.md b/CMake/MigrationSentinels/ITK_MIGRATION_PR6649.md new file mode 100644 index 00000000000..866d31c8c24 --- /dev/null +++ b/CMake/MigrationSentinels/ITK_MIGRATION_PR6649.md @@ -0,0 +1 @@ +DOC: note HDF5ImageIO compression-default change in migration guide (PR #6649). diff --git a/CMake/MigrationSentinels/README.md b/CMake/MigrationSentinels/README.md new file mode 100644 index 00000000000..a13a01a9519 --- /dev/null +++ b/CMake/MigrationSentinels/README.md @@ -0,0 +1,12 @@ +# Migration sentinels + +Each `ITK_MIGRATION_*.md` file in this directory asserts that one +downstream-visible change is present in this ITK source tree. The filenames are +collected into `ITK_MIGRATION_SENTINELS` at configure time and exported through +`ITKConfig.cmake`, so downstream projects can gate on a specific change during +the interval between ITK tags. + +Add one file per downstream-visible change. See +`Documentation/docs/contributing/migration_sentinels.md`. + +This file is not a sentinel: the glob matches only `ITK_MIGRATION_*.md`. diff --git a/CMake/itkMigrationSentinels.cmake b/CMake/itkMigrationSentinels.cmake new file mode 100644 index 00000000000..8e240239f84 --- /dev/null +++ b/CMake/itkMigrationSentinels.cmake @@ -0,0 +1,28 @@ +# Migration sentinels: one Markdown file per downstream-visible change. +# +# The resulting list is exported through ITKConfig.cmake ONLY. It must never +# reach itkConfigure.h or any other compiled header: the value changes on most +# merges, and that header is included nearly everywhere, so embedding it would +# invalidate ccache for every translation unit on every build machine. +function(itk_collect_migration_sentinels _sentinel_dir _out_var) + # CONFIGURE_DEPENDS is required during a real configure: without it, adding + # a sentinel file does not re-run CMake and the new sentinel is silently + # missed. It is rejected in -P script mode (CMAKE_SCRIPT_MODE_FILE set), + # which the CollectTest.cmake unit test runs under. + if(CMAKE_SCRIPT_MODE_FILE) + file(GLOB _sentinel_files "${_sentinel_dir}/ITK_MIGRATION_*.md") + else() + file( + GLOB _sentinel_files + CONFIGURE_DEPENDS + "${_sentinel_dir}/ITK_MIGRATION_*.md" + ) + endif() + set(_names "") + foreach(_file IN LISTS _sentinel_files) + get_filename_component(_name "${_file}" NAME_WE) + list(APPEND _names "${_name}") + endforeach() + list(SORT _names) + set(${_out_var} "${_names}" PARENT_SCOPE) +endfunction() diff --git a/CMakeLists.txt b/CMakeLists.txt index d34521cf0bd..d65124a8052 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,6 +56,15 @@ set( ITK_VERSION "${ITK_VERSION_MAJOR}.${ITK_VERSION_MINOR}.${ITK_VERSION_PATCH}" ) + +# Migration sentinels: exported through ITKConfig.cmake only, never into a +# compiled header (see CMake/itkMigrationSentinels.cmake). +include(itkMigrationSentinels) +itk_collect_migration_sentinels( + "${CMAKE_CURRENT_SOURCE_DIR}/CMake/MigrationSentinels" + ITK_MIGRATION_SENTINELS +) + project( ITK VERSION ${ITK_VERSION} @@ -903,6 +912,7 @@ if(BUILD_TESTING) ) mark_as_advanced(CTEST_COST_DATA_FILE) add_subdirectory(Utilities/InstallTest) + add_subdirectory(Utilities/MigrationSentinelTest) endif() #----------------------------------------------------------------------------- diff --git a/Documentation/docs/contributing/index.md b/Documentation/docs/contributing/index.md index bb36f8305b7..3ec32adcc40 100644 --- a/Documentation/docs/contributing/index.md +++ b/Documentation/docs/contributing/index.md @@ -468,6 +468,7 @@ ITK Git Cheatsheet dashboard.md updating_third_party.md +migration_sentinels.md python_packaging.md ../README.md ``` diff --git a/Documentation/docs/contributing/migration_sentinels.md b/Documentation/docs/contributing/migration_sentinels.md new file mode 100644 index 00000000000..d638d5ebf00 --- /dev/null +++ b/Documentation/docs/contributing/migration_sentinels.md @@ -0,0 +1,85 @@ +# Migration sentinels + +A migration sentinel lets a downstream project detect, at CMake configure time, +that one specific ITK change is present — during the interval between ITK tags, +which can run two years. + +The motivating case is `git bisect`. A downstream project bisecting ITK to find +which change broke it is configured against a sequence of arbitrary commits, and +at each step must choose which baseline images to test against. Sentinels are +plain files in the source tree, so they are readable in a detached `HEAD`, a +shallow clone, or an exported archive — states in which `git describe` fails and +`git rev-list --count` silently returns a wrong answer. + +Sentinels are **not** a general feature-detection API. They are removed at each +tag. Sentinels are intended **only** for fine-grained configure-time decisions +between tagged releases. + +## Adding a sentinel + +Add one file to `CMake/MigrationSentinels/` containing a single line of +description: + +``` +CMake/MigrationSentinels/ITK_MIGRATION_PR6532.md +``` + +```markdown +itk::Math::SVD replaces vnl_svd for ITK-internal consumers; see PR #6532. +``` + +Choose exactly one naming pattern: + +| Pattern | When | +|---|---| +| `ITK_MIGRATION_PR` | **Strongly preferred** — traceable to the pull request. | +| `ITK_MIGRATION_` | No single PR captures the change. | +| `ITK_MIGRATION_HASH_` | Only a commit identifies the change. | + +CI rejects a name matching none of these. It also rejects a misspelled prefix, +which would otherwise be silently ignored by the glob and never published. + +## Using a sentinel downstream + +```cmake +find_package(ITK REQUIRED) + +if(ITK_VERSION VERSION_GREATER_EQUAL 6.1.0 + OR ITK_MIGRATION_PR6532 IN_LIST ITK_MIGRATION_SENTINELS) + set(BASELINE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/Baseline/post-6532) +else() + set(BASELINE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/Baseline/legacy) +endif() +``` + +Name the ITK release you expect to contain the change as the version arm. Both +arms are required: + +- The **sentinel arm** is true between tags, before any release contains it. +- The **version arm** is true after the tag, once sentinels have been expired. + +Against an ITK predating this mechanism, `ITK_MIGRATION_SENTINELS` is unset, +`IN_LIST` is false, and the legacy branch is taken — no guard needed. This is +also why the mechanism is a list variable rather than a function: calling a +function that older ITK does not define is a hard `Unknown CMake command` +error, and CMake's `if()` cannot invoke a function anyway. + +## Expiring at a tag + +Run once after tagging: + +```bash +python3 Utilities/Maintenance/MigrationSentinels.py expire-itk +python3 Utilities/Maintenance/MigrationSentinels.py \ + expire-downstream --tag 6.1.0 --repo ../BRAINSTools +``` + +Order does not matter and neither step is urgent. A downstream not yet rewritten +keeps working against the tagged ITK because the version arm is now true. + +## Why this is not in a compiled header + +`ITK_MIGRATION_SENTINELS` is exported through `ITKConfig.cmake` only. Embedding +it in `itkConfigure.h` would change every translation unit's preprocessed output +on most merges and destroy ccache hit rates on every build machine. The +`MigrationSentinelConfigureHeaderGuard` test enforces this. diff --git a/Utilities/Maintenance/MigrationSentinels.py b/Utilities/Maintenance/MigrationSentinels.py new file mode 100755 index 00000000000..3f654632215 --- /dev/null +++ b/Utilities/Maintenance/MigrationSentinels.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python + + +description = """ +Manage ITK migration sentinels. + +Each CMake/MigrationSentinels/ITK_MIGRATION_*.md file asserts that one +downstream-visible change is present in the ITK source tree. The filenames are +collected into ITK_MIGRATION_SENTINELS at configure time and exported through +ITKConfig.cmake, letting downstream projects gate on a specific change during +the interval between ITK tags. + +Sentinels are deliberately short-lived. They are removed at each tag, after +which the version arm of the downstream condition carries the same meaning. +""" + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +# Approved sentinel name patterns, most specific first. ITK_MIGRATION_PR is +# strongly preferred because it is traceable to the pull request. +SENTINEL_PATTERNS = ( + ("ITK_MIGRATION_PR", re.compile(r"^ITK_MIGRATION_PR[1-9][0-9]*$")), + ( + "ITK_MIGRATION_HASH_", + re.compile(r"^ITK_MIGRATION_HASH_[0-9a-f]{7,40}$"), + ), + ( + "ITK_MIGRATION_", + re.compile(r"^ITK_MIGRATION_[A-Z][A-Z0-9_]*$"), + ), +) + +SENTINEL_GLOB = "ITK_MIGRATION_*.md" + + +def default_sentinel_dir() -> Path: + """CMake/MigrationSentinels relative to this script's location in ITK.""" + return ( + Path(__file__).resolve().parent.parent.parent / "CMake" / "MigrationSentinels" + ) + + +def matched_pattern(name: str) -> str | None: + for label, pattern in SENTINEL_PATTERNS: + if pattern.match(name): + return label + return None + + +def sentinel_files(sentinel_dir: Path) -> list[Path]: + return sorted(sentinel_dir.glob(SENTINEL_GLOB)) + + +def validate(sentinel_dir: Path) -> int: + if not sentinel_dir.is_dir(): + print(f"error: {sentinel_dir} is not a directory", file=sys.stderr) + return 1 + + errors = [] + + for path in sentinel_files(sentinel_dir): + name = path.stem + if matched_pattern(name) is None: + errors.append( + f"{path.name}: '{name}' matches no approved pattern " + f"({', '.join(label for label, _ in SENTINEL_PATTERNS)})" + ) + lines = [line.strip() for line in path.read_text().splitlines()] + described = [line for line in lines if line] + if len(described) != 1: + errors.append( + f"{path.name}: expected exactly one non-empty description line, " + f"found {len(described)}" + ) + + # A file whose prefix is misspelled is silently ignored by the CMake glob, + # so the sentinel would never be published. Catch it here instead. + for path in sorted(sentinel_dir.iterdir()): + if path.is_dir() or path.name == "README.md": + continue + if path.suffix == ".md" and path.name.startswith("ITK_MIGRATION_"): + continue + errors.append( + f"{path.name}: not matched by the '{SENTINEL_GLOB}' glob, so it " + f"would be silently ignored; rename it or remove it" + ) + + for message in errors: + print(f"error: {message}", file=sys.stderr) + + if errors: + print(f"{len(errors)} sentinel problem(s) found", file=sys.stderr) + return 1 + + print(f"{len(sentinel_files(sentinel_dir))} sentinel(s) validated") + return 0 + + +def expire_itk(sentinel_dir: Path, dry_run: bool) -> int: + """Remove every sentinel file. Run once, immediately after tagging. + + Every sentinel present at a tag ships in that tag by definition, so the + directory is emptied rather than filtered. Downstream projects that have + not yet been rewritten keep working: the version arm of their condition is + now true even though the sentinel is gone. + """ + if not sentinel_dir.is_dir(): + print(f"error: {sentinel_dir} is not a directory", file=sys.stderr) + return 1 + + paths = sentinel_files(sentinel_dir) + if not paths: + print("no sentinels to expire") + return 0 + + for path in paths: + print(f"expiring {path.name}") + if dry_run: + print(f"dry run: {len(paths)} sentinel(s) left in place") + return 0 + + subprocess.run( + ["git", "rm", "--quiet", "--"] + [str(p) for p in paths], + check=True, + ) + print(f"expired {len(paths)} sentinel(s); commit the result") + return 0 + + +# The two-arm downstream condition. \s matches newlines, so the condition may +# be wrapped across lines. Both if() and elseif() are handled: a missed elseif +# would silently keep a sentinel reference alive after the sentinel is gone. +CONDITION_RE = re.compile( + r"(?P[ \t]*)(?Pif|elseif)\s*\(\s*" + r"ITK_VERSION\s+VERSION_GREATER_EQUAL\s+(?P[0-9][0-9A-Za-z.]*)\s+" + r"OR\s+(?PITK_MIGRATION_[A-Za-z0-9_]+)\s+" + r"IN_LIST\s+ITK_MIGRATION_SENTINELS\s*\)" +) + +CMAKE_FILE_GLOBS = ("*.cmake", "CMakeLists.txt") + + +EXCLUDED_DIR_NAMES = {".git", "build", "_build", ".pixi"} + + +def _cmake_files(repo: Path) -> list[Path]: + found: list[Path] = [] + for pattern in CMAKE_FILE_GLOBS: + found.extend(repo.rglob(pattern)) + return sorted(p for p in found if EXCLUDED_DIR_NAMES.isdisjoint(p.parts)) + + +def expire_downstream(repo: Path, tag: str, sentinel_dir: Path, dry_run: bool) -> int: + """Reduce two-arm conditions for `tag` to their version arm. + + Idempotent: a condition already reduced no longer matches CONDITION_RE. + """ + if not repo.is_dir(): + print(f"error: {repo} is not a directory", file=sys.stderr) + return 1 + + known = ( + {p.stem for p in sentinel_files(sentinel_dir)} + if sentinel_dir.is_dir() + else set() + ) + + rewritten = 0 + skipped_other_tag = 0 + unknown_sentinels = [] + + for path in _cmake_files(repo): + try: + original = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + print(f"warning: skipping {path}: not valid UTF-8", file=sys.stderr) + continue + + def replace(match: re.Match) -> str: + nonlocal rewritten, skipped_other_tag + if match.group("tag") != tag: + skipped_other_tag += 1 + return match.group(0) + sentinel = match.group("sentinel") + if known and sentinel not in known: + unknown_sentinels.append(f"{path}: {sentinel}") + rewritten += 1 + return ( + f"{match.group('indent')}{match.group('kw')}" + f"(ITK_VERSION VERSION_GREATER_EQUAL {tag})" + ) + + updated = CONDITION_RE.sub(replace, original) + if updated != original and not dry_run: + path.write_text(updated, encoding="utf-8") + + for entry in unknown_sentinels: + print( + f"warning: {entry} is not published by ITK; it may be a typo or " + f"already expired", + file=sys.stderr, + ) + + action = "would rewrite" if dry_run else "rewrote" + print(f"{action} {rewritten} condition(s) for tag {tag}") + if skipped_other_tag: + print(f"left {skipped_other_tag} condition(s) for other tags untouched") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description=description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + validate_parser = subparsers.add_parser( + "validate", help="check that every sentinel is well-formed" + ) + validate_parser.add_argument( + "--sentinel-dir", + type=Path, + default=default_sentinel_dir(), + help="directory holding the sentinel files", + ) + + expire_itk_parser = subparsers.add_parser( + "expire-itk", help="git rm every sentinel file (run once, after tagging)" + ) + expire_itk_parser.add_argument( + "--sentinel-dir", + type=Path, + default=default_sentinel_dir(), + help="directory holding the sentinel files", + ) + expire_itk_parser.add_argument( + "--dry-run", + action="store_true", + help="list what would be removed without removing it", + ) + + expire_downstream_parser = subparsers.add_parser( + "expire-downstream", + help="reduce two-arm conditions in a downstream repo to the version arm", + ) + expire_downstream_parser.add_argument( + "--tag", required=True, help="version whose conditions should be reduced" + ) + expire_downstream_parser.add_argument( + "--repo", type=Path, required=True, help="downstream repository to rewrite" + ) + expire_downstream_parser.add_argument( + "--sentinel-dir", + type=Path, + default=default_sentinel_dir(), + help="ITK sentinel directory, used to flag unknown sentinels", + ) + expire_downstream_parser.add_argument( + "--dry-run", action="store_true", help="report without writing" + ) + + args = parser.parse_args() + if args.command == "validate": + return validate(args.sentinel_dir) + if args.command == "expire-itk": + return expire_itk(args.sentinel_dir, args.dry_run) + if args.command == "expire-downstream": + return expire_downstream(args.repo, args.tag, args.sentinel_dir, args.dry_run) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Utilities/MigrationSentinelTest/CMakeLists.txt b/Utilities/MigrationSentinelTest/CMakeLists.txt new file mode 100644 index 00000000000..b0bb8b4c90a --- /dev/null +++ b/Utilities/MigrationSentinelTest/CMakeLists.txt @@ -0,0 +1,64 @@ +itk_add_test( + NAME MigrationSentinelCollect + COMMAND + ${CMAKE_COMMAND} + -DITK_SOURCE_DIR=${ITK_SOURCE_DIR} + -P + ${CMAKE_CURRENT_SOURCE_DIR}/CollectTest.cmake +) + +itk_add_test( + NAME MigrationSentinelDownstreamIdiom + COMMAND + ${CMAKE_COMMAND} + -P + ${CMAKE_CURRENT_SOURCE_DIR}/DownstreamIdiomTest.cmake +) + +itk_add_test( + NAME MigrationSentinelConfigureHeaderGuard + COMMAND + ${CMAKE_COMMAND} + -DITK_CONFIGURE_HEADER=${ITK_BINARY_DIR}/Modules/Core/Common/itkConfigure.h + -P + ${CMAKE_CURRENT_SOURCE_DIR}/ConfigureHeaderGuardTest.cmake +) + +itk_add_test( + NAME MigrationSentinelConsumer + COMMAND + ${CMAKE_COMMAND} + -DCONSUMER_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}/Consumer + -DCONSUMER_BINARY_DIR=${CMAKE_CURRENT_BINARY_DIR}/ConsumerBuild + -DITK_BINARY_DIR=${ITK_BINARY_DIR} + -P + ${CMAKE_CURRENT_SOURCE_DIR}/ConsumerTest.cmake +) + +if(Python3_EXECUTABLE) + itk_add_test( + NAME MigrationSentinelValidate + COMMAND + ${Python3_EXECUTABLE} + ${ITK_SOURCE_DIR}/Utilities/Maintenance/MigrationSentinels.py + validate + --sentinel-dir + ${ITK_SOURCE_DIR}/CMake/MigrationSentinels + ) + + itk_add_test( + NAME MigrationSentinelValidateRejects + COMMAND + ${Python3_EXECUTABLE} + ${ITK_SOURCE_DIR}/Utilities/Maintenance/MigrationSentinels.py + validate + --sentinel-dir + ${ITK_SOURCE_DIR}/Utilities/MigrationSentinelTest/Fixtures/Invalid + ) + set_tests_properties( + MigrationSentinelValidateRejects + PROPERTIES + WILL_FAIL + TRUE + ) +endif() diff --git a/Utilities/MigrationSentinelTest/CollectTest.cmake b/Utilities/MigrationSentinelTest/CollectTest.cmake new file mode 100644 index 00000000000..5cad54fd561 --- /dev/null +++ b/Utilities/MigrationSentinelTest/CollectTest.cmake @@ -0,0 +1,34 @@ +# Verifies itk_collect_migration_sentinels() against fixed fixtures. +# Fixtures are used rather than the real sentinel directory so these tests do +# not break when real sentinels are expired at tag time. +cmake_minimum_required(VERSION 3.16) +include("${ITK_SOURCE_DIR}/CMake/itkMigrationSentinels.cmake") + +set(_failures 0) +macro(expect_eq _actual _expected _what) + if(NOT "${_actual}" STREQUAL "${_expected}") + message(SEND_ERROR "${_what}: expected [${_expected}] got [${_actual}]") + math(EXPR _failures "${_failures} + 1") + endif() +endmacro() + +# Populated fixture: two sentinels, plus a README.md and a stray file that the +# glob must ignore. +itk_collect_migration_sentinels( + "${CMAKE_CURRENT_LIST_DIR}/Fixtures/Populated" _got +) +expect_eq("${_got}" "ITK_MIGRATION_MATH_SVD;ITK_MIGRATION_PR6532" + "populated fixture (sorted, README.md excluded)" +) + +# Empty fixture: models ITK immediately after a tag, when all sentinels have +# been expired. Must yield an empty list, not an error. +itk_collect_migration_sentinels( + "${CMAKE_CURRENT_LIST_DIR}/Fixtures/Empty" _got_empty +) +expect_eq("${_got_empty}" "" "empty fixture") + +if(_failures GREATER 0) + message(FATAL_ERROR "${_failures} collection check(s) failed") +endif() +message(STATUS "CollectTest: all checks passed") diff --git a/Utilities/MigrationSentinelTest/ConfigureHeaderGuardTest.cmake b/Utilities/MigrationSentinelTest/ConfigureHeaderGuardTest.cmake new file mode 100644 index 00000000000..8ffa42ebb5c --- /dev/null +++ b/Utilities/MigrationSentinelTest/ConfigureHeaderGuardTest.cmake @@ -0,0 +1,20 @@ +# ccache guard: the sentinel list must never reach a compiled header. +# If it did, every translation unit's preprocessed output would change on most +# merges and ccache would miss fleet-wide. +cmake_minimum_required(VERSION 3.16) + +if(NOT EXISTS "${ITK_CONFIGURE_HEADER}") + message(FATAL_ERROR "itkConfigure.h not found at ${ITK_CONFIGURE_HEADER}") +endif() + +file(READ "${ITK_CONFIGURE_HEADER}" _contents) +string(FIND "${_contents}" "ITK_MIGRATION" _pos) +if(NOT _pos EQUAL -1) + message( + FATAL_ERROR + "itkConfigure.h contains 'ITK_MIGRATION'. The migration sentinel list must " + "live only in ITKConfig.cmake; embedding it in a compiled header " + "invalidates ccache for every translation unit on every merge." + ) +endif() +message(STATUS "ConfigureHeaderGuardTest: itkConfigure.h is free of sentinels") diff --git a/Utilities/MigrationSentinelTest/Consumer/CMakeLists.txt b/Utilities/MigrationSentinelTest/Consumer/CMakeLists.txt new file mode 100644 index 00000000000..aa49f99fd77 --- /dev/null +++ b/Utilities/MigrationSentinelTest/Consumer/CMakeLists.txt @@ -0,0 +1,22 @@ +# Minimal consumer: proves ITKConfig.cmake exports ITK_MIGRATION_SENTINELS. +cmake_minimum_required(VERSION 3.16) +# CXX rather than NONE: ITK's config chain reaches find_package(OpenMP) +# via optional modules, and FindOpenMP requires a language to be enabled. +project(MigrationSentinelConsumer CXX) + +find_package(ITK REQUIRED) + +# DEFINED rather than truthiness: the list is legitimately empty when no +# sentinels are pending, but it must still be exported. +if(NOT DEFINED ITK_MIGRATION_SENTINELS) + message( + FATAL_ERROR + "ITKConfig.cmake did not export ITK_MIGRATION_SENTINELS. Downstream " + "projects cannot gate on migrations without it." + ) +endif() + +message( + STATUS + "Consumer saw ITK_MIGRATION_SENTINELS=[${ITK_MIGRATION_SENTINELS}]" +) diff --git a/Utilities/MigrationSentinelTest/ConsumerTest.cmake b/Utilities/MigrationSentinelTest/ConsumerTest.cmake new file mode 100644 index 00000000000..f62675f11be --- /dev/null +++ b/Utilities/MigrationSentinelTest/ConsumerTest.cmake @@ -0,0 +1,16 @@ +# Configures the minimal consumer against the ITK build tree. +cmake_minimum_required(VERSION 3.16) + +execute_process( + COMMAND + "${CMAKE_COMMAND}" -S "${CONSUMER_SOURCE_DIR}" -B "${CONSUMER_BINARY_DIR}" + -DITK_DIR:PATH=${ITK_BINARY_DIR} + RESULT_VARIABLE _result + OUTPUT_VARIABLE _output + ERROR_VARIABLE _output +) + +if(NOT _result EQUAL 0) + message(FATAL_ERROR "consumer configure failed:\n${_output}") +endif() +message(STATUS "ConsumerTest passed:\n${_output}") diff --git a/Utilities/MigrationSentinelTest/DownstreamIdiomTest.cmake b/Utilities/MigrationSentinelTest/DownstreamIdiomTest.cmake new file mode 100644 index 00000000000..bb250f6b40d --- /dev/null +++ b/Utilities/MigrationSentinelTest/DownstreamIdiomTest.cmake @@ -0,0 +1,88 @@ +# Verifies the two-arm downstream idiom in all four states. +cmake_minimum_required(VERSION 3.16) + +set(_failures 0) +macro(expect_branch _cond_result _expected _what) + if(NOT "${_cond_result}" STREQUAL "${_expected}") + message(SEND_ERROR "${_what}: expected ${_expected} got ${_cond_result}") + math(EXPR _failures "${_failures} + 1") + endif() +endmacro() + +# State 1: ITK predating the mechanism. ITK_MIGRATION_SENTINELS is never set. +set(ITK_VERSION 6.0.0) +unset(ITK_MIGRATION_SENTINELS) +if( + ITK_VERSION + VERSION_GREATER_EQUAL + 6.1.0 + OR + ITK_MIGRATION_PR6532 + IN_LIST + ITK_MIGRATION_SENTINELS +) + set(_r TRUE) +else() + set(_r FALSE) +endif() +expect_branch("${_r}" "FALSE" "old ITK, list unset -> legacy branch") + +# State 2: sentinel present, version still below the future tag. +set( + ITK_MIGRATION_SENTINELS + ITK_MIGRATION_MATH_SVD + ITK_MIGRATION_PR6532 +) +if( + ITK_VERSION + VERSION_GREATER_EQUAL + 6.1.0 + OR + ITK_MIGRATION_PR6532 + IN_LIST + ITK_MIGRATION_SENTINELS +) + set(_r TRUE) +else() + set(_r FALSE) +endif() +expect_branch("${_r}" "TRUE" "sentinel present -> new branch") + +# State 3: mechanism present but this sentinel absent. +if( + ITK_VERSION + VERSION_GREATER_EQUAL + 6.1.0 + OR + ITK_MIGRATION_PR9999 + IN_LIST + ITK_MIGRATION_SENTINELS +) + set(_r TRUE) +else() + set(_r FALSE) +endif() +expect_branch("${_r}" "FALSE" "sentinel absent -> legacy branch") + +# State 4: after the tag. Sentinels expired and removed, version arm carries it. +set(ITK_VERSION 6.1.0) +unset(ITK_MIGRATION_SENTINELS) +if( + ITK_VERSION + VERSION_GREATER_EQUAL + 6.1.0 + OR + ITK_MIGRATION_PR6532 + IN_LIST + ITK_MIGRATION_SENTINELS +) + set(_r TRUE) +else() + set(_r FALSE) +endif() +expect_branch("${_r}" "TRUE" "post-tag, sentinels expired -> new branch") + +if(_failures GREATER 0) + message(FATAL_ERROR "${_failures} downstream-idiom check(s) failed") +endif() +message(STATUS "DownstreamIdiomTest: all checks passed") diff --git a/Utilities/MigrationSentinelTest/Fixtures/Empty/.gitkeep b/Utilities/MigrationSentinelTest/Fixtures/Empty/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Utilities/MigrationSentinelTest/Fixtures/Invalid/ITK_MIGRATION_PR1234.md b/Utilities/MigrationSentinelTest/Fixtures/Invalid/ITK_MIGRATION_PR1234.md new file mode 100644 index 00000000000..5a13922f873 --- /dev/null +++ b/Utilities/MigrationSentinelTest/Fixtures/Invalid/ITK_MIGRATION_PR1234.md @@ -0,0 +1,3 @@ +First line. + +Second paragraph makes this two description lines. diff --git a/Utilities/MigrationSentinelTest/Fixtures/Invalid/ITK_MIGRATION_lowercase.md b/Utilities/MigrationSentinelTest/Fixtures/Invalid/ITK_MIGRATION_lowercase.md new file mode 100644 index 00000000000..a9903b1e99d --- /dev/null +++ b/Utilities/MigrationSentinelTest/Fixtures/Invalid/ITK_MIGRATION_lowercase.md @@ -0,0 +1 @@ +Lowercase name is not an approved pattern. diff --git a/Utilities/MigrationSentinelTest/Fixtures/Invalid/ITK_MIGRATON_PR5678.md b/Utilities/MigrationSentinelTest/Fixtures/Invalid/ITK_MIGRATON_PR5678.md new file mode 100644 index 00000000000..7ce20c3633a --- /dev/null +++ b/Utilities/MigrationSentinelTest/Fixtures/Invalid/ITK_MIGRATON_PR5678.md @@ -0,0 +1 @@ +Typo in the prefix means the glob silently ignores this file. diff --git a/Utilities/MigrationSentinelTest/Fixtures/Populated/ITK_MIGRATION_MATH_SVD.md b/Utilities/MigrationSentinelTest/Fixtures/Populated/ITK_MIGRATION_MATH_SVD.md new file mode 100644 index 00000000000..ce6fc13d055 --- /dev/null +++ b/Utilities/MigrationSentinelTest/Fixtures/Populated/ITK_MIGRATION_MATH_SVD.md @@ -0,0 +1 @@ +itk::Math::SVD is available as the Eigen-backed SVD entry point. diff --git a/Utilities/MigrationSentinelTest/Fixtures/Populated/ITK_MIGRATION_PR6532.md b/Utilities/MigrationSentinelTest/Fixtures/Populated/ITK_MIGRATION_PR6532.md new file mode 100644 index 00000000000..3d166f92905 --- /dev/null +++ b/Utilities/MigrationSentinelTest/Fixtures/Populated/ITK_MIGRATION_PR6532.md @@ -0,0 +1 @@ +itk::Math::SVD replaces vnl_svd for ITK-internal consumers; see PR #6532. diff --git a/Utilities/MigrationSentinelTest/Fixtures/Populated/README.md b/Utilities/MigrationSentinelTest/Fixtures/Populated/README.md new file mode 100644 index 00000000000..beebbc260e3 --- /dev/null +++ b/Utilities/MigrationSentinelTest/Fixtures/Populated/README.md @@ -0,0 +1 @@ +Test fixture. Files here are inert; the glob matches only ITK_MIGRATION_*.md.