@@ -87,6 +87,82 @@ def resolve_catalog_specs(
8787 return [resolve_catalog_spec (spec , catalog = catalog ) for spec in specs ]
8888
8989
90+ class IncompatibleExtensionsError (ValueError ):
91+ """Raised when selected extensions declare mutual incompatibility."""
92+
93+ def __init__ (self , pairs : list [tuple [str , str ]]) -> None :
94+ self .pairs = pairs
95+ rendered = ", " .join (f"'{ a } ' ↔ '{ b } '" for a , b in pairs )
96+ super ().__init__ (
97+ "Incompatible extension combination: "
98+ f"{ rendered } . Remove one of each conflicting pair and retry."
99+ )
100+
101+
102+ def _extension_entries (catalog : dict [str , Any ]) -> list [dict [str , Any ]]:
103+ raw = catalog .get ("extensions" , catalog .get ("addons" , []))
104+ if not isinstance (raw , list ):
105+ return []
106+ return [entry for entry in raw if isinstance (entry , dict )]
107+
108+
109+ def find_extension_entry (catalog : dict [str , Any ], spec : str ) -> dict [str , Any ] | None :
110+ """Find an extension by slug or URL."""
111+ for entry in _extension_entries (catalog ):
112+ slug = str (entry .get ("slug" , "" ))
113+ url = str (entry .get ("url" , "" ))
114+ if spec in (slug , url ):
115+ return entry
116+ return None
117+
118+
119+ def find_incompatible_pairs (
120+ specs : list [str ], * , catalog : dict [str , Any ] | None = None
121+ ) -> list [tuple [str , str ]]:
122+ """Return ordered (slug, conflicting_slug) pairs among *specs*."""
123+ data = catalog if catalog is not None else get_catalog_data ()
124+ selected : list [dict [str , Any ]] = []
125+ seen_slugs : set [str ] = set ()
126+ for spec in specs :
127+ entry = find_extension_entry (data , spec )
128+ if entry is None :
129+ continue
130+ slug = str (entry .get ("slug" , "" ))
131+ if not slug or slug in seen_slugs :
132+ continue
133+ seen_slugs .add (slug )
134+ selected .append (entry )
135+
136+ selected_slugs = {str (entry .get ("slug" , "" )) for entry in selected }
137+ pairs : list [tuple [str , str ]] = []
138+ reported : set [tuple [str , str ]] = set ()
139+ for entry in selected :
140+ slug = str (entry .get ("slug" , "" ))
141+ raw = entry .get ("incompatibleWith" ) or entry .get ("incompatible_with" ) or []
142+ if not isinstance (raw , list ):
143+ continue
144+ for other in raw :
145+ other_slug = str (other )
146+ if other_slug not in selected_slugs or other_slug == slug :
147+ continue
148+ first , second = sorted ((slug , other_slug ))
149+ key = (first , second )
150+ if key in reported :
151+ continue
152+ reported .add (key )
153+ pairs .append ((slug , other_slug ))
154+ return pairs
155+
156+
157+ def validate_extension_compatibility (
158+ specs : list [str ], * , catalog : dict [str , Any ] | None = None
159+ ) -> None :
160+ """Fail fast when selected catalog extensions are mutually incompatible."""
161+ pairs = find_incompatible_pairs (specs , catalog = catalog )
162+ if pairs :
163+ raise IncompatibleExtensionsError (pairs )
164+
165+
90166def short_category_label (category_name : str ) -> str :
91167 """Derive a compact badge label from a catalog category name."""
92168 stop_words = {"Applications" , "Application" , "Boilerplate" }
0 commit comments