@@ -48,6 +48,19 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]:
4848 ]
4949
5050
51+ def _multi_install_safe_orders () -> list [list [str ]]:
52+ safe_keys = _multi_install_safe_keys ()
53+ if len (safe_keys ) < 2 :
54+ return [safe_keys ]
55+ return [safe_keys [index :] + safe_keys [:index ] for index in range (len (safe_keys ))]
56+
57+
58+ def _multi_install_safe_order_id (ordered_keys : list [str ]) -> str :
59+ if not ordered_keys :
60+ return "no-safe-integrations"
61+ return f"init-{ ordered_keys [0 ]} "
62+
63+
5164def _posix_path (value : str | None ) -> str | None :
5265 if not value :
5366 return None
@@ -87,16 +100,6 @@ def _paths_overlap(first: str | None, second: str | None) -> bool:
87100 return False
88101
89102
90- def _path_is_inside (path : str | None , directory : str | None ) -> bool :
91- if not path or not directory :
92- return False
93- try :
94- PurePosixPath (path ).relative_to (PurePosixPath (directory ))
95- return True
96- except ValueError :
97- return False
98-
99-
100103class TestRegistry :
101104 def test_registry_is_dict (self ):
102105 assert isinstance (INTEGRATION_REGISTRY , dict )
@@ -162,6 +165,15 @@ def test_no_stale_cursor_shorthand(self):
162165class TestMultiInstallSafeContracts :
163166 """Declared safe integrations must stay isolated from each other."""
164167
168+ def test_safe_install_orders_rotate_each_integration_through_init (self ):
169+ safe_keys = _multi_install_safe_keys ()
170+ orders = _multi_install_safe_orders ()
171+
172+ assert len (safe_keys ) >= 2
173+ assert [order [0 ] for order in orders ] == safe_keys
174+ assert len ({tuple (order ) for order in orders }) == len (safe_keys )
175+ assert all (sorted (order ) == safe_keys for order in orders )
176+
165177 @pytest .mark .parametrize ("key" , _multi_install_safe_keys ())
166178 def test_safe_integrations_have_static_isolated_paths (self , key ):
167179 assert _integration_root_dir (key ), (
@@ -187,62 +199,77 @@ def test_safe_integrations_have_distinct_command_dirs(self, first, second):
187199 f"{ _integration_commands_dir (second )!r} "
188200 )
189201
190- @pytest .mark .parametrize (("first" , "second" ), _multi_install_safe_pairs ())
202+ @pytest .mark .parametrize (
203+ "ordered_keys" ,
204+ _multi_install_safe_orders (),
205+ ids = _multi_install_safe_order_id ,
206+ )
191207 def test_safe_integrations_have_disjoint_manifests (
192208 self ,
193209 tmp_path ,
194- first ,
195- second ,
210+ ordered_keys ,
196211 ):
197- for initial , additional in ((first , second ), (second , first )):
198- project_root = tmp_path / f"project-{ initial } -{ additional } "
199- project_root .mkdir ()
200- runner = CliRunner ()
201-
202- original_cwd = os .getcwd ()
203- try :
204- os .chdir (project_root )
205- init_result = runner .invoke (
206- app ,
207- [
208- "init" ,
209- "--here" ,
210- "--integration" ,
211- initial ,
212- "--script" ,
213- "sh" ,
214- "--ignore-agent-tools" ,
215- ],
216- catch_exceptions = False ,
217- )
218- assert init_result .exit_code == 0 , init_result .output
212+ # The pairwise disjointness contract is only meaningful with at least
213+ # two safe integrations. Guard so a shrunken registry fails loudly here
214+ # rather than passing vacuously (or tripping over ordered_keys[0] below).
215+ assert len (ordered_keys ) >= 2 , (
216+ f"expected at least two multi-install-safe integrations, got { ordered_keys } "
217+ )
219218
219+ project_root = tmp_path / "project"
220+ project_root .mkdir ()
221+ runner = CliRunner ()
222+
223+ # Install every safe integration once into a single project, then assert
224+ # pairwise manifest isolation. Each safe integration writes only to its
225+ # own (disjoint) directories and always records what it writes, so a
226+ # manifest's contents are independent of install order and of which other
227+ # integrations are co-installed. The parametrized rotations keep the
228+ # aggregate setup while placing each safe integration first once, so each
229+ # one still exercises the `specify init --integration ...` path.
230+ original_cwd = os .getcwd ()
231+ try :
232+ os .chdir (project_root )
233+ init_result = runner .invoke (
234+ app ,
235+ [
236+ "init" ,
237+ "--here" ,
238+ "--integration" ,
239+ ordered_keys [0 ],
240+ "--script" ,
241+ "sh" ,
242+ "--ignore-agent-tools" ,
243+ ],
244+ catch_exceptions = False ,
245+ )
246+ assert init_result .exit_code == 0 , init_result .output
247+
248+ for key in ordered_keys [1 :]:
220249 install_result = runner .invoke (
221250 app ,
222- ["integration" , "install" , additional , "--script" , "sh" ],
251+ ["integration" , "install" , key , "--script" , "sh" ],
223252 catch_exceptions = False ,
224253 )
225254 assert install_result .exit_code == 0 , install_result .output
226- finally :
227- os .chdir (original_cwd )
255+ finally :
256+ os .chdir (original_cwd )
228257
229- initial_manifest = json .loads (
230- (
231- project_root / ".specify" / "integrations" / f"{ initial } .manifest.json"
232- ).read_text (encoding = "utf-8" )
258+ integrations_dir = project_root / ".specify" / "integrations"
259+ manifests = {}
260+ for key in ordered_keys :
261+ manifest = json .loads (
262+ (integrations_dir / f"{ key } .manifest.json" ).read_text (encoding = "utf-8" )
233263 )
234- additional_manifest = json .loads (
235- (
236- project_root / ".specify" / "integrations" / f"{ additional } .manifest.json"
237- ).read_text (encoding = "utf-8" )
238- )
239-
240- initial_files = set (initial_manifest .get ("files" , {}))
241- additional_files = set (additional_manifest .get ("files" , {}))
242-
243- assert initial_files .isdisjoint (additional_files ), (
244- f"{ initial } and { additional } are declared multi-install safe but both manage "
245- f"these files: { sorted (initial_files & additional_files )} "
264+ files = manifest .get ("files" , {})
265+ assert isinstance (files , dict ), f"{ key } manifest files must be an object"
266+ manifests [key ] = set (files .keys ())
267+
268+ for first , second in _multi_install_safe_pairs ():
269+ overlap = manifests [first ] & manifests [second ]
270+ assert not overlap , (
271+ f"{ first } and { second } are declared multi-install safe but both manage "
272+ f"these files: { sorted (overlap )} "
246273 )
247274
248275
0 commit comments