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 () {