@@ -357,6 +357,60 @@ describe('SchemaRegistry', () => {
357357 registry . unregisterObjectsByPackage ( 'com.owner' , true ) ;
358358 } ) . not . toThrow ( ) ;
359359 } ) ;
360+
361+ it ( '[#7970] refuses before removing anything — a free sibling object survives' , ( ) => {
362+ // `free` is walked FIRST (Map insertion order) and used to be spliced
363+ // out on the way to refusing over `important`, so the refusal that
364+ // exists to keep the registry whole half-tore it down instead.
365+ registry . registerObject ( { name : 'free' , fields : { } } , 'com.owner' , 'base' , 'own' ) ;
366+ registry . registerObject ( { name : 'important' , fields : { } } , 'com.owner' , 'base' , 'own' ) ;
367+ registry . registerObject ( { name : 'important' , fields : { } } , 'com.ext' , undefined , 'extend' ) ;
368+
369+ expect ( ( ) => {
370+ registry . unregisterObjectsByPackage ( 'com.owner' ) ;
371+ } ) . toThrow ( / o b j e c t " i m p o r t a n t " i s e x t e n d e d b y c o m \. e x t / ) ;
372+
373+ expect ( registry . getObject ( 'free' ) ) . toBeDefined ( ) ;
374+ expect ( registry . getObject ( 'important' ) ) . toBeDefined ( ) ;
375+ } ) ;
376+
377+ /**
378+ * [#7970] MESSAGE IDENTITY, the half the reorder must not disturb. The
379+ * refusal pass replaces an inline check, so it must name the SAME object
380+ * and the SAME extenders as before — with two refusable objects the
381+ * first one walked still wins, and an object's extenders are still
382+ * listed in registration order. This test is deliberately written to
383+ * pass BOTH before and after the fix: run it against the pre-fix
384+ * `registry.ts` and it stays green, which is what proves the message did
385+ * not move (only the mutations that used to precede it are gone).
386+ */
387+ it ( '[#7970] the refusal still names the first refusable object and all its extenders' , ( ) => {
388+ registry . registerObject ( { name : 'alpha' , fields : { } } , 'com.owner' , 'base' , 'own' ) ;
389+ registry . registerObject ( { name : 'beta' , fields : { } } , 'com.owner' , 'base' , 'own' ) ;
390+ registry . registerObject ( { name : 'alpha' , fields : { } } , 'com.ext1' , undefined , 'extend' ) ;
391+ registry . registerObject ( { name : 'alpha' , fields : { } } , 'com.ext2' , undefined , 'extend' ) ;
392+ registry . registerObject ( { name : 'beta' , fields : { } } , 'com.ext3' , undefined , 'extend' ) ;
393+
394+ expect ( ( ) => registry . unregisterObjectsByPackage ( 'com.owner' ) ) . toThrow (
395+ 'Cannot uninstall package "com.owner": object "alpha" is extended by ' +
396+ 'com.ext1, com.ext2. Uninstall extenders first.' ,
397+ ) ;
398+ } ) ;
399+
400+ it ( '[#7970] force still removes the owner even with a free sibling ahead of it' , ( ) => {
401+ registry . registerObject ( { name : 'free' , fields : { } } , 'com.owner' , 'base' , 'own' ) ;
402+ registry . registerObject ( { name : 'important' , fields : { } } , 'com.owner' , 'base' , 'own' ) ;
403+ registry . registerObject ( { name : 'important' , fields : { } } , 'com.ext' , undefined , 'extend' ) ;
404+
405+ registry . unregisterObjectsByPackage ( 'com.owner' , true ) ;
406+
407+ // The refusal pass is skipped under `force`, and the mutation pass is
408+ // unchanged: both of the package's contributions are gone, and the
409+ // extender's own contribution is left where it was.
410+ expect ( registry . getObject ( 'free' ) ) . toBeUndefined ( ) ;
411+ expect ( registry . getObjectOwner ( 'important' ) ) . toBeUndefined ( ) ;
412+ expect ( registry . getObjectContributors ( 'important' ) ) . toHaveLength ( 1 ) ;
413+ } ) ;
360414 } ) ;
361415
362416 // ==========================================
@@ -427,6 +481,64 @@ describe('SchemaRegistry', () => {
427481 expect ( registry . getNamespaceOwner ( 'test' ) ) . toBeUndefined ( ) ;
428482 } ) ;
429483
484+ /**
485+ * [#7970] The uninstall's ONE refusable step is `unregisterObjectsByPackage`
486+ * (ADR-0029: you may not uninstall the owner of an object another package
487+ * extends). It now runs before every mutation, so reaching that refusal
488+ * costs nothing. The namespace is the limb that measured this: the release
489+ * used to run FIRST, so a refused uninstall left the package installed —
490+ * record, objects and items all intact — while its namespace no longer
491+ * resolved, for the life of the process. No test refused and then inspected
492+ * the namespace, which is exactly why the defect was invisible.
493+ *
494+ * Latent by grade: no in-tree caller reaches the refusal path today.
495+ */
496+ it ( '[#7970] a refused uninstall leaves the namespace still resolving' , ( ) => {
497+ registry . installPackage ( { id : 'com.crm' , name : 'CRM' , namespace : 'crm' , version : '1.0.0' } as any ) ;
498+ registry . registerObject ( { name : 'contact' , fields : { } } , 'com.crm' , 'crm' , 'own' ) ;
499+ registry . registerObject ( { name : 'contact' , fields : { } } , 'com.analytics' , undefined , 'extend' ) ;
500+
501+ expect ( registry . getNamespaceOwner ( 'crm' ) ) . toBe ( 'com.crm' ) ;
502+
503+ expect ( ( ) => registry . uninstallPackage ( 'com.crm' ) ) . toThrow (
504+ / C a n n o t u n i n s t a l l p a c k a g e " c o m \. c r m " .* e x t e n d e d b y c o m \. a n a l y t i c s / ,
505+ ) ;
506+
507+ // The assertion the card names: refused ⇒ the namespace still resolves.
508+ expect ( registry . getNamespaceOwner ( 'crm' ) ) . toBe ( 'com.crm' ) ;
509+ expect ( registry . getNamespaceOwners ( 'crm' ) ) . toEqual ( [ 'com.crm' ] ) ;
510+ } ) ;
511+
512+ it ( '[#7970] a refused uninstall leaves the whole package intact, not just the namespace' , ( ) => {
513+ registry . installPackage ( { id : 'com.crm' , name : 'CRM' , namespace : 'crm' , version : '1.0.0' } as any ) ;
514+ // Registered ahead of the extended object, so the object walk reaches
515+ // this one before it can refuse.
516+ registry . registerObject ( { name : 'account' , fields : { } } , 'com.crm' , 'crm' , 'own' ) ;
517+ registry . registerObject ( { name : 'contact' , fields : { } } , 'com.crm' , 'crm' , 'own' ) ;
518+ registry . registerObject ( { name : 'contact' , fields : { } } , 'com.analytics' , undefined , 'extend' ) ;
519+ registry . registerItem ( 'page' , { name : 'home' } , 'name' , 'com.crm' ) ;
520+
521+ expect ( ( ) => registry . uninstallPackage ( 'com.crm' ) ) . toThrow ( / e x t e n d e d b y c o m \. a n a l y t i c s / ) ;
522+
523+ // Every limb `uninstallPackage` mutates, in the order it mutates them.
524+ expect ( registry . getNamespaceOwner ( 'crm' ) ) . toBe ( 'com.crm' ) ;
525+ expect ( registry . getObject ( 'account' ) ) . toBeDefined ( ) ;
526+ expect ( registry . getObject ( 'contact' ) ) . toBeDefined ( ) ;
527+ expect ( registry . getItem ( 'page' , 'home' ) ) . toMatchObject ( { name : 'home' } ) ;
528+ expect ( registry . getPackage ( 'com.crm' ) ) . toBeDefined ( ) ;
529+ } ) ;
530+
531+ it ( '[#7970] the successful path still releases the namespace after the object verb' , ( ) => {
532+ registry . installPackage ( { id : 'com.crm' , name : 'CRM' , namespace : 'crm' , version : '1.0.0' } as any ) ;
533+ registry . registerObject ( { name : 'contact' , fields : { } } , 'com.crm' , 'crm' , 'own' ) ;
534+
535+ expect ( registry . uninstallPackage ( 'com.crm' ) ) . toBe ( true ) ;
536+
537+ expect ( registry . getNamespaceOwner ( 'crm' ) ) . toBeUndefined ( ) ;
538+ expect ( registry . getObject ( 'contact' ) ) . toBeUndefined ( ) ;
539+ expect ( registry . getPackage ( 'com.crm' ) ) . toBeUndefined ( ) ;
540+ } ) ;
541+
430542 it ( 'updatePackageManifest merges editable fields, preserving lifecycle state' , ( ) => {
431543 registry . installPackage ( { id : 'com.test' , name : 'Old' , version : '1.0.0' } as any ) ;
432544 registry . disablePackage ( 'com.test' ) ; // lifecycle state that must survive an edit
0 commit comments