• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

valksor / php-dev-build / 19634179404

24 Nov 2025 12:21PM UTC coverage: 27.943% (+8.2%) from 19.747%
19634179404

push

github

k0d3r1s
add valksor-dev snapshot

1 of 13 new or added lines in 2 files covered. (7.69%)

101 existing lines in 4 files now uncovered.

667 of 2387 relevant lines covered (27.94%)

1.08 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

30.7
/Service/HotReloadService.php
1
<?php declare(strict_types = 1);
2

3
/*
4
 * This file is part of the Valksor package.
5
 *
6
 * (c) Davis Zalitis (k0d3r1s)
7
 * (c) SIA Valksor <packages@valksor.com>
8
 *
9
 * For the full copyright and license information, please view the LICENSE
10
 * file that was distributed with this source code.
11
 */
12

13
namespace ValksorDev\Build\Service;
14

15
use Exception;
16
use JsonException;
17
use RuntimeException;
18
use Symfony\Component\Console\Command\Command;
19
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
20
use Valksor\Bundle\Service\PathFilter;
21
use Valksor\Component\Sse\Service\AbstractService;
22

23
use function array_key_exists;
24
use function array_keys;
25
use function array_merge;
26
use function closedir;
27
use function count;
28
use function extension_loaded;
29
use function file_put_contents;
30
use function is_dir;
31
use function is_writable;
32
use function json_encode;
33
use function ltrim;
34
use function max;
35
use function microtime;
36
use function opendir;
37
use function pathinfo;
38
use function preg_match;
39
use function preg_quote;
40
use function readdir;
41
use function sprintf;
42
use function str_ends_with;
43
use function str_replace;
44
use function str_starts_with;
45
use function stream_select;
46
use function strtolower;
47
use function usleep;
48

49
use const DIRECTORY_SEPARATOR;
50
use const PATHINFO_EXTENSION;
51

52
/**
53
 * Hot reload service for automatically triggering browser refreshes when files change.
54
 *
55
 * This service monitors file system changes and sends reload signals to connected browsers
56
 * via Server-Sent Events (SSE). It handles:
57
 * - File system monitoring with inotify
58
 * - Intelligent debouncing to prevent excessive reloads
59
 * - File transformation patterns (e.g., .tailwind.css → .css)
60
 * - Rate limiting and duplicate change detection
61
 * - Multi-app project structure support
62
 */
63
final class HotReloadService extends AbstractService
64
{
65
    /**
66
     * Deadline for when debounced changes should be flushed.
67
     * This prevents excessive reloads when multiple files change rapidly.
68
     */
69
    private float $debounceDeadline = 0.0;
70

71
    /**
72
     * File transformation patterns for mapping source files to output files.
73
     * Example: '*.tailwind.css' → ['output_pattern' => '{path}/{name}.css', 'debounce_delay' => 0.5].
74
     *
75
     * @var array<string,array<string,mixed>>
76
     */
77
    private array $fileTransformations = [];
78

79
    /**
80
     * Path filter for ignoring directories and files during file watching.
81
     */
82
    private PathFilter $filter;
83

84
    /**
85
     * Registry of known output files that should trigger reloads.
86
     * These are the target files generated by transformations (e.g., .css files).
87
     *
88
     * @var array<string,bool>
89
     */
90
    private array $outputFiles = [];
91

92
    /**
93
     * Pending file changes waiting to be debounced and flushed.
94
     * Prevents duplicate changes and implements debouncing logic.
95
     *
96
     * @var array<string,bool>
97
     */
98
    private array $pendingChanges = [];
99

100
    public function __construct(
101
        ParameterBagInterface $bag,
102
    ) {
103
        parent::__construct($bag);
12✔
104
        $this->filter = PathFilter::createDefault($this->projectDir);
12✔
105
    }
106

107
    public function reload(): void
108
    {
109
        // Hot reload service doesn't support manual reload
UNCOV
110
        $this->io->note('Hot reload service reload requested (no action needed)');
×
111
    }
112

113
    /**
114
     * @param array<string,mixed> $config Configuration from hot_reload config section
115
     */
116
    public function start(
117
        array $config = [],
118
    ): int {
119
        // SSE server communication now handled via direct service calls
120

121
        // Get hot reload configuration from service-based structure
122
        $hotReloadConfig = $this->parameterBag->get('valksor.build.services.hot_reload.options');
4✔
123

124
        // Create custom path filter with user exclusions
125
        $excludePatterns = $hotReloadConfig['exclude'] ?? [];
4✔
126
        $this->filter = $this->createPathFilterWithExclusions($excludePatterns);
4✔
127

128
        // Validate configuration early to fail fast if setup is incorrect
129
        if (!$this->validateConfiguration($hotReloadConfig)) {
4✔
130
            return 1; // Return error code for invalid config
×
131
        }
132

133
        $watchDirs = $hotReloadConfig['watch_dirs'] ?? [];
4✔
134

135
        // Early return if no watch directories configured - nothing to monitor
136
        if (empty($watchDirs)) {
4✔
137
            $this->io->warning('No watch directories configured. Hot reload disabled.');
1✔
138

139
            return Command::SUCCESS;
1✔
140
        }
141

142
        // Extract configuration with sensible defaults
143
        $debounceDelay = $hotReloadConfig['debounce_delay'] ?? 0.3;           // Default 300ms debounce
3✔
144
        $extendedExtensions = $hotReloadConfig['extended_extensions'] ?? [];     // File extensions to watch
3✔
145
        $extendedSuffixes = $hotReloadConfig['extended_suffixes'] ?? ['.tailwind.css' => 0.5]; // Specific file suffixes
3✔
146
        $fileTransformations = $hotReloadConfig['file_transformations'] ?? [
3✔
147
            // Default transformation: Tailwind source files to CSS output
148
            '*.tailwind.css' => [
3✔
149
                'output_pattern' => '{path}/{name}.css',    // Convert .tailwind.css to .css
3✔
150
                'debounce_delay' => 0.5,                   // Longer debounce for compiled files
3✔
151
                'track_output' => true,                    // Monitor the output CSS file
3✔
152
            ],
3✔
153
        ];
154

155
        $this->fileTransformations = $fileTransformations;
3✔
156
        // Lazy load output files only when needed, not during startup
157
        $this->outputFiles = [];
3✔
158

159
        try {
160
            $watcher = new RecursiveInotifyWatcher($this->filter, function (string $path) use ($extendedSuffixes, $debounceDelay, $extendedExtensions): void {
3✔
161
                $this->handleFilesystemChange($path, $extendedSuffixes, $extendedExtensions, $debounceDelay);
×
162
            });
3✔
163

164
            $this->io->text('File watcher initialized successfully');
3✔
165
        } catch (RuntimeException $e) {
×
166
            $this->io->error(sprintf('Failed to initialize file watcher: %s', $e->getMessage()));
×
167
            $this->io->warning('Hot reload will continue without file watching (manual reload only)');
×
168

169
            // Continue without watcher - will fall back to manual mode
170
            $watcher = null;
×
171
        } catch (Exception $e) {
×
172
            $this->io->error(sprintf('Unexpected error initializing watcher: %s', $e->getMessage()));
×
173

174
            // Continue without watcher
175
            $watcher = null;
×
176
        }
177

178
        $watchTargets = $this->collectWatchTargets($this->parameterBag->get('kernel.project_dir'), $watchDirs);
3✔
179

180
        if ([] === $watchTargets) {
3✔
181
            $this->io->warning('No watch targets found. Hot reload will remain idle.');
3✔
182
        } else {
183
            foreach ($watchTargets as $target) {
×
184
                $watcher->addRoot($target);
×
185
            }
186

187
            $this->io->note(sprintf('Watching %d directories for changes', count($watchTargets)));
×
188
        }
189

190
        $this->running = true;
3✔
191

192
        $this->io->success('Hot reload service started');
3✔
193
        $this->io->text(sprintf('[RESOURCE] Watching %d directories | Memory: %.2f MB', count($watchTargets), memory_get_usage(true) / 1024 / 1024));
3✔
194

195
        while ($this->running) {
3✔
196
            if ($watcher) {
3✔
197
                $read = [$watcher->getStream()];
3✔
198
                $write = null;
3✔
199
                $except = null;
3✔
200

201
                // Calculate dynamic timeout based on debouncing needs
202
                $timeout = $this->calculateSelectTimeout();
3✔
203
                $seconds = $timeout >= 1 ? (int) $timeout : 0;
3✔
204
                $microseconds = $timeout >= 1 ? (int) (($timeout - $seconds) * 1_000_000) : (int) max($timeout * 1_000_000, 50_000);
3✔
205
                // Minimum 50ms timeout to prevent busy waiting, maximum based on debounce deadline
206

207
                // Add error handling around stream_select
208
                try {
209
                    $ready = @stream_select($read, $write, $except, $seconds, $microseconds);
3✔
210

211
                    if (false === $ready) {
3✔
212
                        // stream_select failed, likely due to signal interruption
213
                        $this->io->text('Stream select interrupted, continuing...');
3✔
214
                        usleep(100000); // Wait 100ms before retry
3✔
215

216
                        continue;
3✔
217
                    }
218

219
                    if (!empty($read)) {
×
220
                        $watcher->poll();
×
221
                    }
222

223
                    $this->flushPendingReloads();
×
224
                } catch (Exception $e) {
×
225
                    $this->io->error(sprintf('Error in file watching loop: %s', $e->getMessage()));
×
226
                    // Continue running but log the error
227
                }
228
            } else {
229
                // No watcher available, just wait with timeout
230
                usleep(100000); // 100ms
×
231
            }
232
        }
233

234
        return 0;
3✔
235
    }
236

237
    public function stop(): void
238
    {
239
        $this->running = false;
4✔
240
    }
241

242
    public static function getServiceName(): string
243
    {
244
        return 'hot-reload';
2✔
245
    }
246

247
    private function calculateSelectTimeout(): float
248
    {
249
        $now = microtime(true);
3✔
250

251
        if ($this->debounceDeadline > 0) {
3✔
252
            return max(0.05, $this->debounceDeadline - $now);
×
253
        }
254

255
        return 1.0;
3✔
256
    }
257

258
    /**
259
     * Categorize an exclude pattern into the appropriate filter type.
260
     */
261
    private function categorizeExcludePattern(
262
        string $pattern,
263
        array &$dirs,
264
        array &$globs,
265
        array &$filenames,
266
        array &$extensions,
267
    ): void {
268
        // Glob patterns (contain wildcards)
269
        if (str_contains($pattern, '*') || str_contains($pattern, '?')) {
×
270
            $globs[] = $pattern;
×
271

272
            return;
×
273
        }
274

275
        // File extensions (start with dot)
276
        if (str_starts_with($pattern, '.')) {
×
277
            $extensions[] = $pattern;
×
278

279
            return;
×
280
        }
281

282
        // Path patterns (contain path separators)
283
        if (str_contains($pattern, '/')) {
×
284
            $globs[] = $pattern;
×
285

286
            return;
×
287
        }
288

289
        // Simple directory names (no slashes, no wildcards)
290
        if (!str_contains($pattern, '.')) {
×
291
            $dirs[] = $pattern;
×
292

293
            return;
×
294
        }
295

296
        // Everything else treat as glob pattern
297
        $globs[] = $pattern;
×
298
    }
299

300
    /**
301
     * @return array<int,string>
302
     */
303
    private function collectWatchTargets(
304
        string $projectRoot,
305
        array $watchDirs,
306
    ): array {
307
        $targets = [];
3✔
308

309
        foreach ($watchDirs as $dir) {
3✔
310
            $fullPath = $projectRoot . DIRECTORY_SEPARATOR . ltrim($dir, '/');
3✔
311

312
            if (is_dir($fullPath) && !$this->shouldExcludeDirectory($fullPath)) {
3✔
313
                $targets[] = $fullPath;
×
314
            }
315
        }
316

317
        return $targets;
3✔
318
    }
319

320
    /**
321
     * Create a PathFilter that merges default exclusions with custom exclude patterns.
322
     */
323
    private function createPathFilterWithExclusions(
324
        array $excludePatterns = [],
325
    ): PathFilter {
326
        if (empty($excludePatterns)) {
4✔
327
            return PathFilter::createDefault($this->projectDir);
4✔
328
        }
329

330
        // Get default filter values
331
        $defaultFilter = PathFilter::createDefault($this->projectDir);
×
332

333
        // Start with default exclusions using reflection to access private properties
334
        $defaultExclusions = $this->extractDefaultExclusions($defaultFilter);
×
335

336
        // Categorize custom patterns
337
        $customDirs = [];
×
338
        $customGlobs = [];
×
339
        $customFilenames = [];
×
340
        $customExtensions = [];
×
341

342
        foreach ($excludePatterns as $pattern) {
×
343
            $this->categorizeExcludePattern($pattern, $customDirs, $customGlobs, $customFilenames, $customExtensions);
×
344
        }
345

346
        // Merge with defaults
347
        $mergedDirs = array_merge($defaultExclusions['directories'], $customDirs);
×
348
        $mergedGlobs = array_merge($defaultExclusions['globs'], $customGlobs);
×
349
        $mergedFilenames = array_merge($defaultExclusions['filenames'], $customFilenames);
×
350
        $mergedExtensions = array_merge($defaultExclusions['extensions'], $customExtensions);
×
351

352
        // Create unified patterns array for the new PathFilter
NEW
353
        $allPatterns = array_merge(
×
NEW
354
            $mergedDirs,
×
NEW
355
            $mergedGlobs,
×
NEW
356
            $mergedFilenames,
×
NEW
357
            $mergedExtensions,
×
NEW
358
        );
×
359

NEW
360
        return new PathFilter($allPatterns, $this->projectDir);
×
361
    }
362

363
    private function determineReloadDelay(
364
        string $path,
365
        array $extendedExtensions,
366
        array $extendedSuffixes,
367
        float $debounceDelay,
368
    ): float {
369
        // Priority 1: Check if this is a source file that matches transformation patterns
370
        // Source files get their configured debounce delay (typically longer for compiled files)
371
        foreach ($this->fileTransformations as $pattern => $config) {
×
372
            if ($this->matchesPattern($path, $pattern)) {
×
373
                return $config['debounce_delay'] ?? $debounceDelay;
×
374
            }
375
        }
376

377
        // Priority 2: Check extended suffixes (legacy support for specific file patterns)
378
        // Provides backward compatibility with older configuration formats
379
        foreach ($extendedSuffixes as $suffix => $delay) {
×
380
            if (str_ends_with($path, $suffix)) {
×
381
                return $delay;
×
382
            }
383
        }
384

385
        // Priority 3: Check file extensions for general file type handling
386
        $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
×
387

388
        if ('' !== $extension && array_key_exists($extension, $extendedExtensions)) {
×
389
            return $extendedExtensions[$extension];
×
390
        }
391

392
        // Priority 4: Use default debounce delay for all other files
393
        return $debounceDelay;
×
394
    }
395

396
    /**
397
     * @param array<string,array<string,mixed>> $transformations
398
     *
399
     * @return array<string,bool>
400
     */
401
    private function discoverOutputFiles(
402
        string $projectRoot,
403
        array $transformations,
404
    ): array {
405
        $outputs = [];
×
406

407
        foreach ($transformations as $pattern => $config) {
×
408
            if (!($config['track_output'] ?? true)) {
×
409
                continue;
×
410
            }
411

412
            $roots = $config['watch_dirs'] ?? [
×
413
                $projectRoot . $this->parameterBag->get('valksor.project.apps_dir'),
×
414
                $projectRoot . $this->parameterBag->get('valksor.project.infrastructure_dir'),
×
415
            ];
416

417
            foreach ($roots as $root) {
×
418
                $root = $projectRoot . DIRECTORY_SEPARATOR . ltrim($root, '/');
×
419

420
                if (!is_dir($root)) {
×
421
                    continue;
×
422
                }
423

424
                $this->visitSources($root, $pattern, $config['output_pattern'], $outputs);
×
425
            }
426
        }
427

428
        return $outputs;
×
429
    }
430

431
    /**
432
     * Extract default exclusions from PathFilter using compatibility methods.
433
     */
434
    private function extractDefaultExclusions(
435
        PathFilter $filter,
436
    ): array {
437
        return [
×
NEW
438
            'directories' => $filter->getIgnoredDirectories(),
×
NEW
439
            'globs' => $filter->getIgnoredGlobs(),
×
NEW
440
            'filenames' => $filter->getIgnoredFilenames(),
×
NEW
441
            'extensions' => $filter->getIgnoredExtensions(),
×
UNCOV
442
        ];
×
443
    }
444

445
    /**
446
     * @throws JsonException
447
     */
448
    private function flushPendingReloads(): void
449
    {
450
        if ([] === $this->pendingChanges || microtime(true) < $this->debounceDeadline) {
×
451
            return;
×
452
        }
453

454
        $files = array_keys($this->pendingChanges);
×
455
        $this->pendingChanges = [];
×
456
        $this->debounceDeadline = 0.0;
×
457

458
        // Write signal file for SSE service with error handling
459
        $signalFile = $this->parameterBag->get('kernel.project_dir') . '/var/run/valksor-reload.signal';
×
460
        $signalData = json_encode(['files' => $files, 'timestamp' => microtime(true)], JSON_THROW_ON_ERROR);
×
461

462
        try {
463
            $result = file_put_contents($signalFile, $signalData);
×
464

465
            if (false === $result) {
×
466
                throw new RuntimeException('Failed to write signal file');
×
467
            }
468
        } catch (Exception $e) {
×
469
            $this->io->error(sprintf('Failed to write reload signal: %s', $e->getMessage()));
×
470

471
            return;
×
472
        }
473

474
        $this->io->success(sprintf('Reload signal sent for %d changed files', count($files)));
×
475

476
        // Refresh file list after sending signal to handle new/deleted files
477
        $this->refreshWatchTargets();
×
478
    }
479

480
    /**
481
     * Generate output file path from pattern.
482
     */
483
    private function generateOutputPath(
484
        string $projectRoot,
485
        string $relativeDir,
486
        string $filename,
487
        string $outputPattern,
488
    ): string {
489
        $placeholders = [
×
490
            '{path}' => $relativeDir,
×
491
            '{name}' => $filename,
×
492
        ];
×
493

494
        $outputPath = $outputPattern;
×
495

496
        foreach ($placeholders as $placeholder => $value) {
×
497
            $outputPath = str_replace($placeholder, $value, $outputPath);
×
498
        }
499

500
        return $projectRoot . DIRECTORY_SEPARATOR . $outputPath;
×
501
    }
502

503
    private function handleFilesystemChange(
504
        string $path,
505
        array $extendedSuffixes,
506
        array $extendedExtensions,
507
        float $debounceDelay,
508
    ): void {
509
        // Ignore files that match the path filter (e.g., .git, node_modules, vendor)
510
        if ($this->filter->shouldIgnorePath($path)) {
×
511
            return;
×
512
        }
513

514
        // Lazy load output files discovery to avoid expensive directory scanning during startup
515
        if (empty($this->outputFiles)) {
×
516
            $this->outputFiles = $this->discoverOutputFiles($this->parameterBag->get('kernel.project_dir'), $this->fileTransformations);
×
517
        }
518

519
        $now = microtime(true);
×
520

521
        // Determine the appropriate debounce delay for this specific file type
522
        $fileDelay = $this->determineReloadDelay($path, $extendedExtensions, $extendedSuffixes, $debounceDelay);
×
523

524
        // Calculate when this change should be flushed (debounce deadline)
525
        // Always reset the deadline - this is the key to proper debouncing behavior
526
        $this->debounceDeadline = $now + $fileDelay;
×
527

528
        // Add this file to the pending changes queue
529
        $this->pendingChanges[$path] = true;
×
530

531
        $this->io->text(sprintf('File changed: %s', $path));
×
532
    }
533

534
    /**
535
     * Check if a file path matches a glob pattern.
536
     *
537
     * Converts simple glob patterns (like *.tailwind.css) to regex for matching
538
     * against absolute file paths. This supports the transformation pattern system.
539
     *
540
     * Examples:
541
     * - "*.tailwind.css" matches all .tailwind.css files in any directory
542
     *
543
     * @param string $path    Absolute file path to check
544
     * @param string $pattern Glob pattern to match against
545
     *
546
     * @return bool True if the path matches the pattern
547
     */
548
    private function matchesPattern(
549
        string $path,
550
        string $pattern,
551
    ): bool {
552
        // Convert glob pattern to regex pattern
553
        // * becomes .* (match any characters)
554
        // ? becomes . (match any single character)
555
        $pattern = str_replace(['*', '?'], ['.*', '.'], $pattern);
×
556

557
        // Create full regex pattern that matches from project root
558
        $pattern = '#^' . preg_quote($this->parameterBag->get('kernel.project_dir') . DIRECTORY_SEPARATOR, '#') . $pattern . '$#';
×
559

560
        return 1 === preg_match($pattern, $path);
×
561
    }
562

563
    /**
564
     * Refresh watch targets and output files after a reload cycle.
565
     * This ensures new and deleted files are properly tracked.
566
     */
567
    private function refreshWatchTargets(): void
568
    {
569
        // Clear existing output files and rediscover
570
        $this->outputFiles = $this->discoverOutputFiles($this->parameterBag->get('kernel.project_dir'), $this->fileTransformations);
×
571

572
        $this->io->text(sprintf('Refreshed file discovery - tracking %d output files', count($this->outputFiles)));
×
573
    }
574

575
    /**
576
     * Check if a directory should be excluded based on exclude patterns.
577
     */
578
    private function shouldExcludeDirectory(
579
        string $dirPath,
580
    ): bool {
581
        // Get relative path from project root for pattern matching
582
        $projectRoot = $this->parameterBag->get('kernel.project_dir');
×
583
        $relativePath = str_replace($projectRoot . DIRECTORY_SEPARATOR, '', $dirPath);
×
584

585
        // Use PathFilter to check if this directory should be excluded
586
        return $this->filter->shouldIgnoreDirectory($relativePath) || $this->filter->shouldIgnorePath($relativePath);
×
587
    }
588

589
    /**
590
     * Validate hot reload configuration before starting.
591
     */
592
    private function validateConfiguration(
593
        array $config,
594
    ): bool {
595
        $projectRoot = $this->parameterBag->get('kernel.project_dir');
4✔
596

597
        // Validate var/run directory exists and is writable
598
        $runDir = $projectRoot . '/var/run';
4✔
599
        $this->ensureDirectory($runDir);
4✔
600

601
        if (!is_writable($runDir)) {
4✔
602
            $this->io->error(sprintf('Run directory is not writable: %s', $runDir));
×
603

604
            return false;
×
605
        }
606

607
        // Validate watch directories exist
608
        foreach ($config['watch_dirs'] ?? [] as $dir) {
4✔
609
            $fullPath = $projectRoot . '/' . ltrim($dir, '/');
3✔
610

611
            if (!is_dir($fullPath)) {
3✔
612
                $this->io->warning(sprintf('Watch directory does not exist: %s', $fullPath));
3✔
613
            }
614
        }
615

616
        // Check if inotify extension is available
617
        if (!extension_loaded('inotify')) {
4✔
618
            $this->io->warning('PHP inotify extension is not available. File watching may not work optimally.');
×
619
        }
620

621
        return true;
4✔
622
    }
623

624
    /**
625
     * @param array<string,bool> $outputs
626
     */
627
    private function visitSources(
628
        string $path,
629
        string $pattern,
630
        string $outputPattern,
631
        array &$outputs,
632
    ): void {
633
        $handle = opendir($path);
×
634

635
        if (false === $handle) {
×
636
            return;
×
637
        }
638

639
        try {
640
            while (($entry = readdir($handle)) !== false) {
×
641
                if ('.' === $entry || '..' === $entry) {
×
642
                    continue;
×
643
                }
644

645
                $full = $path . DIRECTORY_SEPARATOR . $entry;
×
646

647
                if (is_dir($full)) {
×
648
                    if ($this->filter->shouldIgnoreDirectory($entry)) {
×
649
                        continue;
×
650
                    }
651
                    $this->visitSources($full, $pattern, $outputPattern, $outputs);
×
652

653
                    continue;
×
654
                }
655

656
                if (!$this->matchesPattern($full, $pattern)) {
×
657
                    continue;
×
658
                }
659

660
                // Generate output file path
661
                $relativePath = str_replace($this->parameterBag->get('kernel.project_dir') . DIRECTORY_SEPARATOR, '', $full);
×
662
                $pathInfo = pathinfo($relativePath);
×
663

664
                $outputFile = $this->generateOutputPath(
×
665
                    $this->parameterBag->get('kernel.project_dir'),
×
666
                    $pathInfo['dirname'] ?? '',
×
667
                    $pathInfo['filename'],
×
668
                    $outputPattern,
×
669
                );
×
670

671
                $outputs[$outputFile] = true;
×
672
            }
673
        } finally {
674
            closedir($handle);
×
675
        }
676
    }
677
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc