diff --git a/phpunit.xml b/phpunit.xml index 7cc5ad27..2d6c9f52 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,6 +14,7 @@ src/bootstrap.php + src/TypePHP.php src/Internal/Cli diff --git a/src/Internal/Cli/ConfigInitCommand.php b/src/Internal/Cli/ConfigInitCommand.php index 1d02f102..9d00adc1 100644 --- a/src/Internal/Cli/ConfigInitCommand.php +++ b/src/Internal/Cli/ConfigInitCommand.php @@ -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, /* |-------------------------------------------------------------------------- @@ -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/**', diff --git a/src/Internal/Cli/ReportCommand.php b/src/Internal/Cli/ReportCommand.php index e11ef30f..f8f2cf41 100644 --- a/src/Internal/Cli/ReportCommand.php +++ b/src/Internal/Cli/ReportCommand.php @@ -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); } @@ -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>}|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> + * }|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>> $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>> + */ $grouped = []; $filesAffectedMap = []; @@ -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"); diff --git a/src/Internal/Docblock/DocblockNormalizer.php b/src/Internal/Docblock/DocblockNormalizer.php index 0ce089d5..9ad579b8 100644 --- a/src/Internal/Docblock/DocblockNormalizer.php +++ b/src/Internal/Docblock/DocblockNormalizer.php @@ -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; } diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 17175ab0..f3cbc65f 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -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)) { @@ -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; @@ -1385,6 +1393,7 @@ private static function parseMethodHierarchyDocs( $paramTags = DocblockExtractor::getParamTags($phpDocNode); + $paramIndex = 0; foreach ($paramTags as $paramName => $paramTag) { $targetParamName = self::resolveTargetParamName( $paramName, @@ -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; @@ -1421,6 +1435,7 @@ private static function parseMethodHierarchyDocs( } $paramOutTags = DocblockExtractor::getParamOutTags($phpDocNode); + $paramOutIndex = 0; foreach ($paramOutTags as $paramName => $paramOutTag) { $targetParamName = self::resolveTargetParamName( $paramName, @@ -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); diff --git a/src/Internal/Io/CacheManager.php b/src/Internal/Io/CacheManager.php index 4fc0b5be..ae7717be 100644 --- a/src/Internal/Io/CacheManager.php +++ b/src/Internal/Io/CacheManager.php @@ -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); diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index a9c2712b..757648c3 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -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 + */ + 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. * @@ -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 { @@ -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 + */ + 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. * @@ -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') @@ -411,6 +471,10 @@ 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; } @@ -418,6 +482,10 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ ? $this->openCachedStream($resolvedPath, $mode) : $this->openMemoryStream($resolvedPath); + if ($success && self::shouldTrackTelemetry()) { + self::$instrumentedFiles[$resolvedPath] = true; + } + self::register(); return $success; @@ -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; @@ -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|false */ @@ -645,7 +709,6 @@ 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; } @@ -653,7 +716,6 @@ public function url_stat(string $path, int $flags): array|false return $result; } - // Only cache negative misses for immutable vendor packages to prevent poisoning application paths if (PathMatcher::isVendorPath($normalized)) { self::$staticNegativeStatCache[$normalized] = true; } diff --git a/src/Internal/Reporting/ViolationCollector.php b/src/Internal/Reporting/ViolationCollector.php index 3b2429ab..007f9084 100644 --- a/src/Internal/Reporting/ViolationCollector.php +++ b/src/Internal/Reporting/ViolationCollector.php @@ -9,6 +9,7 @@ use Throwable; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Docblock\DocblockParser; +use TypePHP\Internal\Io\StreamWrapper; use TypePHP\Internal\Resolver\HierarchyResolver; use TypePHP\Internal\Util\Config; @@ -48,6 +49,7 @@ public static function reset(): void self::$violations = []; self::$warnedHashes = []; self::$propertyLineCache = []; + self::$shutdownRegistered = false; } /** @@ -144,10 +146,7 @@ private static function recordViolation(string $hash, ViolationRecord $record): self::$violations[$hash] = $record; } - if (! self::$shutdownRegistered) { - self::registerShutdownHandler(); - self::$shutdownRegistered = true; - } + self::registerShutdownHandler(); } /** @@ -192,18 +191,19 @@ public static function normalizeRelativePath(string $path): string /** * Registers shutdown hook to write worker shards or master report and optionally exit with failure. */ - private static function registerShutdownHandler(): void + public static function registerShutdownHandler(): void { + if (self::$shutdownRegistered) { + return; + } + + self::$shutdownRegistered = true; + register_shutdown_function(static function (): void { if (Config::getOnViolation() !== 'report') { return; } - $violationCount = \count(self::$violations); - if ($violationCount === 0) { - return; - } - $reportFile = Config::getReportFile(); $testToken = getenv('TEST_TOKEN'); $pestWorkerId = getenv('PEST_PARALLEL_WORKER_ID'); @@ -221,10 +221,19 @@ private static function registerShutdownHandler(): void } } - if (Config::isFailOnReportEnabled()) { + $violationCount = \count(self::$violations); + $instrumentedCount = StreamWrapper::getInstrumentedCount(); + $shouldFailOnZero = Config::isFailOnZeroFilesEnabled() || Config::isFailOnReportEnabled(); + + if (Config::isFailOnReportEnabled() && $violationCount > 0) { @fwrite(STDERR, "\n[TypePHP] {$violationCount} contract violation(s) recorded in report. Exiting with status 1.\n"); exit(1); } + + if ($instrumentedCount === 0 && $shouldFailOnZero) { + @fwrite(STDERR, "\n[TypePHP Error] 0 application files were instrumented matching the configured include patterns.\nExiting with status 1 to prevent silent pass.\n"); + exit(1); + } }); } @@ -233,7 +242,7 @@ private static function registerShutdownHandler(): void */ public static function writeWorkerShard(string $reportFile): void { - if (self::$violations === []) { + if (self::$violations === [] && StreamWrapper::getInstrumentedCount() === 0) { return; } @@ -253,7 +262,12 @@ public static function writeWorkerShard(string $reportFile): void $safeToken = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $workerId); $shardFile = $shardDir . '/shard_' . $safeToken . '_' . bin2hex(random_bytes(4)) . '.json'; - $content = json_encode(array_values(self::$violations), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + $payload = [ + 'violations' => array_values(self::$violations), + 'instrumented_files' => StreamWrapper::getInstrumentedFiles(), + ]; + $content = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($content !== false) { @file_put_contents($shardFile, $content); @@ -327,17 +341,32 @@ private static function consolidateShards(array $baseViolations, string $shardDi */ private static function mergeShardContent(array $allViolations, string $raw): array { - /** @var list>|null $decoded */ + /** @var array|list>|null $decoded */ $decoded = json_decode($raw, true); if (! \is_array($decoded)) { return $allViolations; } - foreach ($decoded as $item) { - if (! \is_array($item)) { - continue; + /** @var list> $violationsData */ + $violationsData = []; + if (isset($decoded['violations']) && \is_array($decoded['violations'])) { + /** @var list> $vList */ + $vList = $decoded['violations']; + $violationsData = $vList; + if (isset($decoded['instrumented_files']) && \is_array($decoded['instrumented_files'])) { + foreach ($decoded['instrumented_files'] as $iFile) { + if (\is_string($iFile)) { + StreamWrapper::recordInstrumentedFile($iFile); + } + } } + } elseif (array_is_list($decoded)) { + /** @var list> $dList */ + $dList = $decoded; + $violationsData = $dList; + } + foreach ($violationsData as $item) { $record = ViolationRecord::fromArray($item); if ($record === null) { continue; @@ -355,7 +384,13 @@ private static function mergeShardContent(array $allViolations, string $raw): ar /** * @param array $violations * - * @return array{version: string, generated_at: string, summary: array{total_violations: int, files_affected: int}, violations: list} + * @return 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 + * } */ private static function buildReportDocument(array $violations): array { @@ -365,13 +400,29 @@ private static function buildReportDocument(array $violations): array $filesAffected[$v->file] = true; } + $instrumentedCount = StreamWrapper::getInstrumentedCount(); + $configFile = Config::getResolvedConfigFile(); + $projectRoot = Config::getProjectRoot(); + + $summary = [ + 'total_violations' => \count($violationsList), + 'files_affected' => \count($filesAffected), + 'files_instrumented' => $instrumentedCount, + ]; + + if ($instrumentedCount === 0) { + $summary['warning'] = 'No application files were instrumented matching the configured include/exclude patterns.'; + } + return [ 'version' => '1.0', 'generated_at' => gmdate('c'), - 'summary' => [ - 'total_violations' => \count($violationsList), - 'files_affected' => \count($filesAffected), + 'context' => [ + 'project_root' => $projectRoot, + 'config_file' => $configFile !== null ? self::normalizeRelativePath($configFile) : 'defaults', + 'files_instrumented' => $instrumentedCount, ], + 'summary' => $summary, 'violations' => $violationsList, ]; } diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index 4cac4abc..d92bfd1b 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -39,6 +39,11 @@ final class Config */ private static ?string $projectRoot = null; + /** + * Absolute path of the resolved typephp.php config file, or null if defaults were used. + */ + private static ?string $resolvedConfigFile = null; + private static bool $enabled = true; private static bool $autoBoot = true; @@ -49,6 +54,8 @@ final class Config private static bool $failOnReport = false; + private static bool $failOnZeroFiles = false; + private static bool $redactValues = false; private static bool $params = true; @@ -207,6 +214,20 @@ public static function isFailOnReportEnabled(): bool return self::$failOnReport; } + public static function isFailOnZeroFilesEnabled(): bool + { + $envFailZero = self::getEnvValue('TYPEPHP_FAIL_ON_ZERO_FILES'); + if ($envFailZero !== null && trim($envFailZero) !== '') { + return filter_var($envFailZero, FILTER_VALIDATE_BOOLEAN); + } + + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$failOnZeroFiles; + } + public static function isRedactValuesEnabled(): bool { $envRedact = self::getEnvValue('TYPEPHP_REDACT_VALUES'); @@ -240,6 +261,15 @@ public static function getIgnoreTraceDepth(): int return self::$ignoreTraceDepth; } + public static function getResolvedConfigFile(): ?string + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$resolvedConfigFile; + } + public static function isInlinePropertiesEnabled(): bool { if (self::$cachedConfig === null) { @@ -443,8 +473,8 @@ public static function getArrayValidationStrategy(): string } /** - * Locates the project root directory by searching upwards for vendor/autoload.php or composer.json. - * Caches the result in memory so the search happens exactly once. + * Locates the project root directory by searching upwards for typephp.php, composer.json, or vendor/autoload.php. + * Prioritizes explicit typephp.php in the current working directory. */ public static function getProjectRoot(?string $startingDir = null): string { @@ -452,6 +482,16 @@ public static function getProjectRoot(?string $startingDir = null): string return self::$projectRoot; } + $cwd = getcwd(); + $normCwd = null; + if ($startingDir === null && $cwd !== false) { + $realCwd = realpath($cwd); + $normCwd = rtrim(str_replace('\\', '/', $realCwd !== false ? $realCwd : $cwd), '/'); + if (file_exists($normCwd . '/typephp.php')) { + return self::$projectRoot = $normCwd; + } + } + if ($startingDir === null && class_exists(\Composer\InstalledVersions::class)) { try { $rootPackage = \Composer\InstalledVersions::getRootPackage(); @@ -459,7 +499,8 @@ public static function getProjectRoot(?string $startingDir = null): string if (\is_string($installPath) && $installPath !== '') { $realPath = realpath($installPath); $normPath = rtrim(str_replace('\\', '/', $realPath !== false ? $realPath : $installPath), '/'); - if (file_exists($normPath . '/composer.json') || file_exists($normPath . '/typephp.php')) { + + if (file_exists($normPath . '/typephp.php') || file_exists($normPath . '/composer.json')) { return self::$projectRoot = $normPath; } } @@ -467,12 +508,10 @@ public static function getProjectRoot(?string $startingDir = null): string } } - $cwd = getcwd(); - if ($startingDir === null && $cwd !== false) { - $realCwd = realpath($cwd); - $checkCwd = rtrim(str_replace('\\', '/', $realCwd !== false ? $realCwd : $cwd), '/'); + if ($startingDir === null && $normCwd !== null) { + $checkCwd = $normCwd; for ($i = 0; $i < 5; $i++) { - if (file_exists($checkCwd . '/composer.json') || file_exists($checkCwd . '/typephp.php')) { + if (file_exists($checkCwd . '/typephp.php') || file_exists($checkCwd . '/composer.json')) { return self::$projectRoot = $checkCwd; } $parent = \dirname($checkCwd); @@ -491,7 +530,7 @@ public static function getProjectRoot(?string $startingDir = null): string $candidate = substr($dir, 0, $vendorPos); $realCandidate = realpath($candidate); $normCandidate = rtrim(str_replace('\\', '/', $realCandidate !== false ? $realCandidate : $candidate), '/'); - if (file_exists($normCandidate . '/vendor/autoload.php') || file_exists($normCandidate . '/composer.json')) { + if (file_exists($normCandidate . '/typephp.php') || file_exists($normCandidate . '/vendor/autoload.php') || file_exists($normCandidate . '/composer.json')) { return self::$projectRoot = $normCandidate; } } @@ -500,7 +539,7 @@ public static function getProjectRoot(?string $startingDir = null): string $firstCandidate = null; for ($i = 0; $i < 15; $i++) { - if (file_exists($dir . '/composer.json') || file_exists($dir . '/typephp.php') || file_exists($dir . '/vendor/autoload.php')) { + if (file_exists($dir . '/typephp.php') || file_exists($dir . '/composer.json') || file_exists($dir . '/vendor/autoload.php')) { $realDir = realpath($dir); $normFound = rtrim(str_replace('\\', '/', $realDir !== false ? $realDir : $dir), '/'); @@ -522,16 +561,18 @@ public static function getProjectRoot(?string $startingDir = null): string return self::$projectRoot = $firstCandidate; } - if ($cwd !== false) { - $realCwd = realpath($cwd); - $fallback = $realCwd !== false ? $realCwd : $cwd; - } else { - $fallback = '.'; + if ($normCwd !== null) { + return self::$projectRoot = $normCwd; } - return self::$projectRoot = rtrim(str_replace('\\', '/', $fallback), '/'); + return self::$projectRoot = '.'; } + /** + * Loads and caches global configuration from 'typephp.php', 'composer.json' extra, extensions, and base defaults. + * + * @return array + */ /** * Loads and caches global configuration from 'typephp.php', 'composer.json' extra, extensions, and base defaults. * @@ -549,6 +590,7 @@ public static function get(): array 'on_violation' => 'throw', 'report_file' => null, 'fail_on_report' => false, + 'fail_on_zero_files' => false, 'redact_values' => false, 'params' => true, 'returns' => true, @@ -585,19 +627,38 @@ public static function get(): array $projectRoot = self::getProjectRoot(); $configFile = $projectRoot . '/typephp.php'; $userConfig = []; + self::$resolvedConfigFile = null; if (file_exists($configFile)) { + self::$resolvedConfigFile = $configFile; $loadedConfig = require $configFile; if (\is_array($loadedConfig)) { /** @var array $userConfig */ $userConfig = $loadedConfig; } + } else { + $cwd = getcwd(); + if ($cwd !== false) { + $realCwd = realpath($cwd); + $normCwd = rtrim(str_replace('\\', '/', $realCwd !== false ? $realCwd : $cwd), '/'); + $cwdConfig = $normCwd . '/typephp.php'; + if ($normCwd !== $projectRoot && str_starts_with($projectRoot, $normCwd . '/') && file_exists($cwdConfig)) { + @trigger_error( + \sprintf( + "[TypePHP Warning] Configuration file found at '%s', but project root resolved to '%s' (where no typephp.php exists). Using default configuration.", + $cwdConfig, + $projectRoot + ), + E_USER_WARNING + ); + } + } } $composerJsonFile = $projectRoot . '/composer.json'; if (file_exists($composerJsonFile)) { $content = @file_get_contents($composerJsonFile); - if ($content !== false && (str_contains($content, 'typephp') || str_contains($content, 'auto-boot') || str_contains($content, 'on-violation') || str_contains($content, 'fail-on-report') || str_contains($content, 'redact-values'))) { + if ($content !== false && (str_contains($content, 'typephp') || str_contains($content, 'auto-boot') || str_contains($content, 'on-violation') || str_contains($content, 'fail-on-report') || str_contains($content, 'fail-on-zero-files') || str_contains($content, 'redact-values'))) { $data = json_decode($content, true); if ( \is_array($data) @@ -618,6 +679,9 @@ public static function get(): array if (! isset($userConfig['fail_on_report']) && isset($data['extra']['typephp']['fail-on-report'])) { $userConfig['fail_on_report'] = filter_var($data['extra']['typephp']['fail-on-report'], FILTER_VALIDATE_BOOLEAN); } + if (! isset($userConfig['fail_on_zero_files']) && isset($data['extra']['typephp']['fail-on-zero-files'])) { + $userConfig['fail_on_zero_files'] = filter_var($data['extra']['typephp']['fail-on-zero-files'], FILTER_VALIDATE_BOOLEAN); + } if (! isset($userConfig['redact_values']) && isset($data['extra']['typephp']['redact-values'])) { $userConfig['redact_values'] = filter_var($data['extra']['typephp']['redact-values'], FILTER_VALIDATE_BOOLEAN); } @@ -736,11 +800,13 @@ public static function reset(): void { self::$cachedConfig = null; self::$projectRoot = null; + self::$resolvedConfigFile = null; self::$enabled = true; self::$autoBoot = true; self::$onViolation = 'throw'; self::$reportFile = null; self::$failOnReport = false; + self::$failOnZeroFiles = false; self::$redactValues = false; self::$params = true; self::$returns = true; @@ -802,6 +868,7 @@ private static function syncFlags(array $config): void : null; self::$failOnReport = (bool) ($config['fail_on_report'] ?? false); + self::$failOnZeroFiles = (bool) ($config['fail_on_zero_files'] ?? false); self::$redactValues = (bool) ($config['redact_values'] ?? false); self::$params = (bool) ($config['params'] ?? true); diff --git a/src/Internal/Validator/IntersectionValidator.php b/src/Internal/Validator/IntersectionValidator.php index def0a043..30873690 100644 --- a/src/Internal/Validator/IntersectionValidator.php +++ b/src/Internal/Validator/IntersectionValidator.php @@ -45,6 +45,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali str_starts_with($msg, $context . '->') || str_starts_with($msg, $context . ' is missing required') || str_starts_with($msg, $context . ' contains unsealed') || + str_starts_with($msg, $context . ' contains unexpected') || str_starts_with($msg, $context . ' property') || str_starts_with($msg, $context . ' key') ) { diff --git a/src/Internal/Validator/UnionValidator.php b/src/Internal/Validator/UnionValidator.php index 24ad17ff..918bb1c3 100644 --- a/src/Internal/Validator/UnionValidator.php +++ b/src/Internal/Validator/UnionValidator.php @@ -90,6 +90,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali str_starts_with($msg, $context . '->') || str_starts_with($msg, $context . ' is missing required') || str_starts_with($msg, $context . ' contains unsealed') || + str_starts_with($msg, $context . ' contains unexpected') || str_starts_with($msg, $context . ' property') || str_starts_with($msg, $context . ' key') || str_starts_with($msg, $context . ' value') || diff --git a/src/TypePHP.php b/src/TypePHP.php index 3a885d3f..907f9b67 100644 --- a/src/TypePHP.php +++ b/src/TypePHP.php @@ -18,6 +18,10 @@ final class TypePHP public static function boot(): void { StreamWrapper::register(Config::get()); + + if (Config::getOnViolation() === 'report') { + ViolationCollector::registerShutdownHandler(); + } } /** diff --git a/tests/Feature/ViolationReportingTest.php b/tests/Feature/ViolationReportingTest.php index eebb203e..5090434f 100644 --- a/tests/Feature/ViolationReportingTest.php +++ b/tests/Feature/ViolationReportingTest.php @@ -493,7 +493,6 @@ function testReportingSensitiveParamFunc( ->and(file_exists($reportFilePath))->toBeTrue() ; - /** @var array{version: string, generated_at: string, summary: array{total_violations: int, files_affected: int}, violations: list>} $doc */ $doc = json_decode((string) file_get_contents($reportFilePath), true); expect($doc)->toHaveKey('version') @@ -524,7 +523,6 @@ function testReportingSensitiveParamFunc( testReportingParamFunc(-10, 'ValidRecord'); - // Intentionally write broken JSON into shard folder file_put_contents($shardDir . '/shard_broken_123.json', '{{{ corrupted invalid json'); $exported = TypePHP::exportReport($reportFilePath); @@ -533,7 +531,6 @@ function testReportingSensitiveParamFunc( ->and(file_exists($reportFilePath))->toBeTrue() ; - /** @var array{version: string, summary: array{total_violations: int}, violations: list>} $doc */ $doc = json_decode((string) file_get_contents($reportFilePath), true); expect($doc['summary']['total_violations'])->toBe(1); diff --git a/tests/Feature/ZeroFilesDetectionTest.php b/tests/Feature/ZeroFilesDetectionTest.php new file mode 100644 index 00000000..0e5d4f2b --- /dev/null +++ b/tests/Feature/ZeroFilesDetectionTest.php @@ -0,0 +1,337 @@ + 'report', + 'include' => ['custom_modules/**'], +]; +PHP; + file_put_contents($tempRepo . '/typephp.php', $rootConfigContent); + + $subComposerJson = json_encode([ + 'name' => 'acme/sub-app', + 'config' => ['vendor-dir' => 'libs'], + ]); + file_put_contents($tempRepo . '/app/composer.json', $subComposerJson); + + $oldCwd = getcwd(); + chdir($tempRepo); + + try { + Config::reset(); + + $projectRoot = Config::getProjectRoot(); + $normalizedRepo = rtrim(str_replace('\\', '/', (string) realpath($tempRepo)), '/'); + + expect($projectRoot)->toBe($normalizedRepo); + + $config = Config::get(); + expect(Config::getResolvedConfigFile())->toBe($normalizedRepo . '/typephp.php') + ->and($config['include'])->toBe(['custom_modules/**']) + ->and(Config::getOnViolation())->toBe('report') + ; + } finally { + chdir((string) $oldCwd); + @unlink($tempRepo . '/typephp.php'); + @unlink($tempRepo . '/app/composer.json'); + @rmdir($tempRepo . '/app/libs'); + @rmdir($tempRepo . '/app'); + @rmdir($tempRepo); + Config::reset(); + } + }); + + test('emits E_USER_WARNING when typephp.php exists in cwd but projectRoot resolved to a different directory without config', function () { + $tempRepo = sys_get_temp_dir() . '/typephp_warn_cwd_' . uniqid(); + mkdir($tempRepo . '/subproject', 0777, true); + + file_put_contents($tempRepo . '/typephp.php', " true];"); + + $oldCwd = getcwd(); + chdir($tempRepo); + + $warningEmitted = false; + $warningMessage = ''; + set_error_handler(function (int $errno, string $errstr) use (&$warningEmitted, &$warningMessage): bool { + if ($errno === E_USER_WARNING && str_contains($errstr, '[TypePHP Warning]')) { + $warningEmitted = true; + $warningMessage = $errstr; + + return true; + } + + return false; + }); + + try { + Config::reset(); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('projectRoot'); + $normSubproject = rtrim(str_replace('\\', '/', (string) realpath($tempRepo . '/subproject')), '/'); + $prop->setValue(null, $normSubproject); + + Config::get(); + + expect($warningEmitted)->toBeTrue() + ->and($warningMessage)->toContain('typephp.php') + ->and($warningMessage)->toContain($normSubproject) + ; + } finally { + restore_error_handler(); + chdir((string) $oldCwd); + @unlink($tempRepo . '/typephp.php'); + @rmdir($tempRepo . '/subproject'); + @rmdir($tempRepo); + Config::reset(); + } + }); +}); + +describe('2. Telemetry Overhead Gating (throw mode vs report mode)', function () { + test('allocates 0 file tracking in throw and warn modes (zero overhead)', function () { + Config::set(['on_violation' => 'throw']); + expect(StreamWrapper::shouldTrackTelemetry())->toBeFalse(); + + $wrapper = new StreamWrapper(); + $openedPath = null; + $thisFile = __FILE__; + + $wrapper->stream_open($thisFile, 'r', StreamWrapper::STREAM_OPEN_FOR_INCLUDE, $openedPath); + $wrapper->stream_close(); + + // In throw mode: 0 files tracked in memory! + expect(StreamWrapper::getInstrumentedCount())->toBe(0) + ->and(StreamWrapper::getInstrumentedFiles())->toBeEmpty() + ; + }); + + test('tracks unique files in report mode and deduplicates repeated includes', function () { + Config::set(['on_violation' => 'report']); + expect(StreamWrapper::shouldTrackTelemetry())->toBeTrue(); + + $wrapper = new StreamWrapper(); + $openedPath = null; + $thisFile = __FILE__; + + $wrapper->stream_open($thisFile, 'r', StreamWrapper::STREAM_OPEN_FOR_INCLUDE, $openedPath); + $wrapper->stream_close(); + + expect(StreamWrapper::getInstrumentedCount())->toBe(1); + + // Repeated include of the same file should not duplicate the count + $wrapper->stream_open($thisFile, 'r', StreamWrapper::STREAM_OPEN_FOR_INCLUDE, $openedPath); + $wrapper->stream_close(); + + expect(StreamWrapper::getInstrumentedCount())->toBe(1); + }); +}); + +describe('3. Report Schema Telemetry & 0-Files Warning', function () { + test('includes context, config_file, and files_instrumented in report JSON document', function () { + $tempDir = sys_get_temp_dir() . '/typephp_report_telemetry_' . uniqid(); + mkdir($tempDir, 0777, true); + $reportFile = $tempDir . '/audit.json'; + + Config::set([ + 'on_violation' => 'report', + 'report_file' => $reportFile, + ]); + + StreamWrapper::recordInstrumentedFile('/app/Services/OrderService.php'); + StreamWrapper::recordInstrumentedFile('/app/Services/UserService.php'); + + ViolationCollector::exportReport($reportFile); + + expect(file_exists($reportFile))->toBeTrue(); + + /** @var array{context: array, summary: array} $doc */ + $doc = json_decode((string) file_get_contents($reportFile), true); + + expect($doc)->toHaveKey('context') + ->and($doc['context']['files_instrumented'])->toBe(2) + ->and($doc['summary']['files_instrumented'])->toBe(2) + ->and($doc['summary']['total_violations'])->toBe(0) + ->and($doc['summary'])->not()->toHaveKey('warning') + ; + + @unlink($reportFile); + @rmdir($tempDir); + }); + + test('adds warning to report summary when files_instrumented is 0', function () { + $tempDir = sys_get_temp_dir() . '/typephp_zero_report_' . uniqid(); + mkdir($tempDir, 0777, true); + $reportFile = $tempDir . '/zero-audit.json'; + + Config::set([ + 'on_violation' => 'report', + 'report_file' => $reportFile, + ]); + + ViolationCollector::exportReport($reportFile); + + /** @var array{summary: array} $doc */ + $doc = json_decode((string) file_get_contents($reportFile), true); + + expect($doc['summary']['files_instrumented'])->toBe(0) + ->and($doc['summary'])->toHaveKey('warning') + ->and($doc['summary']['warning'])->toContain('No application files were instrumented') + ; + + @unlink($reportFile); + @rmdir($tempDir); + }); +}); + +describe('4. CLI Report Command: Distinguishing Empty Run from Clean Run', function () { + test('returns exit code 1 when report file recorded 0 violations over 0 instrumented files', function () { + $tempDir = sys_get_temp_dir() . '/typephp_cli_zero_' . uniqid(); + mkdir($tempDir, 0777, true); + $reportFile = $tempDir . '/zero.json'; + + $reportData = [ + 'version' => '1.0', + 'context' => [ + 'project_root' => '/repo', + 'config_file' => '/repo/typephp.php', + 'files_instrumented' => 0, + ], + 'summary' => [ + 'total_violations' => 0, + 'files_affected' => 0, + 'files_instrumented' => 0, + 'warning' => 'No application files were instrumented', + ], + 'violations' => [], + ]; + file_put_contents($reportFile, json_encode($reportData)); + + Config::set(['report_file' => $reportFile]); + + $stream = fopen('php://memory', 'r+'); + + try { + $exitCode = CommandRunner::run(['report'], $stream, $stream); + + rewind($stream); + $rawOutput = (string) stream_get_contents($stream); + $output = (string) preg_replace('/\x1b\[[0-9;]*m/', '', $rawOutput); + + expect($exitCode)->toBe(1) + ->and($output)->toContain('Files Instrumented: 0') + ->and($output)->toContain('0 violations over 0 instrumented files') + ; + } finally { + fclose($stream); + @unlink($reportFile); + @rmdir($tempDir); + } + }); + + test('returns exit code 0 when report file recorded 0 violations across 15 instrumented files (Clean Run)', function () { + $tempDir = sys_get_temp_dir() . '/typephp_cli_clean_' . uniqid(); + mkdir($tempDir, 0777, true); + $reportFile = $tempDir . '/clean.json'; + + $reportData = [ + 'version' => '1.0', + 'context' => [ + 'project_root' => '/repo', + 'config_file' => '/repo/typephp.php', + 'files_instrumented' => 15, + ], + 'summary' => [ + 'total_violations' => 0, + 'files_affected' => 0, + 'files_instrumented' => 15, + ], + 'violations' => [], + ]; + file_put_contents($reportFile, json_encode($reportData)); + + Config::set(['report_file' => $reportFile]); + + $stream = fopen('php://memory', 'r+'); + + try { + $exitCode = CommandRunner::run(['report'], $stream, $stream); + + rewind($stream); + $rawOutput = (string) stream_get_contents($stream); + $output = (string) preg_replace('/\x1b\[[0-9;]*m/', '', $rawOutput); + + expect($exitCode)->toBe(0) + ->and($output)->toContain('Clean run: No type violations found across 15 instrumented file(s)') + ->and($output)->toContain('Files Instrumented: 15') + ; + } finally { + fclose($stream); + @unlink($reportFile); + @rmdir($tempDir); + } + }); +}); + +describe('5. fail_on_zero_files Configuration Setting', function () { + test('isFailOnZeroFilesEnabled respects config setting and environment variable', function () { + expect(Config::isFailOnZeroFilesEnabled())->toBeFalse(); + + Config::set(['fail_on_zero_files' => true]); + expect(Config::isFailOnZeroFilesEnabled())->toBeTrue(); + + Config::set(['fail_on_zero_files' => false]); + expect(Config::isFailOnZeroFilesEnabled())->toBeFalse(); + + putenv('TYPEPHP_FAIL_ON_ZERO_FILES=true'); + expect(Config::isFailOnZeroFilesEnabled())->toBeTrue(); + + putenv('TYPEPHP_FAIL_ON_ZERO_FILES=false'); + expect(Config::isFailOnZeroFilesEnabled())->toBeFalse(); + }); +}); diff --git a/tests/Internal/Reporting/ViolationCollectorTest.php b/tests/Internal/Reporting/ViolationCollectorTest.php index d70fd818..eb98300e 100644 --- a/tests/Internal/Reporting/ViolationCollectorTest.php +++ b/tests/Internal/Reporting/ViolationCollectorTest.php @@ -477,7 +477,6 @@ function: 'test', ->and(is_dir($shardDir))->toBeFalse() ; - /** @var array{version: string, generated_at: string, summary: array{total_violations: int, files_affected: int}, violations: list>} $doc */ $doc = json_decode((string) file_get_contents($reportFile), true); expect($doc['version'])->toBe('1.0') diff --git a/tests/TypeChecking/ArraysAndShapes/AdvancedTypesAndEnumsTest.php b/tests/TypeChecking/ArraysAndShapes/AdvancedTypesAndEnumsTest.php index 15fd4d38..0e8c5326 100644 --- a/tests/TypeChecking/ArraysAndShapes/AdvancedTypesAndEnumsTest.php +++ b/tests/TypeChecking/ArraysAndShapes/AdvancedTypesAndEnumsTest.php @@ -115,6 +115,14 @@ function testReturnClassConstantContract(bool $valid): mixed return 100; } +/** + * @param stdClass{name: non-empty-string, age: positive-int}|null $payload + */ +function testNullableStdClassShapeContract(?stdClass $payload): bool +{ + return true; +} + describe('PHPDoc Object Shapes (object{prop: type})', function () { test('accepts stdClass object matching required object shape properties', function () { $payload = new stdClass(); @@ -268,3 +276,27 @@ function testReturnClassConstantContract(bool $valid): mixed ; }); }); + +describe('Strict stdClass Shape Error Precision (Issue Bug Report)', function () { + test('names exact unexpected property when stdClass shape receives extra field', function () { + $user = json_decode('{"id":42,"name":"Charlie","role":"admin"}'); + + expect(fn () => testStrictStdClassShapeContract($user)) + ->toThrow( + TypeError::class, + "testStrictStdClassShapeContract(): Argument \$payload contains unexpected property 'role'" + ) + ; + }); + + test('names exact unexpected property when stdClass shape is nullable (stdClass{...}|null)', function () { + $user = json_decode('{"name":"Charlie","age":22,"role":"admin"}'); + + expect(fn () => testNullableStdClassShapeContract($user)) + ->toThrow( + TypeError::class, + "contains unexpected property 'role'" + ) + ; + }); +}); diff --git a/tests/TypeChecking/Boundaries/MultilineParamTagVariableResolutionTest.php b/tests/TypeChecking/Boundaries/MultilineParamTagVariableResolutionTest.php new file mode 100644 index 00000000..75d19c7a --- /dev/null +++ b/tests/TypeChecking/Boundaries/MultilineParamTagVariableResolutionTest.php @@ -0,0 +1,104 @@ + 'click', 'x' => 10, 'y' => 20]))->toBe('click'); + expect(testMultilineUnionParamWithVariableOnNewLine(['type' => 'hover', 'element' => 'btn-submit']))->toBe('hover'); + expect(testMultilineUnionParamWithVariableOnNewLine(['type' => 'keypress', 'key' => 'Enter']))->toBe('keypress'); + }); + + test('throws TypeError when matching discriminator branch has an invalid property', function () { + expect(fn () => testMultilineUnionParamWithVariableOnNewLine(['type' => 'hover', 'element' => ''])) + ->toThrow( + TypeError::class, + "Argument \$event['element'] must be of type non-empty-string, empty string ('') given" + ) + ; + }); + + test('throws TypeError when discriminator does not match any union branch', function () { + expect(fn () => testMultilineUnionParamWithVariableOnNewLine(['type' => 'scroll', 'delta' => 5])) + ->toThrow( + TypeError::class, + 'Argument $event must be of type (array{type: \'click\', x: int, y: int} | array{type: \'hover\', element: non-empty-string} | array{type: \'keypress\', key: non-empty-string})' + ) + ; + }); + + test('validates multi-line union parameter with variable and description on new line', function () { + expect(testMultilineUnionWithDescription(['type' => 'click', 'x' => 5, 'y' => 10]))->toBe('click'); + + expect(fn () => testMultilineUnionWithDescription(['type' => 'hover', 'element' => ''])) + ->toThrow( + TypeError::class, + "['element'] must be of type non-empty-string" + ) + ; + }); + + test('validates class methods when variable name is on a new line below multi-line union', function () { + $service = new MultilineParamService(); + + expect($service->handle(['mode' => 'sync', 'timeout' => 30]))->toBe('sync'); + expect($service->handle(['mode' => 'async', 'queue' => 'default']))->toBe('async'); + + expect(fn () => $service->handle(['mode' => 'sync', 'timeout' => -5])) + ->toThrow(TypeError::class, "['timeout'] must be of type positive-int") + ; + + expect(fn () => $service->handle(['mode' => 'async', 'queue' => ''])) + ->toThrow(TypeError::class, "['queue'] must be of type non-empty-string") + ; + + expect(fn () => $service->handle(['mode' => 'unknown', 'foo' => 'bar'])) + ->toThrow(TypeError::class, 'must be of type') + ; + }); +}); diff --git a/tests/TypeChecking/Boundaries/NativeStreamBehaviorTest.php b/tests/TypeChecking/Boundaries/NativeStreamBehaviorTest.php index 3c99c28a..9156791a 100644 --- a/tests/TypeChecking/Boundaries/NativeStreamBehaviorTest.php +++ b/tests/TypeChecking/Boundaries/NativeStreamBehaviorTest.php @@ -145,6 +145,54 @@ $allWarnings = implode("\n", $warnings); expect($allWarnings)->toContain('fopen(' . $missing . '): Failed to open stream'); }); + + test('include_once on missing file emits No such file or directory warning naming the path', function () { + StreamWrapper::register(); + + $missingFile = sys_get_temp_dir() . '/missing_autoloader_probe_' . uniqid() . '.php'; + + $warnings = []; + set_error_handler(function (int $errno, string $errstr) use (&$warnings): bool { + $warnings[] = $errstr; + + return true; + }); + + try { + include_once $missingFile; + } finally { + restore_error_handler(); + } + + $allWarnings = implode("\n", $warnings); + + expect($allWarnings)->toContain('No such file or directory') + ->and($allWarnings)->toContain(basename($missingFile)) + ; + }); + + test('@include_once on missing file respects @ error suppression', function () { + StreamWrapper::register(); + + $missingFile = sys_get_temp_dir() . '/missing_silent_probe_' . uniqid() . '.php'; + + $warnings = []; + set_error_handler(function (int $errno, string $errstr) use (&$warnings): bool { + if ((error_reporting() & $errno) !== 0) { + $warnings[] = $errstr; + } + + return true; + }); + + try { + @include_once $missingFile; + } finally { + restore_error_handler(); + } + + expect($warnings)->toBeEmpty(); + }); }); describe('3. stream_select() & stream_cast() Interoperability', function () {