@@ -78,8 +78,21 @@ def _register_rule(rule: MigrationRule) -> None:
7878 _RULES .append (rule )
7979
8080
81- def _get_rules (from_version : str , to_version : str ) -> list [MigrationRule ]:
82- """Get applicable rules between two versions."""
81+ def _get_rules (
82+ from_version : str , to_version : str , * , major_only : bool = False
83+ ) -> list [MigrationRule ]:
84+ """Get applicable rules between two versions.
85+
86+ Parameters
87+ ----------
88+ from_version
89+ Current dataset version.
90+ to_version
91+ Target version.
92+ major_only
93+ If True, only return rules for the target major version
94+ (e.g., only 2.0 rules, not 1.x rules).
95+ """
8396 from packaging .version import InvalidVersion , Version
8497
8598 try :
@@ -94,12 +107,37 @@ def _get_rules(from_version: str, to_version: str) -> list[MigrationRule]:
94107 rule_v = Version (rule .from_version )
95108 except Exception :
96109 continue
97- if from_v < rule_v <= to_v or rule_v <= from_v <= to_v :
98- applicable .append (rule )
110+
111+ if major_only :
112+ # Only include rules whose major version matches the target
113+ if rule_v .major != to_v .major :
114+ continue
115+ if rule_v <= to_v :
116+ applicable .append (rule )
117+ else :
118+ if from_v < rule_v <= to_v or rule_v <= from_v <= to_v :
119+ applicable .append (rule )
99120
100121 return applicable
101122
102123
124+ def _is_major_version_upgrade (from_version : str , to_version : str ) -> bool :
125+ """Check if migration crosses a major version boundary."""
126+ from packaging .version import InvalidVersion , Version
127+
128+ try :
129+ from_v = Version (from_version )
130+ to_v = Version (to_version )
131+ except InvalidVersion :
132+ return False
133+ return to_v .major > from_v .major
134+
135+
136+ def _latest_1x_version () -> str :
137+ """Return the latest known 1.x BIDS version."""
138+ return "1.11.1"
139+
140+
103141# ---------------------------------------------------------------------------
104142# Built-in migration rules (1.x deprecations)
105143# ---------------------------------------------------------------------------
@@ -270,6 +308,23 @@ def _get_rules(from_version: str, to_version: str) -> list[MigrationRule]:
270308)
271309
272310
311+ # ---------------------------------------------------------------------------
312+ # BIDS 2.0 migration rules (placeholder infrastructure)
313+ #
314+ # The BIDS 2.0 schema is not yet finalized. The rules below register the
315+ # *categories* of change that 2.0 will require so that the engine, scanner,
316+ # applier, and test infrastructure are exercised end-to-end. Concrete rules
317+ # will be added once the 2.0 schema stabilizes.
318+ # ---------------------------------------------------------------------------
319+
320+ # NOTE: No concrete 2.0 rules are registered yet because the schema is not
321+ # finalized. When rules are added they should use from_version="2.0.0" and
322+ # one of the 2.0-specific categories below:
323+ # - "entity_rename" (entity key changes, e.g. hypothetical acq→acquisition)
324+ # - "structural_reorg" (directory layout changes)
325+ # - "metadata_key_change" (metadata key renames specific to 2.0)
326+
327+
273328# ---------------------------------------------------------------------------
274329# Scanning and fixing logic
275330# ---------------------------------------------------------------------------
@@ -531,6 +586,119 @@ def _scan_for_deprecated_template(
531586 return findings
532587
533588
589+ # ---------------------------------------------------------------------------
590+ # 2.0-specific scanners
591+ # ---------------------------------------------------------------------------
592+
593+
594+ def _scan_for_entity_rename (
595+ dataset_root : Path ,
596+ rule : MigrationRule ,
597+ ) -> list [MigrationFinding ]:
598+ """Scan for files using a deprecated entity key (2.0 migration)."""
599+ findings : list [MigrationFinding ] = []
600+ old_key = rule .old_field
601+ new_key = rule .new_field
602+ if not old_key :
603+ return findings
604+
605+ bids_files = _scan_bids_files (dataset_root )
606+ for fp in bids_files :
607+ try :
608+ bp = BIDSPath .from_path (fp )
609+ except Exception :
610+ continue
611+ if old_key in bp .entities :
612+ findings .append (
613+ MigrationFinding (
614+ rule = rule ,
615+ file = fp ,
616+ current_value = f"{ old_key } -{ bp .entities [old_key ]} " ,
617+ proposed_value = f"{ new_key } -{ bp .entities [old_key ]} " ,
618+ can_auto_fix = True ,
619+ )
620+ )
621+ return findings
622+
623+
624+ def _scan_for_metadata_key_change (
625+ json_files : list [Path ],
626+ rule : MigrationRule ,
627+ ) -> list [MigrationFinding ]:
628+ """Scan for metadata keys that changed in 2.0."""
629+ # Reuse the field_rename scanner — same logic, different category label
630+ return _scan_for_field_rename (json_files , rule )
631+
632+
633+ def _scan_for_structural_reorg (
634+ dataset_root : Path ,
635+ rule : MigrationRule ,
636+ ) -> list [MigrationFinding ]:
637+ """Scan for structural layout issues requiring 2.0 reorganization.
638+
639+ Structural reorganization rules are inherently ambiguous and require
640+ human judgment. This scanner flags findings but marks them as not
641+ auto-fixable.
642+ """
643+ findings : list [MigrationFinding ] = []
644+ # Structural reorg rules describe directory layout changes that cannot
645+ # be applied automatically without understanding dataset intent.
646+ # Flag the entire dataset as needing review.
647+ findings .append (
648+ MigrationFinding (
649+ rule = rule ,
650+ file = dataset_root / "dataset_description.json" ,
651+ current_value = "current layout" ,
652+ proposed_value = rule .description ,
653+ can_auto_fix = False ,
654+ reason = (
655+ "Structural reorganization requires human judgment;"
656+ " review the BIDS 2.0 specification for guidance"
657+ ),
658+ )
659+ )
660+ return findings
661+
662+
663+ # ---------------------------------------------------------------------------
664+ # 2.0-specific appliers
665+ # ---------------------------------------------------------------------------
666+
667+
668+ def _apply_entity_rename (
669+ finding : MigrationFinding , dataset : BIDSDataset
670+ ) -> Change | None :
671+ """Apply an entity key rename by delegating to rename_file()."""
672+ from bids_utils .rename import rename_file
673+
674+ fp = finding .file
675+ rule = finding .rule
676+ old_key = rule .old_field
677+ new_key = rule .new_field
678+ if not old_key or not new_key :
679+ return None
680+
681+ try :
682+ bp = BIDSPath .from_path (fp )
683+ except Exception :
684+ return None
685+
686+ if old_key not in bp .entities :
687+ return None
688+
689+ # Rename: drop old entity, add new entity with same value
690+ value = bp .entities [old_key ]
691+ result = rename_file (
692+ dataset ,
693+ fp ,
694+ set_entities = {new_key : value },
695+ drop_entities = [old_key ],
696+ )
697+ if result .success and result .changes :
698+ return result .changes [0 ]
699+ return None
700+
701+
534702# ---------------------------------------------------------------------------
535703# Apply fixes
536704# ---------------------------------------------------------------------------
@@ -715,6 +883,10 @@ def migrate_dataset(
715883) -> MigrationResult :
716884 """Apply schema-driven migrations to a BIDS dataset.
717885
886+ When the target is a major version upgrade (e.g., 1.x → 2.0), migration
887+ is **cumulative**: all 1.x deprecation fixes are applied first, then
888+ 2.0-specific transformations.
889+
718890 Parameters
719891 ----------
720892 dataset
@@ -741,8 +913,16 @@ def migrate_dataset(
741913 to_version = to_version ,
742914 )
743915
744- # Get applicable rules
745- rules = _get_rules (from_version , to_version )
916+ is_major_upgrade = _is_major_version_upgrade (from_version , to_version )
917+
918+ if is_major_upgrade :
919+ # Cumulative migration: apply all 1.x fixes first, then 2.0 rules
920+ latest_1x = _latest_1x_version ()
921+ onex_rules = _get_rules (from_version , latest_1x )
922+ twox_rules = _get_rules (from_version , to_version , major_only = True )
923+ rules = onex_rules + twox_rules
924+ else :
925+ rules = _get_rules (from_version , to_version )
746926
747927 if not rules :
748928 result .warnings .append ("No applicable migration rules found" )
@@ -760,6 +940,10 @@ def migrate_dataset(
760940 "value_rename" : lambda r : _scan_for_doi_format (json_files , r ),
761941 "suffix_deprecation" : lambda r : _scan_for_suffix_deprecation (dataset .root , r ),
762942 "deprecated_template" : lambda r : _scan_for_deprecated_template (json_files , r ),
943+ # 2.0-specific categories
944+ "entity_rename" : lambda r : _scan_for_entity_rename (dataset .root , r ),
945+ "metadata_key_change" : lambda r : _scan_for_metadata_key_change (json_files , r ),
946+ "structural_reorg" : lambda r : _scan_for_structural_reorg (dataset .root , r ),
763947 }
764948
765949 for rule in rules :
@@ -772,6 +956,23 @@ def migrate_dataset(
772956 result .warnings .append ("Nothing to migrate — dataset is up to date" )
773957 return result
774958
959+ # T043: Check for ambiguities that should abort migration
960+ unfixable = [f for f in result .findings if not f .can_auto_fix ]
961+ if is_major_upgrade and unfixable and not dry_run :
962+ # For major version upgrades, unfixable findings abort the migration
963+ # rather than partially applying (user must resolve ambiguities first)
964+ result .success = False
965+ for f in unfixable :
966+ result .errors .append (
967+ f"Cannot auto-fix ({ f .rule .id } ): { f .file } : { f .reason } "
968+ )
969+ result .warnings .append (
970+ "Migration aborted: resolve the above ambiguities manually "
971+ "before migrating to a new major version. "
972+ "Run with --dry-run to see all findings."
973+ )
974+ return result
975+
775976 if dry_run :
776977 return result
777978
@@ -783,7 +984,10 @@ def migrate_dataset(
783984 "cross_file_move" : lambda f : _apply_scandate_move (f , dataset .root ),
784985 "value_rename" : lambda f : _apply_doi_format (f ),
785986 "suffix_deprecation" : lambda f : _apply_suffix_deprecation (f , dataset ),
786- # deprecated_template: no applier — can_auto_fix=False
987+ # 2.0-specific appliers
988+ "entity_rename" : lambda f : _apply_entity_rename (f , dataset ),
989+ "metadata_key_change" : lambda f : _apply_field_rename (f ),
990+ # deprecated_template, structural_reorg: no applier — can_auto_fix=False
787991 }
788992
789993 for finding in result .findings :
0 commit comments