Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions .github/workflows/qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,11 @@ jobs:
id: stan
run: |
set +e
vendor/bin/phpstan analyse --error-format=json --no-progress > phpstan.json 2>&1
vendor/bin/phpstan analyse --error-format=json --no-progress > phpstan.json
EXIT_CODE=$?

# Also run for GitHub format
vendor/bin/phpstan analyse --error-format=github --no-progress 2>&1 || true
vendor/bin/phpstan analyse --error-format=github --no-progress || true

ERRORS=$(jq '.totals.file_errors // 0' phpstan.json 2>/dev/null || echo "0")
echo "errors=${ERRORS}" >> $GITHUB_OUTPUT
Expand Down Expand Up @@ -181,11 +181,18 @@ jobs:
id: psalm
run: |
set +e
vendor/bin/psalm --output-format=json --show-info=false > psalm.json 2>&1
vendor/bin/psalm --output-format=json --show-info=false > psalm.json
EXIT_CODE=$?

# Generate SARIF for GitHub Security
vendor/bin/psalm --output-format=sarif --show-info=false > psalm.sarif 2>&1 || true
vendor/bin/psalm --output-format=sarif --show-info=false > psalm_raw.sarif || true

# Sanitize SARIF: Ensure all region values are >= 1
if [ -s psalm_raw.sarif ]; then
jq 'walk(if type == "object" and has("region") then .region |= with_entries(.value = if .value < 1 then 1 else .value end) else . end)' psalm_raw.sarif > psalm.sarif
else
touch psalm.sarif
fi

ERRORS=$(jq 'length' psalm.json 2>/dev/null || echo "0")
echo "errors=${ERRORS}" >> $GITHUB_OUTPUT
Expand Down Expand Up @@ -242,7 +249,7 @@ jobs:
id: fmt
run: |
set +e
OUTPUT=$(vendor/bin/pint --test --format=json 2>&1)
OUTPUT=$(vendor/bin/pint --test --format=json)
EXIT_CODE=$?
echo "$OUTPUT" > pint.json

Expand Down Expand Up @@ -292,7 +299,7 @@ jobs:
id: audit
run: |
set +e
composer audit --format=json > audit.json 2>&1
composer audit --format=json > audit.json
EXIT_CODE=$?

VULNS=$(jq '.advisories | to_entries | map(.value | length) | add // 0' audit.json 2>/dev/null || echo "0")
Expand Down
143 changes: 142 additions & 1 deletion src/Core/Service/ServiceDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,23 @@ class ServiceDiscovery
*/
protected array $registered = [];

/**
* The Composer ClassLoader instance.
*/
protected ?\Composer\Autoload\ClassLoader $loader = null;

/**
* Normalized PSR-4 prefixes map.
*
* @var array<string, array<string>>
*/
protected array $normalizedPrefixes = [];

/**
* Whether prefixes have been loaded.
*/
protected bool $prefixesLoaded = false;

/**
* Add a path to scan for service definitions.
*/
Expand Down Expand Up @@ -597,6 +614,35 @@ protected function performDiscovery(): array
*/
protected function scanPath(string $path, array &$services): void
{
$this->loadPrefixes();

// Normalize scan path
$realScanPath = realpath($path);
if ($realScanPath === false) {
$realScanPath = $path; // Fallback
}
if (DIRECTORY_SEPARATOR !== '/') {
$realScanPath = str_replace(DIRECTORY_SEPARATOR, '/', $realScanPath);
}
$realScanPath = rtrim($realScanPath, '/').'/';

// Filter prefixes relevant to this scan path
$relevantPrefixes = [];
foreach ($this->normalizedPrefixes as $namespace => $paths) {
foreach ($paths as $prefixPath) {
if (str_starts_with($realScanPath, $prefixPath)) {
$relevantPrefixes[$namespace][] = $prefixPath;

continue;
}
if (str_starts_with($prefixPath, $realScanPath)) {
$relevantPrefixes[$namespace][] = $prefixPath;

continue;
}
}
}

$files = File::allFiles($path);

foreach ($files as $file) {
Expand All @@ -614,7 +660,21 @@ protected function scanPath(string $path, array &$services): void
continue;
}

$class = $this->getClassFromFile($file->getPathname());
// Optimization: Resolve class from path
$realPath = $file->getRealPath();
if ($realPath === false) {
continue;
}
if (DIRECTORY_SEPARATOR !== '/') {
$realPath = str_replace(DIRECTORY_SEPARATOR, '/', $realPath);
}

$class = $this->resolveClass($realPath, $relevantPrefixes);

if ($class === null) {
$class = $this->getClassFromFile($file->getPathname());
}

if ($class === null) {
continue;
}
Expand All @@ -640,6 +700,87 @@ protected function scanPath(string $path, array &$services): void
}
}

/**
* Get the Composer ClassLoader instance.
*/
protected function getComposerLoader(): ?\Composer\Autoload\ClassLoader
{
if ($this->loader !== null) {
return $this->loader;
}

foreach (spl_autoload_functions() as $autoloader) {
if (is_array($autoloader) && $autoloader[0] instanceof \Composer\Autoload\ClassLoader) {
$this->loader = $autoloader[0];

return $this->loader;
}
}

return null;
}

/**
* Load and normalize Composer PSR-4 prefixes.
*/
protected function loadPrefixes(): void
{
if ($this->prefixesLoaded) {
return;
}

$loader = $this->getComposerLoader();
if ($loader === null) {
$this->prefixesLoaded = true;

return;
}

$prefixes = $loader->getPrefixesPsr4();

foreach ($prefixes as $namespace => $paths) {
foreach ($paths as $path) {
$realPath = realpath($path);
if ($realPath === false) {
continue;
}

if (DIRECTORY_SEPARATOR !== '/') {
$realPath = str_replace(DIRECTORY_SEPARATOR, '/', $realPath);
}

$this->normalizedPrefixes[$namespace][] = rtrim($realPath, '/').'/';
}
}

$this->prefixesLoaded = true;
}

/**
* Resolve class name from real file path using a subset of prefixes.
*
* @param string $realPath Normalized real path of the file
* @param array<string, array<string>> $prefixes Subset of prefixes to check
*/
protected function resolveClass(string $realPath, array $prefixes): ?string
{
foreach ($prefixes as $namespace => $paths) {
foreach ($paths as $path) {
if (str_starts_with($realPath, $path)) {
// Found matching prefix
$relativePath = substr($realPath, strlen($path));

// Convert path to namespace
$relativeClass = str_replace(['/', '.php'], ['\\', ''], $relativePath);

return $namespace.$relativeClass;
}
}
}

return null;
}

/**
* Extract class name from a PHP file.
*/
Expand Down
Loading