1111
1212namespace Symfony \AI \Mate \Discovery ;
1313
14+ use Composer \Composer ;
15+ use Composer \Factory ;
16+ use Composer \IO \NullIO ;
17+ use Composer \Util \Filesystem ;
1418use Psr \Log \LoggerInterface ;
1519
1620/**
@@ -46,6 +50,8 @@ final class ComposerExtensionDiscovery
4650 */
4751 private ?array $ installedPackages = null ;
4852
53+ private ?Composer $ composer = null ;
54+
4955 public function __construct (
5056 private string $ rootDir ,
5157 private LoggerInterface $ logger ,
@@ -161,63 +167,19 @@ private function getInstalledPackages(): array
161167 return $ this ->installedPackages ;
162168 }
163169
164- $ installedJsonPath = $ this ->rootDir .'/vendor/composer/installed.json ' ;
165- if (!file_exists ($ installedJsonPath )) {
166- $ this ->logger ->warning ('Composer installed.json not found ' , ['path ' => $ installedJsonPath ]);
167-
168- return $ this ->installedPackages = [];
169- }
170-
171- $ content = file_get_contents ($ installedJsonPath );
172- if (false === $ content ) {
173- $ this ->logger ->warning ('Could not read installed.json ' , [
174- 'path ' => $ installedJsonPath ,
175- 'error ' => error_get_last ()['message ' ] ?? 'Unknown error ' ,
176- ]);
177-
178- return $ this ->installedPackages = [];
179- }
180-
181- try {
182- $ data = json_decode ($ content , true , 512 , \JSON_THROW_ON_ERROR );
183- } catch (\JsonException $ e ) {
184- $ this ->logger ->error ('Invalid JSON in installed.json ' , ['error ' => $ e ->getMessage ()]);
185-
186- return $ this ->installedPackages = [];
187- }
188-
189- if (!\is_array ($ data )) {
190- return $ this ->installedPackages = [];
191- }
192-
193- // Handle both formats: {"packages": [...]} and direct array
194- $ packages = $ data ['packages ' ] ?? $ data ;
195- if (!\is_array ($ packages )) {
196- return $ this ->installedPackages = [];
197- }
198-
199- $ indexed = [];
200- foreach ($ packages as $ package ) {
201- if (!\is_array ($ package ) || !isset ($ package ['name ' ]) || !\is_string ($ package ['name ' ])) {
202- continue ;
203- }
170+ $ composer = $ this ->getComposer ();
204171
205- /** @var array{
206- * name: string,
207- * extra: array<string, mixed>,
208- * } $validPackage */
209- $ validPackage = [
210- 'name ' => $ package ['name ' ],
211- 'extra ' => [],
212- ];
172+ if ($ composer !== null ) {
173+ $ indexed = [];
213174
214- if (isset ($ package ['extra ' ]) && \is_array ($ package ['extra ' ])) {
215- /** @var array<string, mixed> $extra */
216- $ extra = $ package ['extra ' ];
217- $ validPackage ['extra ' ] = $ extra ;
175+ foreach ($ composer ->getRepositoryManager ()->getLocalRepository ()->getPackages () as $ package ) {
176+ $ indexed [$ package ->getName ()] = [
177+ 'name ' => $ package ->getName (),
178+ 'extra ' => $ package ->getExtra (),
179+ ];
218180 }
219-
220- $ indexed[ $ package [ ' name ' ]] = $ validPackage ;
181+ } else {
182+ $ indexed = $ this -> getPackagesWithoutComposer () ;
221183 }
222184
223185 return $ this ->installedPackages = $ indexed ;
@@ -234,10 +196,25 @@ private function getInstalledPackages(): array
234196 private function extractScanDirs (array $ package , string $ packageName ): array
235197 {
236198 $ aiMateConfig = $ package ['extra ' ]['ai-mate ' ] ?? null ;
199+ $ composer = $ this ->getComposer ();
200+ $ package = null ;
201+
202+ if ($ composer instanceof Composer) {
203+ $ package = $ composer ->getRepositoryManager ()->getLocalRepository ()->findPackage ($ packageName , '* ' );
204+ }
205+
237206 if (null === $ aiMateConfig ) {
238207 // Default: scan package root directory if no config provided
239208 $ defaultDir = 'vendor/ ' .$ packageName ;
240- if (is_dir ($ this ->rootDir .'/ ' .$ defaultDir )) {
209+ $ fullPath = $ this ->rootDir .'/ ' .$ defaultDir ;
210+
211+ if ($ package !== null ) {
212+ $ packagePath = $ composer ->getInstallationManager ()->getInstallPath ($ package );
213+ $ defaultDir = (new Filesystem ())->findShortestPath ($ this ->rootDir , $ packagePath );
214+ $ fullPath = $ packagePath ;
215+ }
216+
217+ if (is_dir ($ fullPath )) {
241218 return [$ defaultDir ];
242219 }
243220
@@ -268,16 +245,25 @@ private function extractScanDirs(array $package, string $packageName): array
268245 continue ;
269246 }
270247
271- $ fullPath = 'vendor/ ' .$ packageName .'/ ' .ltrim ($ dir , '/ ' );
272- if (!is_dir ($ this ->rootDir .'/ ' .$ fullPath )) {
248+ $ packageScanDir = 'vendor/ ' .$ packageName .'/ ' .ltrim ($ dir , '/ ' );
249+ $ fullPath = $ this ->rootDir .'/ ' .$ packageScanDir ;
250+
251+ if ($ package !== null ) {
252+ $ packagePath = $ composer ->getInstallationManager ()->getInstallPath ($ package );
253+ $ packageScanDir = $ packagePath .'/ ' .ltrim ($ dir , '/ ' );
254+ $ packageScanDir = (new Filesystem ())->findShortestPath ($ this ->rootDir , $ packageScanDir );
255+ $ fullPath = $ packageScanDir ;
256+ }
257+
258+ if (!is_dir ($ fullPath )) {
273259 $ this ->logger ->warning ('Scan directory does not exist ' , [
274260 'package ' => $ packageName ,
275- 'directory ' => $ fullPath ,
261+ 'directory ' => $ packageScanDir ,
276262 ]);
277263 continue ;
278264 }
279265
280- $ validDirs [] = $ fullPath ;
266+ $ validDirs [] = $ packageScanDir ;
281267 }
282268
283269 return $ validDirs ;
@@ -316,13 +302,26 @@ private function extractIncludeFiles(array $package, string $packageName): array
316302 return [];
317303 }
318304
305+ $ composer = $ this ->getComposer ();
306+ $ package = null ;
307+
308+ if ($ composer instanceof Composer) {
309+ $ package = $ composer ->getRepositoryManager ()->getLocalRepository ()->findPackage ($ packageName , '* ' );
310+ }
311+
319312 $ validFiles = [];
320313 foreach ($ includes as $ file ) {
321314 if (!\is_string ($ file ) || '' === trim ($ file ) || str_contains ($ file , '.. ' )) {
322315 continue ;
323316 }
324317
325318 $ fullPath = $ this ->rootDir .'/vendor/ ' .$ packageName .'/ ' .ltrim ($ file , '/ ' );
319+
320+ if ($ package !== null ) {
321+ $ packagePath = $ composer ->getInstallationManager ()->getInstallPath ($ package );
322+ $ fullPath = $ packagePath .'/ ' .ltrim ($ file , '/ ' );
323+ }
324+
326325 if (!file_exists ($ fullPath )) {
327326 $ this ->logger ->warning ('Include file does not exist ' , [
328327 'package ' => $ packageName ,
@@ -371,8 +370,21 @@ private function extractInstructions(array $package, string $packageName): ?stri
371370 return null ;
372371 }
373372
373+ $ composer = $ this ->getComposer ();
374+ $ package = null ;
375+
376+ if ($ composer instanceof Composer) {
377+ $ package = $ composer ->getRepositoryManager ()->getLocalRepository ()->findPackage ($ packageName , '* ' );
378+ }
379+
374380 // Validate file exists
375381 $ fullPath = $ this ->rootDir .'/vendor/ ' .$ packageName .'/ ' .ltrim ($ agentInstructions , '/ ' );
382+
383+ if ($ package !== null ) {
384+ $ packagePath = $ composer ->getInstallationManager ()->getInstallPath ($ package );
385+ $ fullPath = $ packagePath .'/ ' .ltrim ($ agentInstructions , '/ ' );
386+ }
387+
376388 if (!file_exists ($ fullPath )) {
377389 $ this ->logger ->warning ('Agent instructions file does not exist ' , [
378390 'package ' => $ packageName ,
@@ -420,4 +432,88 @@ private function extractAiMateConfigString(array $composer, string $key): ?strin
420432
421433 return $ value ;
422434 }
435+
436+ private function getPackagesWithoutComposer (): array
437+ {
438+ $ installedJsonPath = $ this ->rootDir .'/vendor/composer/installed.json ' ;
439+
440+ if (!file_exists ($ installedJsonPath )) {
441+ $ this ->logger ->warning ('Composer installed.json not found ' , ['path ' => $ installedJsonPath ]);
442+
443+ return [];
444+ }
445+
446+ $ content = file_get_contents ($ installedJsonPath );
447+ if (false === $ content ) {
448+ $ this ->logger ->warning ('Could not read installed.json ' , [
449+ 'path ' => $ installedJsonPath ,
450+ 'error ' => error_get_last ()['message ' ] ?? 'Unknown error ' ,
451+ ]);
452+
453+ return [];
454+ }
455+
456+ try {
457+ $ data = json_decode ($ content , true , 512 , \JSON_THROW_ON_ERROR );
458+ } catch (\JsonException $ e ) {
459+ $ this ->logger ->error ('Invalid JSON in installed.json ' , ['error ' => $ e ->getMessage ()]);
460+
461+ return [];
462+ }
463+
464+ if (!\is_array ($ data )) {
465+ return [];
466+ }
467+
468+ // Handle both formats: {"packages": [...]} and direct array
469+ $ packages = $ data ['packages ' ] ?? $ data ;
470+
471+ if (!\is_array ($ packages )) {
472+ return [];
473+ }
474+
475+ $ indexed = [];
476+
477+ foreach ($ packages as $ package ) {
478+ if (!\is_array ($ package ) || !isset ($ package ['name ' ]) || !\is_string ($ package ['name ' ])) {
479+ continue ;
480+ }
481+
482+ /** @var array{
483+ * name: string,
484+ * extra: array<string, mixed>,
485+ * } $validPackage
486+ */
487+ $ validPackage = [
488+ 'name ' => $ package ['name ' ],
489+ 'extra ' => [],
490+ ];
491+
492+ if (isset ($ package ['extra ' ]) && \is_array ($ package ['extra ' ])) {
493+ /** @var array<string, mixed> $extra */
494+ $ extra = $ package ['extra ' ];
495+ $ validPackage ['extra ' ] = $ extra ;
496+ }
497+
498+ $ indexed [$ package ['name ' ]] = $ validPackage ;
499+ }
500+
501+ return $ indexed ;
502+ }
503+
504+ private function getComposer (): ?Composer
505+ {
506+ if ($ this ->composer !== null ) {
507+ return $ this ->composer ;
508+ }
509+
510+ if (class_exists (Factory::class)) {
511+ $ result = Factory::create (new NullIO (), disableScripts: true );
512+ $ this ->composer = $ result ;
513+
514+ return $ result ;
515+ }
516+
517+ return null ;
518+ }
423519}
0 commit comments