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

valksor / php-dev-snapshot / 19634210989

24 Nov 2025 12:21PM UTC coverage: 61.538%. First build
19634210989

push

github

k0d3r1s
add valksor-dev snapshot

336 of 546 new or added lines in 4 files covered. (61.54%)

336 of 546 relevant lines covered (61.54%)

2.88 hits per line

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

74.07
/Service/SnapshotService.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\Snapshot\Service;
14

15
use Exception;
16
use FilesystemIterator;
17
use RecursiveDirectoryIterator;
18
use RecursiveIteratorIterator;
19
use Symfony\Component\Console\Style\SymfonyStyle;
20
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
21
use Valksor\Bundle\Service\PathFilter;
22
use Valksor\Bundle\Service\PathFilterHelper;
23
use ValksorDev\Snapshot\Util\OutputGenerator;
24

25
use function array_column;
26
use function array_slice;
27
use function array_sum;
28
use function basename;
29
use function count;
30
use function date;
31
use function dirname;
32
use function explode;
33
use function file_put_contents;
34
use function getmypid;
35
use function implode;
36
use function is_array;
37
use function is_dir;
38
use function mkdir;
39
use function realpath;
40
use function str_contains;
41
use function str_replace;
42
use function strlen;
43
use function unlink;
44

45
/**
46
 * Service for generating MCP (Markdown Context Pack) snapshots of projects.
47
 *
48
 * This service provides intelligent file scanning and content analysis to create
49
 * AI-optimized project documentation. It combines advanced filtering capabilities
50
 * with binary detection and content limiting to produce focused, useful snapshots
51
 * for AI consumption.
52
 *
53
 * Key Features:
54
 * - Multi-path scanning with configurable limits
55
 * - Binary file detection and exclusion
56
 * - Gitignore integration for intelligent filtering
57
 * - Content size and line limiting
58
 * - MCP format output generation
59
 * - Comprehensive file type support
60
 *
61
 * Use Cases:
62
 * - AI code analysis and review
63
 * - Project documentation generation
64
 * - Code base summarization
65
 * - Knowledge base creation
66
 */
67
final class SnapshotService
68
{
69
    private PathFilter $fileFilter;
70
    private SymfonyStyle $io;
71
    private bool $isRunning = false;
72

73
    public function __construct(
74
        private readonly ParameterBagInterface $parameterBag,
75
    ) {
76
        $projectRoot = $parameterBag->get('kernel.project_dir');
11✔
77
        $this->fileFilter = PathFilter::createDefault($projectRoot);
11✔
78
    }
79

80
    public function isRunning(): bool
81
    {
82
        return $this->isRunning;
2✔
83
    }
84

85
    public function reload(): void
86
    {
87
        // Snapshot service doesn't support reloading as it's a one-time operation
88
    }
1✔
89

90
    public function removePidFile(
91
        string $pidFile,
92
    ): void {
93
        if (is_file($pidFile)) {
2✔
94
            unlink($pidFile);
1✔
95
        }
96
    }
97

98
    public function setIo(
99
        SymfonyStyle $io,
100
    ): void {
101
        $this->io = $io;
4✔
102
    }
103

104
    public function start(
105
        array $config,
106
    ): int {
107
        $this->isRunning = true;
4✔
108

109
        try {
110
            // Use multiple paths if provided, otherwise use default paths
111
            $projectRoot = $this->parameterBag->get('kernel.project_dir');
4✔
112
            $paths = $config['paths'] ?? $config['path'] ?? [$projectRoot];
4✔
113

114
            if (!is_array($paths)) {
4✔
NEW
115
                $paths = [$paths];
×
116
            }
117

118
            // Generate output filename if not provided
119
            $outputFile = $config['output_file'] ?? null;
4✔
120

121
            if (null === $outputFile) {
4✔
NEW
122
                $timestamp = date('Y_m_d_His');
×
NEW
123
                $outputFile = "snapshot_$timestamp.mcp";
×
124
            }
125

126
            // Ensure output directory exists
127
            $outputDir = dirname($outputFile);
4✔
128

129
            if (!is_dir($outputDir)) {
4✔
NEW
130
                mkdir($outputDir, 0o755, true);
×
131
            }
132

133
            // Get snapshot configuration from service-based structure (like hot reload)
134
            $snapshotConfig = $this->parameterBag->get('valksor.snapshot.options');
4✔
135

136
            // Create custom path filter with user exclusions (like hot reload)
137
            $excludePatterns = $snapshotConfig['exclude'] ?? [];
4✔
138
            $this->fileFilter = PathFilterHelper::createPathFilterWithExclusions($excludePatterns, $this->parameterBag->get('kernel.project_dir'));
4✔
139

140
            // Scan files from all paths
141
            $maxLines = $config['max_lines'] ?? 1000;
4✔
142
            $files = $this->scanMultiplePaths($paths, $maxLines, $config);
4✔
143

144
            if (empty($files)) {
4✔
145
                if (isset($this->io)) {
3✔
146
                    $this->io->warning('No files found to process.');
3✔
147
                }
148
                $this->isRunning = false;
3✔
149

150
                return 0;
3✔
151
            }
152

153
            // Generate output
154
            $projectName = basename($projectRoot);
1✔
155
            $stats = [
1✔
156
                'files_processed' => count($files),
1✔
157
                'total_size' => array_sum(array_column($files, 'size')),
1✔
158
            ];
1✔
159

160
            $output = OutputGenerator::generate($projectName, $files, $stats);
1✔
161

162
            // Write to file
163
            if (false === file_put_contents($outputFile, $output)) {
1✔
NEW
164
                if (isset($this->io)) {
×
NEW
165
                    $this->io->error("Failed to write output file: $outputFile");
×
166
                }
NEW
167
                $this->isRunning = false;
×
168

NEW
169
                return 1;
×
170
            }
171

172
            if (isset($this->io)) {
1✔
173
                $this->io->success("Snapshot generated: $outputFile");
1✔
174
                $this->io->table(
1✔
175
                    ['Metric', 'Value'],
1✔
176
                    [
1✔
177
                        ['Files processed', $stats['files_processed']],
1✔
178
                        ['Total size', round($stats['total_size'] / 1024, 2) . ' KB'],
1✔
179
                        ['Output file', $outputFile],
1✔
180
                    ],
1✔
181
                );
1✔
182
            }
183

184
            $this->isRunning = false;
1✔
185

186
            return 0;
1✔
NEW
187
        } catch (Exception $e) {
×
NEW
188
            if (isset($this->io)) {
×
NEW
189
                $this->io->error('Snapshot generation failed: ' . $e->getMessage());
×
190
            }
NEW
191
            $this->isRunning = false;
×
192

NEW
193
            return 1;
×
194
        }
195
    }
196

197
    public function stop(): void
198
    {
199
        $this->isRunning = false;
1✔
200
    }
201

202
    public function writePidFile(
203
        string $pidFile,
204
    ): void {
205
        $pid = getmypid();
1✔
206

207
        if (false !== $pid) {
1✔
208
            file_put_contents($pidFile, $pid);
1✔
209
        }
210
    }
211

212
    /**
213
     * Process a single file and return its data.
214
     *
215
     * This method handles content reading, binary detection, and line limiting
216
     * to ensure files are processed efficiently and safely for AI consumption.
217
     */
218
    private function processFile(
219
        string $path,
220
        string $relativePath,
221
        int $maxLines,
222
    ): ?array {
223
        try {
224
            $content = file_get_contents($path);
1✔
225

226
            if (false === $content) {
1✔
NEW
227
                return null;
×
228
            }
229

230
            // Check for binary content in case file filter missed it
231
            if (str_contains($content, "\x00")) {
1✔
NEW
232
                return null;
×
233
            }
234

235
            // Limit lines if specified
236
            if ($maxLines > 0) {
1✔
237
                $lines = explode("\n", $content);
1✔
238

239
                if (count($lines) > $maxLines) {
1✔
NEW
240
                    $content = implode("\n", array_slice($lines, 0, $maxLines));
×
NEW
241
                    $content .= "\n\n# [Truncated at $maxLines lines]";
×
242
                }
243
            }
244

245
            return [
1✔
246
                'path' => $path,
1✔
247
                'relative_path' => $relativePath,
1✔
248
                'content' => $content,
1✔
249
                'size' => strlen($content),
1✔
250
                'lines' => substr_count($content, "\n") + 1,
1✔
251
            ];
1✔
NEW
252
        } catch (Exception $e) {
×
NEW
253
            if (isset($this->io) && $this->io->isVerbose()) {
×
NEW
254
                $this->io->warning("Error processing file $path: " . $e->getMessage());
×
255
            }
256

NEW
257
            return null;
×
258
        }
259
    }
260

261
    /**
262
     * Scan files from a single path with recursive directory traversal.
263
     *
264
     * This method uses RecursiveDirectoryIterator for efficient file system
265
     * traversal and applies filtering rules to exclude unwanted files.
266
     */
267
    private function scanFiles(
268
        string $path,
269
        int $maxLines,
270
        array $config,
271
    ): array {
272
        $files = [];
4✔
273
        $processedCount = 0;
4✔
274
        $realPath = realpath($path);
4✔
275

276
        if (false === $realPath) {
4✔
NEW
277
            return $files;
×
278
        }
279

280
        // Use RecursiveDirectoryIterator for better performance
281
        $iterator = new RecursiveIteratorIterator(
4✔
282
            new RecursiveDirectoryIterator($realPath, FilesystemIterator::SKIP_DOTS),
4✔
283
            RecursiveIteratorIterator::SELF_FIRST,
4✔
284
        );
4✔
285

286
        foreach ($iterator as $fileInfo) {
4✔
287
            $path = $fileInfo->getPathname();
4✔
288

289
            // Calculate relative path from project root, not from scan path
290
            $projectRoot = $this->parameterBag->get('kernel.project_dir');
4✔
291
            $relativePath = str_replace($projectRoot . '/', '', $path);
4✔
292

293
            if ($fileInfo->isDir()) {
4✔
294
                // Use PathFilter like hot reload - directory filtering
295
                if ($this->fileFilter->shouldIgnoreDirectory($fileInfo->getBasename())) {
3✔
296
                    $iterator->next(); // Skip this directory
3✔
297
                }
298
            } else {
299
                // Use PathFilter like hot reload - file filtering
300
                if ($this->fileFilter->shouldIgnorePath($relativePath)) {
4✔
301
                    continue;
3✔
302
                }
303

304
                // Apply additional snapshot-specific limits not handled by PathFilter
305
                $maxFileSize = ($config['max_file_size'] ?? 1024) * 1024; // Convert KB to bytes
1✔
306

307
                if ($maxFileSize > 0) {
1✔
308
                    $size = filesize($path);
1✔
309

310
                    if (false !== $size && $size > $maxFileSize) {
1✔
NEW
311
                        continue;
×
312
                    }
313
                }
314

315
                // Check file limit BEFORE processing this file
316
                $maxFiles = $config['max_files'] ?? 500;
1✔
317

318
                if ($maxFiles > 0 && $processedCount >= $maxFiles) {
1✔
NEW
319
                    if (isset($this->io)) {
×
NEW
320
                        $this->io->warning("Maximum file limit ($maxFiles) reached. Processed $processedCount files.");
×
321
                    }
322

NEW
323
                    break;
×
324
                }
325

326
                // Process file
327
                $fileData = $this->processFile($path, $relativePath, $maxLines);
1✔
328

329
                if (null !== $fileData) {
1✔
330
                    $files[] = $fileData;
1✔
331
                    $processedCount++;
1✔
332
                }
333
            }
334
        }
335

336
        return $files;
4✔
337
    }
338

339
    /**
340
     * Scan files from multiple paths and merge results.
341
     *
342
     * This method handles path validation, file limit enforcement across
343
     * all paths, and merges results while maintaining processing statistics.
344
     */
345
    private function scanMultiplePaths(
346
        array $paths,
347
        int $maxLines,
348
        array $config,
349
    ): array {
350
        $allFiles = [];
4✔
351
        $processedCount = 0;
4✔
352

353
        // Get maxFiles from the dynamic config (from command line, not static config)
354
        $maxFiles = $config['max_files'] ?? 500;
4✔
355

356
        foreach ($paths as $path) {
4✔
357
            // Check file limit before scanning each path
358
            if ($maxFiles > 0 && $processedCount >= $maxFiles) {
4✔
NEW
359
                if (isset($this->io)) {
×
NEW
360
                    $this->io->warning("Maximum file limit ($maxFiles) reached. Processed $processedCount files.");
×
361
                }
362

NEW
363
                break;
×
364
            }
365

366
            // Validate path
367
            if (!is_dir($path)) {
4✔
NEW
368
                if (isset($this->io)) {
×
NEW
369
                    $this->io->warning("Path does not exist or is not a directory: $path");
×
370
                }
371

NEW
372
                continue;
×
373
            }
374

375
            // Update path for validation and scanning
376
            // Merge files from this path
377
            foreach ($this->scanFiles($path, $maxLines, $config) as $file) {
4✔
378
                // Check global file limit
379
                if ($maxFiles > 0 && $processedCount >= $maxFiles) {
1✔
NEW
380
                    if (isset($this->io)) {
×
NEW
381
                        $this->io->warning("Maximum file limit ($maxFiles) reached. Processed $processedCount files.");
×
382
                    }
383

NEW
384
                    break;
×
385
                }
386

387
                $allFiles[] = $file;
1✔
388
                $processedCount++;
1✔
389
            }
390
        }
391

392
        return $allFiles;
4✔
393
    }
394
}
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