Skip to content
Open
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;

Comment on lines +201 to +217
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Use UK spelling for new identifiers and comments.

Please switch to β€œnormalised/normalise/optimisation” (e.g., normalisedPrefixes, β€œNormalise scan path”) to comply.

✏️ Suggested rename
-     * Normalized PSR-4 prefixes map.
+     * Normalised PSR-4 prefixes map.
@@
-    protected array $normalizedPrefixes = [];
+    protected array $normalisedPrefixes = [];
@@
-        // Normalize scan path
+        // Normalise scan path
@@
-        foreach ($this->normalizedPrefixes as $namespace => $paths) {
+        foreach ($this->normalisedPrefixes as $namespace => $paths) {
@@
-            // Optimization: Resolve class from path
+            // Optimisation: Resolve class from path
@@
-     * Load and normalize Composer PSR-4 prefixes.
+     * Load and normalise Composer PSR-4 prefixes.
@@
-                $this->normalizedPrefixes[$namespace][] = rtrim($realPath, '/').'/';
+                $this->normalisedPrefixes[$namespace][] = rtrim($realPath, '/').'/';
As per coding guidelines, Use UK English spelling (colour, organisation, centre) never American spellings.
πŸ€– Prompt for AI Agents
In `@src/Core/Service/ServiceDiscovery.php` around lines 201 - 217, The properties
and comments use American English ("normalized" and "loaded") but the codebase
requires UK spelling; rename the property normalizedPrefixes to
normalisedPrefixes and update any references (e.g., accesses, assignments, type
annotations) accordingly, rename any related boolean like prefixesLoaded to
prefixesNormalisedLoaded or similar UK-spelled variant, and update docblocks and
inline comments (e.g., "Normalized PSR-4 prefixes map" -> "Normalised PSR-4
prefixes map", "Whether prefixes have been loaded" -> "Whether prefixes have
been normalised/loaded") so all identifiers and comment text use UK spelling
consistently (search for occurrences of normalizedPrefixes and prefixesLoaded
and update callers and tests).

/**
* 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;
}
Comment on lines +759 to +782
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

head -30 src/Core/Service/ServiceDiscovery.php

Repository: host-uk/core-php

Length of output: 825


🏁 Script executed:

sed -n '759,782p' src/Core/Service/ServiceDiscovery.php

Repository: host-uk/core-php

Length of output: 926


🏁 Script executed:

sed -n '730,790p' src/Core/Service/ServiceDiscovery.php

Repository: host-uk/core-php

Length of output: 1894


Use a suffix-only replacement for the .php file extension.

Using str_replace to remove .php will replace all occurrences in the path, not just the file extension. If a class path contains .php elsewhere (though unlikely with PSR-4), it could corrupt the namespace. Use preg_replace with a suffix-only pattern:

Safer extension removal
-                    $relativeClass = str_replace(['/', '.php'], ['\\', ''], $relativePath);
+                    $relativeClass = str_replace('/', '\\', $relativePath);
+                    $relativeClass = preg_replace('/\.php$/i', '', $relativeClass);
πŸ€– Prompt for AI Agents
In `@src/Core/Service/ServiceDiscovery.php` around lines 759 - 782, The
resolveClass method currently uses str_replace to remove ".php" which can remove
occurrences anywhere in the path; update resolveClass so that after computing
$relativePath you strip the ".php" suffix only (e.g. use a suffix-only removal
like preg_replace('/\.php$/', '', $relativePath) or check str_ends_with and
substr) before converting separators into namespace separators and assigning
$relativeClass, ensuring you still return $namespace.$relativeClass.


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