Skip to content

Commit fb08b46

Browse files
authored
Minor feature improvements and bug fixes (#115)
* Enhance violation reporting and telemetry features - Added 'fail_on_zero_files' configuration option to terminate the process if no application files are instrumented. - Updated report structure to include context and summary details about instrumented files. - Improved error handling in the ReportCommand to distinguish between clean runs and cases with zero instrumented files. - Introduced telemetry tracking for instrumented files during report mode, allowing for better diagnostics. - Enhanced the ViolationCollector to manage shutdown handlers and report generation more effectively. - Added tests for zero-files detection, ensuring proper behavior when no files are instrumented. - Updated configuration loading to prioritize 'typephp.php' over 'composer.json' for project root resolution. * Enhance error handling in StreamWrapper and add tests for missing file warnings * Enhance error messaging for unexpected properties in IntersectionValidator and UnionValidator; add tests for nullable stdClass shape contract * Enhance Docblock normalization and parsing for multi-line union parameters; add tests for variable resolution in multi-line contexts * Refactor ZeroFilesDetectionTest to streamline environment cleanup and enhance test structure for 8.2 ci compatibility * Exclude TypePHP.php from PHPUnit source to improve test coverage * fix pint formatting * Fix namespace declaration and improve error handling in MultilineParamTagVariableResolutionTest
1 parent c673bd0 commit fb08b46

18 files changed

Lines changed: 852 additions & 76 deletions

‎phpunit.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
</include>
1515
<exclude>
1616
<file>src/bootstrap.php</file>
17+
<file>src/TypePHP.php</file>
1718
<directory>src/Internal/Cli</directory>
1819
</exclude>
1920
</source>

‎src/Internal/Cli/ConfigInitCommand.php‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,16 @@ private static function getTemplate(): string
8181
| - 'warn' : Emits PHP E_USER_WARNING once per unique violation to your logs
8282
| and allows execution to continue normally without crashing.
8383
|
84-
| 'report_file' : Sets the target JSON output path when 'report' mode is active.
85-
| 'fail_on_report' : When true, terminates the process with exit code 1 at shutdown
86-
| if the violation report is non-empty. Useful for CI gates.
84+
| 'report_file' : Sets the target JSON output path when 'report' mode is active.
85+
| 'fail_on_report' : When true, terminates the process with exit code 1 at shutdown
86+
| if the violation report is non-empty. Useful for CI gates.
87+
| 'fail_on_zero_files' : When true, terminates the process with exit code 1 if 0 application
88+
| files were instrumented. Prevents CI passes when include patterns match nothing.
8789
*/
88-
'on_violation' => 'throw',
89-
'report_file' => null,
90-
'fail_on_report' => false,
90+
'on_violation' => 'throw',
91+
'report_file' => null,
92+
'fail_on_report' => false,
93+
'fail_on_zero_files' => false,
9194
9295
/*
9396
|--------------------------------------------------------------------------
@@ -315,7 +318,7 @@ private static function getTemplate(): string
315318
|--------------------------------------------------------------------------
316319
| Globs or specific file paths that should be ignored by the type checker.
317320
| You can exclude entire directories (e.g. 'vendor/**') or blacklist
318-
| single legacy files inside included directories (e.g. 'src/Legacy/File.php').
321+
| single legacy files inside included directories (e.g. 'src/Legacy/UnsafeFile.php').
319322
*/
320323
'exclude' => [
321324
'vendor/**',

‎src/Internal/Cli/ReportCommand.php‎

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE
2020
$reportDir = \dirname($reportFile);
2121
$shardDir = $reportDir . '/.typephp-shards';
2222

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

43-
/** @var array{version?: string, generated_at?: string, summary?: array{total_violations?: int, files_affected?: int}, violations?: list<array<string, mixed>>}|null $data */
42+
/** @var array{
43+
* version?: string,
44+
* generated_at?: string,
45+
* context?: array{project_root?: string, config_file?: string, files_instrumented?: int},
46+
* summary?: array{total_violations?: int, files_affected?: int, files_instrumented?: int, warning?: string},
47+
* violations?: list<array<string, mixed>>
48+
* }|null $data */
4449
$data = json_decode($rawContent, true);
4550

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

49-
return 0;
54+
return 1;
5055
}
5156

52-
$violations = $data['violations'];
57+
$violations = (isset($data['violations']) && \is_array($data['violations'])) ? $data['violations'] : [];
5358
$totalCount = \count($violations);
59+
$filesInstrumented = $data['summary']['files_instrumented'] ?? ($data['context']['files_instrumented'] ?? null);
60+
$configSource = $data['context']['config_file'] ?? null;
61+
$projectRoot = $data['context']['project_root'] ?? null;
62+
$relativeReportPath = ViolationCollector::normalizeRelativePath($reportFile);
63+
64+
if ($totalCount === 0) {
65+
if ($filesInstrumented === 0) {
66+
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Total Violations: ' . $c('0', 'bold') . "\n");
67+
fwrite($outputStream, ' ' . $c('•', 'red') . ' Files Instrumented: ' . $c('0 (⚠️ No files matched configuration!)', 'bold') . "\n");
68+
if ($configSource !== null) {
69+
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Config Source: ' . $c($configSource, 'gray') . "\n");
70+
}
71+
if ($projectRoot !== null) {
72+
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Project Root: ' . $c($projectRoot, 'gray') . "\n");
73+
}
74+
fwrite($outputStream, "\n " . $c('✗', 'red') . " 0 violations over 0 instrumented files. Check your 'include'/'exclude' paths in typephp.php.\n\n");
5475

55-
/** @var array<string, list<array<string, mixed>>> $grouped */
76+
return 1;
77+
}
78+
79+
if ($filesInstrumented !== null && $filesInstrumented > 0) {
80+
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Total Violations: ' . $c('0', 'bold') . "\n");
81+
fwrite($outputStream, ' ' . $c('•', 'green') . ' Files Instrumented: ' . $c((string) $filesInstrumented, 'bold') . "\n");
82+
if ($configSource !== null) {
83+
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Config Source: ' . $c($configSource, 'gray') . "\n");
84+
}
85+
fwrite($outputStream, "\n " . $c('✓', 'green') . " Clean run: No type violations found across {$filesInstrumented} instrumented file(s).\n\n");
86+
87+
return 0;
88+
}
89+
90+
fwrite($outputStream, ' ' . $c('✓', 'green') . " No violations recorded in report.\n\n");
91+
92+
return 0;
93+
}
94+
95+
/**
96+
* @var array<string, list<array<string, mixed>>>
97+
*/
5698
$grouped = [];
5799
$filesAffectedMap = [];
58100

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

69-
$relativeReportPath = ViolationCollector::normalizeRelativePath($reportFile);
70-
71111
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Total Violations: ' . $c((string) $totalCount, 'bold') . "\n");
72-
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Files Affected: ' . $c((string) $filesAffected, 'bold') . "\n");
73-
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Report Source: ' . $c($relativeReportPath, 'gray') . "\n\n");
112+
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Files Affected: ' . $c((string) $filesAffected, 'bold') . "\n");
113+
if ($filesInstrumented !== null) {
114+
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Files Instrumented: ' . $c((string) $filesInstrumented, 'bold') . "\n");
115+
}
116+
if ($configSource !== null) {
117+
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Config Source: ' . $c($configSource, 'gray') . "\n");
118+
}
119+
fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Report File: ' . $c($relativeReportPath, 'gray') . "\n\n");
74120

75121
foreach ($grouped as $file => $items) {
76122
fwrite($outputStream, ' ' . $c($file, 'bold') . "\n");

‎src/Internal/Docblock/DocblockNormalizer.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ public static function normalize(string $doc): string
7373
$doc = preg_replace('/(\.\.\.\s*)(?:list|array)<([^>]+)>/', '$1<$2>', $doc) ?? $doc;
7474
}
7575

76+
if (str_contains($doc, '$')) {
77+
$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;
78+
}
79+
7680
if (! str_contains($doc, '{')) {
7781
return $doc;
7882
}

‎src/Internal/Docblock/DocblockParser.php‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1066,9 +1066,11 @@ private static function parseFunction(\ReflectionFunction $ref): array
10661066
$doc = $stubDoc ?? $ref->getDocComment();
10671067

10681068
$baseParams = $ref->getParameters();
1069+
$baseParamNames = [];
10691070
$baseParamVariadic = [];
10701071
$baseParamObjects = [];
1071-
foreach ($baseParams as $p) {
1072+
foreach ($baseParams as $idx => $p) {
1073+
$baseParamNames[$idx] = $p->getName();
10721074
$baseParamVariadic[$p->getName()] = $p->isVariadic();
10731075
$baseParamObjects[$p->getName()] = $p;
10741076
if (self::hasSensitiveAttribute($p)) {
@@ -1109,7 +1111,13 @@ private static function parseFunction(\ReflectionFunction $ref): array
11091111
}
11101112
DocblockExtractor::extractAliases($phpDocNode, $aliases, $ref);
11111113

1114+
$paramIndex = 0;
11121115
foreach (DocblockExtractor::getParamTags($phpDocNode) as $paramName => $paramTag) {
1116+
if ($paramName === '' && isset($baseParamNames[$paramIndex])) {
1117+
$paramName = $baseParamNames[$paramIndex];
1118+
}
1119+
$paramIndex++;
1120+
11131121
$type = $paramTag->type;
11141122
$pObj = $baseParamObjects[$paramName] ?? null;
11151123

@@ -1385,6 +1393,7 @@ private static function parseMethodHierarchyDocs(
13851393

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

1396+
$paramIndex = 0;
13881397
foreach ($paramTags as $paramName => $paramTag) {
13891398
$targetParamName = self::resolveTargetParamName(
13901399
$paramName,
@@ -1394,6 +1403,11 @@ private static function parseMethodHierarchyDocs(
13941403
$isConstructor
13951404
);
13961405

1406+
if ($targetParamName === null && $paramName === '' && isset($baseParamNames[$paramIndex])) {
1407+
$targetParamName = $baseParamNames[$paramIndex];
1408+
}
1409+
$paramIndex++;
1410+
13971411
if ($targetParamName !== null && ! isset($types[$targetParamName])) {
13981412
$type = $paramTag->type;
13991413
$pObj = $baseParamObjects[$targetParamName] ?? null;
@@ -1421,6 +1435,7 @@ private static function parseMethodHierarchyDocs(
14211435
}
14221436

14231437
$paramOutTags = DocblockExtractor::getParamOutTags($phpDocNode);
1438+
$paramOutIndex = 0;
14241439
foreach ($paramOutTags as $paramName => $paramOutTag) {
14251440
$targetParamName = self::resolveTargetParamName(
14261441
$paramName,
@@ -1430,6 +1445,11 @@ private static function parseMethodHierarchyDocs(
14301445
$isConstructor
14311446
);
14321447

1448+
if ($targetParamName === null && $paramName === '' && isset($baseParamNames[$paramOutIndex])) {
1449+
$targetParamName = $baseParamNames[$paramOutIndex];
1450+
}
1451+
$paramOutIndex++;
1452+
14331453
if ($targetParamName !== null && ! isset($paramOuts[$targetParamName])) {
14341454
$type = $paramOutTag->type;
14351455
$substitutedType = self::substituteAliases($type, $aliases);

‎src/Internal/Io/CacheManager.php‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,7 @@ public static function warmUp(?callable $progressCallback = null): array
244244
return ['total' => 0, 'cached' => 0, 'skipped' => 0];
245245
}
246246

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

250249
$files = self::findFilesToWarm($baseDir);
251250
$total = \count($files);

‎src/Internal/Io/StreamWrapper.php‎

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,18 @@ final class StreamWrapper implements StreamWrapperInterface
110110
*/
111111
private static array $verifiedCachedFiles = [];
112112

113+
/**
114+
* In-memory registry of unique application files instrumented during report mode.
115+
*
116+
* @var array<string, bool>
117+
*/
118+
private static array $instrumentedFiles = [];
119+
120+
/**
121+
* Whether telemetry tracking is explicitly forced (e.g. in tests).
122+
*/
123+
private static bool $forceTelemetry = false;
124+
113125
/**
114126
* Native PHP functions that read raw source code for viewing, highlighting, or tokenizing.
115127
*
@@ -149,7 +161,7 @@ public static function getCloningVisitor(): CloningVisitor
149161
}
150162

151163
/**
152-
* Resets all internal in-memory caches and path matchers.
164+
* Resets all internal in-memory caches, path matchers, and instrumented file counters.
153165
*/
154166
public static function reset(): void
155167
{
@@ -159,9 +171,59 @@ public static function reset(): void
159171
self::$bypassedPathCache = [];
160172
self::$realpathCache = [];
161173
self::$verifiedCachedFiles = [];
174+
self::$instrumentedFiles = [];
175+
self::$forceTelemetry = false;
162176
PathMatcher::reset();
163177
}
164178

179+
/**
180+
* Enables or disables explicit telemetry tracking.
181+
*/
182+
public static function enableTelemetry(bool $enable = true): void
183+
{
184+
self::$forceTelemetry = $enable;
185+
}
186+
187+
/**
188+
* Determines whether instrumented file paths should be recorded in memory.
189+
* Only active in report mode (or if explicitly forced in tests) to avoid overhead.
190+
*/
191+
public static function shouldTrackTelemetry(): bool
192+
{
193+
return self::$forceTelemetry || Config::isReportMode();
194+
}
195+
196+
/**
197+
* Returns the total number of unique application files instrumented in this process.
198+
*/
199+
public static function getInstrumentedCount(): int
200+
{
201+
return \count(self::$instrumentedFiles);
202+
}
203+
204+
/**
205+
* Returns the list of all application file paths instrumented in this process.
206+
*
207+
* @return list<string>
208+
*/
209+
public static function getInstrumentedFiles(): array
210+
{
211+
return array_keys(self::$instrumentedFiles);
212+
}
213+
214+
/**
215+
* Manually records an instrumented file path (e.g. when consolidating worker shards).
216+
*/
217+
public static function recordInstrumentedFile(string $filePath): void
218+
{
219+
if ($filePath === '') {
220+
return;
221+
}
222+
223+
$normalized = str_replace('\\', '/', $filePath);
224+
self::$instrumentedFiles[$normalized] = true;
225+
}
226+
165227
/**
166228
* Registers the stream wrapper for the native 'file://' protocol.
167229
*
@@ -354,13 +416,11 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
354416
return $this->openDirectHandle($path, $mode, $options);
355417
}
356418

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

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

474+
if ($this->handle === null && ! self::isPharCall()) {
475+
trigger_error("include({$target}): Failed to open stream: No such file or directory", E_USER_WARNING);
476+
}
477+
414478
return $this->handle !== null;
415479
}
416480

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

485+
if ($success && self::shouldTrackTelemetry()) {
486+
self::$instrumentedFiles[$resolvedPath] = true;
487+
}
488+
421489
self::register();
422490

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

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

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

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

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

653716
return $result;
654717
}
655718

656-
// Only cache negative misses for immutable vendor packages to prevent poisoning application paths
657719
if (PathMatcher::isVendorPath($normalized)) {
658720
self::$staticNegativeStatCache[$normalized] = true;
659721
}

0 commit comments

Comments
 (0)