@@ -588,7 +588,7 @@ export class RegistryClient {
588
588
this . logger . warn ( 'Failed to read composer.json for dependency type detection:' , error )
589
589
}
590
590
591
- // Parse composer outdated output and filter based on version constraints
591
+ // Parse composer outdated output and find multiple update paths per package
592
592
if ( composerData . installed ) {
593
593
for ( const pkg of composerData . installed ) {
594
594
if ( pkg . name && pkg . version && pkg . latest ) {
@@ -601,23 +601,12 @@ export class RegistryClient {
601
601
continue // Skip packages not found in composer.json
602
602
}
603
603
604
- // Include all available updates - let grouping and strategy handle filtering
605
- const newVersion = pkg . latest
606
-
607
- const updateType = getUpdateType ( pkg . version , newVersion )
608
-
609
604
// Skip ignored packages
610
605
const ignoredPackages = this . config ?. packages ?. ignore || [ ]
611
606
if ( ignoredPackages . includes ( pkg . name ) ) {
612
607
continue
613
608
}
614
609
615
- // Check if this is a major update and if major updates are excluded
616
- const excludeMajor = this . config ?. packages ?. excludeMajor ?? false
617
- if ( excludeMajor && updateType === 'major' ) {
618
- continue
619
- }
620
-
621
610
// Determine dependency type by checking composer.json
622
611
let dependencyType : 'require' | 'require-dev' = 'require'
623
612
if ( composerJsonData [ 'require-dev' ] && composerJsonData [ 'require-dev' ] [ pkg . name ] ) {
@@ -627,18 +616,48 @@ export class RegistryClient {
627
616
// Get additional metadata for the package
628
617
const metadata = await this . getComposerPackageMetadata ( pkg . name )
629
618
630
- updates . push ( {
631
- name : pkg . name ,
632
- currentVersion : pkg . version ,
633
- newVersion,
634
- updateType,
635
- dependencyType,
636
- file : 'composer.json' ,
637
- metadata,
638
- releaseNotesUrl : this . getComposerReleaseNotesUrl ( pkg . name , metadata ) ,
639
- changelogUrl : this . getComposerChangelogUrl ( pkg . name , metadata ) ,
640
- homepage : metadata ?. homepage ,
641
- } )
619
+ // Find multiple update paths: patch, minor, and major
620
+ const currentVersion = pkg . version
621
+ const latestVersion = pkg . latest
622
+
623
+ // Get all available versions by querying composer show
624
+ let availableVersions : string [ ] = [ ]
625
+ try {
626
+ const showOutput = await this . runCommand ( 'composer' , [ 'show' , pkg . name , '--available' , '--format=json' ] )
627
+ const showData = JSON . parse ( showOutput )
628
+ if ( showData . versions ) {
629
+ availableVersions = Object . keys ( showData . versions )
630
+ }
631
+ } catch ( error ) {
632
+ console . warn ( `Failed to get available versions for ${ pkg . name } , using latest only:` , error )
633
+ availableVersions = [ latestVersion ]
634
+ }
635
+
636
+ // Find the best update for each type (patch, minor, major)
637
+ const updateCandidates = await this . findBestUpdates ( currentVersion , availableVersions , constraint )
638
+
639
+ for ( const candidate of updateCandidates ) {
640
+ const updateType = getUpdateType ( currentVersion , candidate . version )
641
+
642
+ // Check if this update type should be excluded
643
+ const excludeMajor = this . config ?. packages ?. excludeMajor ?? false
644
+ if ( excludeMajor && updateType === 'major' ) {
645
+ continue
646
+ }
647
+
648
+ updates . push ( {
649
+ name : pkg . name ,
650
+ currentVersion,
651
+ newVersion : candidate . version ,
652
+ updateType,
653
+ dependencyType,
654
+ file : 'composer.json' ,
655
+ metadata,
656
+ releaseNotesUrl : this . getComposerReleaseNotesUrl ( pkg . name , metadata ) ,
657
+ changelogUrl : this . getComposerChangelogUrl ( pkg . name , metadata ) ,
658
+ homepage : metadata ?. homepage ,
659
+ } )
660
+ }
642
661
}
643
662
}
644
663
}
@@ -652,6 +671,95 @@ export class RegistryClient {
652
671
}
653
672
}
654
673
674
+ /**
675
+ * Find the best patch, minor, and major updates for a package
676
+ */
677
+ private async findBestUpdates ( currentVersion : string , availableVersions : string [ ] , constraint : string ) : Promise < { version : string , type : 'patch' | 'minor' | 'major' } [ ] > {
678
+ const { getUpdateType } = await import ( '../utils/helpers' )
679
+ const candidates : { version : string , type : 'patch' | 'minor' | 'major' } [ ] = [ ]
680
+
681
+ // Parse current version
682
+ const currentParts = this . parseVersion ( currentVersion )
683
+ if ( ! currentParts ) return [ ]
684
+
685
+ let bestPatch : string | null = null
686
+ let bestMinor : string | null = null
687
+ let bestMajor : string | null = null
688
+
689
+ for ( const version of availableVersions ) {
690
+ // Skip dev/alpha/beta versions for now (could be enhanced later)
691
+ if ( version . includes ( 'dev' ) || version . includes ( 'alpha' ) || version . includes ( 'beta' ) || version . includes ( 'RC' ) ) {
692
+ continue
693
+ }
694
+
695
+ const versionParts = this . parseVersion ( version )
696
+ if ( ! versionParts ) continue
697
+
698
+ // Skip versions that are not newer
699
+ if ( this . compareVersions ( version , currentVersion ) <= 0 ) {
700
+ continue
701
+ }
702
+
703
+ const updateType = getUpdateType ( currentVersion , version )
704
+
705
+ // Find best update for each type
706
+ if ( updateType === 'patch' && versionParts . major === currentParts . major && versionParts . minor === currentParts . minor ) {
707
+ if ( ! bestPatch || this . compareVersions ( version , bestPatch ) > 0 ) {
708
+ bestPatch = version
709
+ }
710
+ } else if ( updateType === 'minor' && versionParts . major === currentParts . major ) {
711
+ if ( ! bestMinor || this . compareVersions ( version , bestMinor ) > 0 ) {
712
+ bestMinor = version
713
+ }
714
+ } else if ( updateType === 'major' ) {
715
+ if ( ! bestMajor || this . compareVersions ( version , bestMajor ) > 0 ) {
716
+ bestMajor = version
717
+ }
718
+ }
719
+ }
720
+
721
+ // Add the best candidates
722
+ if ( bestPatch ) candidates . push ( { version : bestPatch , type : 'patch' } )
723
+ if ( bestMinor ) candidates . push ( { version : bestMinor , type : 'minor' } )
724
+ if ( bestMajor ) candidates . push ( { version : bestMajor , type : 'major' } )
725
+
726
+ return candidates
727
+ }
728
+
729
+ /**
730
+ * Parse a version string into major.minor.patch
731
+ */
732
+ private parseVersion ( version : string ) : { major : number , minor : number , patch : number } | null {
733
+ // Remove 'v' prefix and any pre-release identifiers
734
+ const cleanVersion = version . replace ( / ^ v / , '' ) . split ( '-' ) [ 0 ] . split ( '+' ) [ 0 ]
735
+ const parts = cleanVersion . split ( '.' ) . map ( p => parseInt ( p , 10 ) )
736
+
737
+ if ( parts . length < 2 || parts . some ( p => isNaN ( p ) ) ) {
738
+ return null
739
+ }
740
+
741
+ return {
742
+ major : parts [ 0 ] || 0 ,
743
+ minor : parts [ 1 ] || 0 ,
744
+ patch : parts [ 2 ] || 0
745
+ }
746
+ }
747
+
748
+ /**
749
+ * Compare two version strings
750
+ * Returns: -1 if a < b, 0 if a === b, 1 if a > b
751
+ */
752
+ private compareVersions ( a : string , b : string ) : number {
753
+ const parseA = this . parseVersion ( a )
754
+ const parseB = this . parseVersion ( b )
755
+
756
+ if ( ! parseA || ! parseB ) return 0
757
+
758
+ if ( parseA . major !== parseB . major ) return parseA . major - parseB . major
759
+ if ( parseA . minor !== parseB . minor ) return parseA . minor - parseB . minor
760
+ return parseA . patch - parseB . patch
761
+ }
762
+
655
763
/**
656
764
* Get Composer package metadata from Packagist
657
765
*/
0 commit comments