Skip to content
1 change: 1 addition & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
</include>
<exclude>
<file>src/bootstrap.php</file>
<file>src/TypePHP.php</file>
<directory>src/Internal/Cli</directory>
</exclude>
</source>
Expand Down
17 changes: 10 additions & 7 deletions src/Internal/Cli/ConfigInitCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,16 @@ private static function getTemplate(): string
| - 'warn' : Emits PHP E_USER_WARNING once per unique violation to your logs
| and allows execution to continue normally without crashing.
|
| 'report_file' : Sets the target JSON output path when 'report' mode is active.
| 'fail_on_report' : When true, terminates the process with exit code 1 at shutdown
| if the violation report is non-empty. Useful for CI gates.
| 'report_file' : Sets the target JSON output path when 'report' mode is active.
| 'fail_on_report' : When true, terminates the process with exit code 1 at shutdown
| if the violation report is non-empty. Useful for CI gates.
| 'fail_on_zero_files' : When true, terminates the process with exit code 1 if 0 application
| files were instrumented. Prevents CI passes when include patterns match nothing.
*/
'on_violation' => 'throw',
'report_file' => null,
'fail_on_report' => false,
'on_violation' => 'throw',
'report_file' => null,
'fail_on_report' => false,
'fail_on_zero_files' => false,

/*
|--------------------------------------------------------------------------
Expand Down Expand Up @@ -315,7 +318,7 @@ private static function getTemplate(): string
|--------------------------------------------------------------------------
| Globs or specific file paths that should be ignored by the type checker.
| You can exclude entire directories (e.g. 'vendor/**') or blacklist
| single legacy files inside included directories (e.g. 'src/Legacy/File.php').
| single legacy files inside included directories (e.g. 'src/Legacy/UnsafeFile.php').
*/
'exclude' => [
'vendor/**',
Expand Down
68 changes: 57 additions & 11 deletions src/Internal/Cli/ReportCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE
$reportDir = \dirname($reportFile);
$shardDir = $reportDir . '/.typephp-shards';

// Consolidate any parallel shards if present
if (is_dir($shardDir)) {
ViolationCollector::exportReport($reportFile);
}
Expand All @@ -40,19 +39,62 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE
return 0;
}

/** @var array{version?: string, generated_at?: string, summary?: array{total_violations?: int, files_affected?: int}, violations?: list<array<string, mixed>>}|null $data */
/** @var array{
* version?: string,
* generated_at?: string,
* context?: array{project_root?: string, config_file?: string, files_instrumented?: int},
* summary?: array{total_violations?: int, files_affected?: int, files_instrumented?: int, warning?: string},
* violations?: list<array<string, mixed>>
* }|null $data */
$data = json_decode($rawContent, true);

if (! \is_array($data) || ! isset($data['violations']) || ! \is_array($data['violations']) || $data['violations'] === []) {
fwrite($outputStream, ' ' . $c('✓', 'green') . " No violations recorded in report.\n\n");
if (! \is_array($data)) {
fwrite($outputStream, ' ' . $c('✗', 'red') . " Invalid or corrupted report JSON file.\n\n");

return 0;
return 1;
}

$violations = $data['violations'];
$violations = (isset($data['violations']) && \is_array($data['violations'])) ? $data['violations'] : [];
$totalCount = \count($violations);
$filesInstrumented = $data['summary']['files_instrumented'] ?? ($data['context']['files_instrumented'] ?? null);
$configSource = $data['context']['config_file'] ?? null;
$projectRoot = $data['context']['project_root'] ?? null;
$relativeReportPath = ViolationCollector::normalizeRelativePath($reportFile);

if ($totalCount === 0) {
if ($filesInstrumented === 0) {
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Total Violations: ' . $c('0', 'bold') . "\n");
fwrite($outputStream, ' ' . $c('•', 'red') . ' Files Instrumented: ' . $c('0 (⚠️ No files matched configuration!)', 'bold') . "\n");
if ($configSource !== null) {
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Config Source: ' . $c($configSource, 'gray') . "\n");
}
if ($projectRoot !== null) {
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Project Root: ' . $c($projectRoot, 'gray') . "\n");
}
fwrite($outputStream, "\n " . $c('✗', 'red') . " 0 violations over 0 instrumented files. Check your 'include'/'exclude' paths in typephp.php.\n\n");

/** @var array<string, list<array<string, mixed>>> $grouped */
return 1;
}

if ($filesInstrumented !== null && $filesInstrumented > 0) {
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Total Violations: ' . $c('0', 'bold') . "\n");
fwrite($outputStream, ' ' . $c('•', 'green') . ' Files Instrumented: ' . $c((string) $filesInstrumented, 'bold') . "\n");
if ($configSource !== null) {
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Config Source: ' . $c($configSource, 'gray') . "\n");
}
fwrite($outputStream, "\n " . $c('✓', 'green') . " Clean run: No type violations found across {$filesInstrumented} instrumented file(s).\n\n");

return 0;
}

fwrite($outputStream, ' ' . $c('✓', 'green') . " No violations recorded in report.\n\n");

return 0;
}

/**
* @var array<string, list<array<string, mixed>>>
*/
$grouped = [];
$filesAffectedMap = [];

Expand All @@ -66,11 +108,15 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE
? $data['summary']['files_affected']
: \count($filesAffectedMap);

$relativeReportPath = ViolationCollector::normalizeRelativePath($reportFile);

fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Total Violations: ' . $c((string) $totalCount, 'bold') . "\n");
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Files Affected: ' . $c((string) $filesAffected, 'bold') . "\n");
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Report Source: ' . $c($relativeReportPath, 'gray') . "\n\n");
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Files Affected: ' . $c((string) $filesAffected, 'bold') . "\n");
if ($filesInstrumented !== null) {
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Files Instrumented: ' . $c((string) $filesInstrumented, 'bold') . "\n");
}
if ($configSource !== null) {
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Config Source: ' . $c($configSource, 'gray') . "\n");
}
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Report File: ' . $c($relativeReportPath, 'gray') . "\n\n");

foreach ($grouped as $file => $items) {
fwrite($outputStream, ' ' . $c($file, 'bold') . "\n");
Expand Down
4 changes: 4 additions & 0 deletions src/Internal/Docblock/DocblockNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ public static function normalize(string $doc): string
$doc = preg_replace('/(\.\.\.\s*)(?:list|array)<([^>]+)>/', '$1<$2>', $doc) ?? $doc;
}

if (str_contains($doc, '$')) {
$doc = preg_replace('/([ \t]*\*[ \t]*(?:\|[^\r\n]+|\@[a-zA-Z\-]+[^\r\n]+))[ \t]*\r?\n[ \t]*\*[ \t]*(\$[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\b/', '$1 $2', $doc) ?? $doc;
}

if (! str_contains($doc, '{')) {
return $doc;
}
Expand Down
22 changes: 21 additions & 1 deletion src/Internal/Docblock/DocblockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -1066,9 +1066,11 @@ private static function parseFunction(\ReflectionFunction $ref): array
$doc = $stubDoc ?? $ref->getDocComment();

$baseParams = $ref->getParameters();
$baseParamNames = [];
$baseParamVariadic = [];
$baseParamObjects = [];
foreach ($baseParams as $p) {
foreach ($baseParams as $idx => $p) {
$baseParamNames[$idx] = $p->getName();
$baseParamVariadic[$p->getName()] = $p->isVariadic();
$baseParamObjects[$p->getName()] = $p;
if (self::hasSensitiveAttribute($p)) {
Expand Down Expand Up @@ -1109,7 +1111,13 @@ private static function parseFunction(\ReflectionFunction $ref): array
}
DocblockExtractor::extractAliases($phpDocNode, $aliases, $ref);

$paramIndex = 0;
foreach (DocblockExtractor::getParamTags($phpDocNode) as $paramName => $paramTag) {
if ($paramName === '' && isset($baseParamNames[$paramIndex])) {
$paramName = $baseParamNames[$paramIndex];
}
$paramIndex++;

$type = $paramTag->type;
$pObj = $baseParamObjects[$paramName] ?? null;

Expand Down Expand Up @@ -1385,6 +1393,7 @@ private static function parseMethodHierarchyDocs(

$paramTags = DocblockExtractor::getParamTags($phpDocNode);

$paramIndex = 0;
foreach ($paramTags as $paramName => $paramTag) {
$targetParamName = self::resolveTargetParamName(
$paramName,
Expand All @@ -1394,6 +1403,11 @@ private static function parseMethodHierarchyDocs(
$isConstructor
);

if ($targetParamName === null && $paramName === '' && isset($baseParamNames[$paramIndex])) {
$targetParamName = $baseParamNames[$paramIndex];
}
$paramIndex++;

if ($targetParamName !== null && ! isset($types[$targetParamName])) {
$type = $paramTag->type;
$pObj = $baseParamObjects[$targetParamName] ?? null;
Expand Down Expand Up @@ -1421,6 +1435,7 @@ private static function parseMethodHierarchyDocs(
}

$paramOutTags = DocblockExtractor::getParamOutTags($phpDocNode);
$paramOutIndex = 0;
foreach ($paramOutTags as $paramName => $paramOutTag) {
$targetParamName = self::resolveTargetParamName(
$paramName,
Expand All @@ -1430,6 +1445,11 @@ private static function parseMethodHierarchyDocs(
$isConstructor
);

if ($targetParamName === null && $paramName === '' && isset($baseParamNames[$paramOutIndex])) {
$targetParamName = $baseParamNames[$paramOutIndex];
}
$paramOutIndex++;

if ($targetParamName !== null && ! isset($paramOuts[$targetParamName])) {
$type = $paramOutTag->type;
$substitutedType = self::substituteAliases($type, $aliases);
Expand Down
3 changes: 1 addition & 2 deletions src/Internal/Io/CacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,7 @@ public static function warmUp(?callable $progressCallback = null): array
return ['total' => 0, 'cached' => 0, 'skipped' => 0];
}

$cwd = getcwd();
$baseDir = $cwd !== false ? rtrim(str_replace('\\', '/', $cwd), '/') : '';
$baseDir = Config::getProjectRoot();

$files = self::findFilesToWarm($baseDir);
$total = \count($files);
Expand Down
88 changes: 75 additions & 13 deletions src/Internal/Io/StreamWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,18 @@ final class StreamWrapper implements StreamWrapperInterface
*/
private static array $verifiedCachedFiles = [];

/**
* In-memory registry of unique application files instrumented during report mode.
*
* @var array<string, bool>
*/
private static array $instrumentedFiles = [];

/**
* Whether telemetry tracking is explicitly forced (e.g. in tests).
*/
private static bool $forceTelemetry = false;

/**
* Native PHP functions that read raw source code for viewing, highlighting, or tokenizing.
*
Expand Down Expand Up @@ -149,7 +161,7 @@ public static function getCloningVisitor(): CloningVisitor
}

/**
* Resets all internal in-memory caches and path matchers.
* Resets all internal in-memory caches, path matchers, and instrumented file counters.
*/
public static function reset(): void
{
Expand All @@ -159,9 +171,59 @@ public static function reset(): void
self::$bypassedPathCache = [];
self::$realpathCache = [];
self::$verifiedCachedFiles = [];
self::$instrumentedFiles = [];
self::$forceTelemetry = false;
PathMatcher::reset();
}

/**
* Enables or disables explicit telemetry tracking.
*/
public static function enableTelemetry(bool $enable = true): void
{
self::$forceTelemetry = $enable;
}

/**
* Determines whether instrumented file paths should be recorded in memory.
* Only active in report mode (or if explicitly forced in tests) to avoid overhead.
*/
public static function shouldTrackTelemetry(): bool
{
return self::$forceTelemetry || Config::isReportMode();
}

/**
* Returns the total number of unique application files instrumented in this process.
*/
public static function getInstrumentedCount(): int
{
return \count(self::$instrumentedFiles);
}

/**
* Returns the list of all application file paths instrumented in this process.
*
* @return list<string>
*/
public static function getInstrumentedFiles(): array
{
return array_keys(self::$instrumentedFiles);
}

/**
* Manually records an instrumented file path (e.g. when consolidating worker shards).
*/
public static function recordInstrumentedFile(string $filePath): void
{
if ($filePath === '') {
return;
}

$normalized = str_replace('\\', '/', $filePath);
self::$instrumentedFiles[$normalized] = true;
}

/**
* Registers the stream wrapper for the native 'file://' protocol.
*
Expand Down Expand Up @@ -354,13 +416,11 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
return $this->openDirectHandle($path, $mode, $options);
}

// Fast-path: checks .php directly on $path first, avoiding strtolower() on 99.99% of classes
$isPhp = str_ends_with($path, '.php') || str_ends_with(strtolower($path), '.php');
if (! $isPhp) {
return $this->openDirectHandle($path, $mode, $options);
}

// Fast-path: check template and view caches directly without strtolower()
if (
str_ends_with($path, '.view.php')
|| str_ends_with($path, '.blade.php')
Expand Down Expand Up @@ -411,13 +471,21 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
$this->eof = false;
self::register();

if ($this->handle === null && ! self::isPharCall()) {
trigger_error("include({$target}): Failed to open stream: No such file or directory", E_USER_WARNING);
}

return $this->handle !== null;
}

$success = self::$cacheEnabled
? $this->openCachedStream($resolvedPath, $mode)
: $this->openMemoryStream($resolvedPath);

if ($success && self::shouldTrackTelemetry()) {
self::$instrumentedFiles[$resolvedPath] = true;
}

self::register();

return $success;
Expand Down Expand Up @@ -473,8 +541,9 @@ private function openDirectHandle(string $targetFile, string $mode, int $options
$this->eof = false;
self::register();

if ($this->handle === null && ! $isInclude && ! self::isPharCall()) {
trigger_error("fopen({$targetFile}): Failed to open stream: No such file or directory", E_USER_WARNING);
if ($this->handle === null && ! self::isPharCall()) {
$action = $isInclude ? 'include' : 'fopen';
trigger_error("{$action}({$targetFile}): Failed to open stream: No such file or directory", E_USER_WARNING);
}

return $this->handle !== null;
Expand Down Expand Up @@ -613,12 +682,7 @@ public function stream_close(): void
}

/**
* Resolves file status with dual-tier memoization caching:
* 1. Differentiates between stat() and lstat() (STREAM_URL_STAT_LINK).
* 2. Memoizes PHP files, vendor files, and static package source directories.
*
* NOTE: stat() and lstat() DO trigger recursive url_stat calls when the
* file:// wrapper is registered. The unregister/register cycle is REQUIRED.
* Resolves file status with dual-tier memoization caching.
*
* @return array<int|string, int>|false
*/
Expand All @@ -645,15 +709,13 @@ public function url_stat(string $path, int $flags): array|false
$isPhp = str_ends_with($normalized, '.php') || str_ends_with(strtolower($normalized), '.php');
$isStatic = PathMatcher::isStaticSourcePath($normalized);

// Caches all existing PHP files, vendor files, and static files
if ($isPhp || PathMatcher::isVendorPath($normalized) || $isStatic) {
self::$statCache[$cacheKey] = $result;
}

return $result;
}

// Only cache negative misses for immutable vendor packages to prevent poisoning application paths
if (PathMatcher::isVendorPath($normalized)) {
self::$staticNegativeStatCache[$normalized] = true;
}
Expand Down
Loading
Loading