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

valksor / php-dev-snapshot / 19641329740

24 Nov 2025 03:43PM UTC coverage: 61.538%. Remained the same
19641329740

push

github

k0d3r1s
strip snapshot

8 of 11 new or added lines in 3 files covered. (72.73%)

67 existing lines in 3 files now uncovered.

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\ContentProcessor;
24
use ValksorDev\Snapshot\Util\OutputGenerator;
25

26
use function array_column;
27
use function array_slice;
28
use function array_sum;
29
use function basename;
30
use function count;
31
use function date;
32
use function dirname;
33
use function explode;
34
use function file_get_contents;
35
use function file_put_contents;
36
use function filesize;
37
use function getmypid;
38
use function implode;
39
use function is_array;
40
use function is_dir;
41
use function is_file;
42
use function mkdir;
43
use function pathinfo;
44
use function realpath;
45
use function round;
46
use function str_contains;
47
use function str_replace;
48
use function strlen;
49
use function strtolower;
50
use function substr_count;
51
use function unlink;
52

53
use const PATHINFO_EXTENSION;
54

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

2✔
83
    public function __construct(
84
        private readonly ParameterBagInterface $parameterBag,
85
    ) {
86
        $projectRoot = $parameterBag->get('kernel.project_dir');
87
        $this->fileFilter = PathFilter::createDefault($projectRoot);
88
    }
1✔
89

90
    public function isRunning(): bool
91
    {
92
        return $this->isRunning;
93
    }
2✔
94

1✔
95
    public function reload(): void
96
    {
97
        // Snapshot service doesn't support reloading as it's a one-time operation
98
    }
99

100
    public function removePidFile(
101
        string $pidFile,
4✔
102
    ): void {
103
        if (is_file($pidFile)) {
104
            unlink($pidFile);
105
        }
106
    }
107

4✔
108
    public function setIo(
109
        SymfonyStyle $io,
110
    ): void {
111
        $this->io = $io;
4✔
112
    }
4✔
113

114
    public function start(
4✔
UNCOV
115
        array $config,
×
116
    ): int {
117
        $this->isRunning = true;
118

119
        try {
4✔
120
            // Use multiple paths if provided, otherwise use default paths
121
            $projectRoot = $this->parameterBag->get('kernel.project_dir');
4✔
UNCOV
122
            $paths = $config['paths'] ?? $config['path'] ?? [$projectRoot];
×
UNCOV
123

×
124
            if (!is_array($paths)) {
125
                $paths = [$paths];
126
            }
127

4✔
128
            // Generate output filename if not provided
129
            $outputFile = $config['output_file'] ?? null;
4✔
UNCOV
130

×
131
            if (null === $outputFile) {
132
                $timestamp = date('Y_m_d_His');
133
                $outputFile = "snapshot_$timestamp.mcp";
134
            }
4✔
135

136
            // Ensure output directory exists
137
            $outputDir = dirname($outputFile);
4✔
138

4✔
139
            if (!is_dir($outputDir)) {
140
                mkdir($outputDir, 0o755, true);
141
            }
4✔
142

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

3✔
146
            // Create custom path filter with user exclusions (like hot reload)
3✔
147
            $excludePatterns = $snapshotConfig['exclude'] ?? [];
148
            $this->fileFilter = PathFilterHelper::createPathFilterWithExclusions($excludePatterns, $this->parameterBag->get('kernel.project_dir'));
3✔
149

150
            // Scan files from all paths
3✔
151
            $maxLines = $config['max_lines'] ?? 1000;
152
            $files = $this->scanMultiplePaths($paths, $maxLines, $config);
153

154
            if (empty($files)) {
1✔
155
                if (isset($this->io)) {
1✔
156
                    $this->io->warning('No files found to process.');
1✔
157
                }
1✔
158
                $this->isRunning = false;
1✔
159

160
                return 0;
1✔
161
            }
162

163
            // Generate output
1✔
UNCOV
164
            $projectName = basename($projectRoot);
×
UNCOV
165
            $stats = [
×
166
                'files_processed' => count($files),
UNCOV
167
                'total_size' => array_sum(array_column($files, 'size')),
×
168
            ];
UNCOV
169

×
170
            $output = OutputGenerator::generate($projectName, $files, $stats);
171

172
            // Write to file
1✔
173
            if (false === file_put_contents($outputFile, $output)) {
1✔
174
                if (isset($this->io)) {
1✔
175
                    $this->io->error("Failed to write output file: $outputFile");
1✔
176
                }
1✔
177
                $this->isRunning = false;
1✔
178

1✔
179
                return 1;
1✔
180
            }
1✔
181

1✔
182
            if (isset($this->io)) {
183
                $this->io->success("Snapshot generated: $outputFile");
184
                $this->io->table(
1✔
185
                    ['Metric', 'Value'],
186
                    [
1✔
UNCOV
187
                        ['Files processed', $stats['files_processed']],
×
UNCOV
188
                        ['Total size', round($stats['total_size'] / 1024, 2) . ' KB'],
×
UNCOV
189
                        ['Output file', $outputFile],
×
190
                    ],
UNCOV
191
                );
×
192
            }
UNCOV
193

×
194
            $this->isRunning = false;
195

196
            return 0;
197
        } catch (Exception $e) {
198
            if (isset($this->io)) {
199
                $this->io->error('Snapshot generation failed: ' . $e->getMessage());
1✔
200
            }
201
            $this->isRunning = false;
202

203
            return 1;
204
        }
205
    }
1✔
206

207
    public function stop(): void
1✔
208
    {
1✔
209
        $this->isRunning = false;
210
    }
211

212
    public function writePidFile(
213
        string $pidFile,
214
    ): void {
215
        $pid = getmypid();
216

217
        if (false !== $pid) {
218
            file_put_contents($pidFile, $pid);
219
        }
220
    }
221

222
    /**
223
     * Process a single file and return its data.
224
     *
1✔
225
     * This method handles content reading, binary detection, and line limiting
226
     * to ensure files are processed efficiently and safely for AI consumption.
1✔
UNCOV
227
     */
×
228
    private function processFile(
229
        string $path,
230
        string $relativePath,
231
        int $maxLines,
1✔
NEW
232
        array $config,
×
233
    ): ?array {
234
        try {
235
            $content = file_get_contents($path);
236

1✔
237
            if (false === $content) {
1✔
238
                return null;
239
            }
1✔
UNCOV
240

×
UNCOV
241
            // Check for binary content in case file filter missed it
×
242
            if (str_contains($content, "\x00")) {
243
                return null;
244
            }
245

1✔
246
            // Apply content processing if strip_comments is enabled
1✔
247
            $stripComments = $config['strip_comments'] ?? false;
1✔
248

1✔
249
            if ($stripComments) {
1✔
250
                $extension = strtolower(pathinfo($relativePath, PATHINFO_EXTENSION));
1✔
251
                $content = ContentProcessor::processContent($content, $extension, true); // Preserve empty lines for structure
1✔
NEW
252
            }
×
NEW
253

×
UNCOV
254
            // Limit lines if specified
×
255
            if ($maxLines > 0) {
256
                $lines = explode("\n", $content);
UNCOV
257

×
258
                if (count($lines) > $maxLines) {
259
                    $content = implode("\n", array_slice($lines, 0, $maxLines));
260
                    $content .= "\n\n# [Truncated at $maxLines lines]";
261
                }
262
            }
263

264
            return [
265
                'path' => $path,
266
                'relative_path' => $relativePath,
267
                'content' => $content,
268
                'size' => strlen($content),
269
                'lines' => substr_count($content, "\n") + 1,
270
            ];
271
        } catch (Exception $e) {
272
            if (isset($this->io) && $this->io->isVerbose()) {
4✔
273
                $this->io->warning("Error processing file $path: " . $e->getMessage());
4✔
274
            }
4✔
275

276
            return null;
4✔
UNCOV
277
        }
×
278
    }
279

280
    /**
281
     * Scan files from a single path with recursive directory traversal.
4✔
282
     *
4✔
283
     * This method uses RecursiveDirectoryIterator for efficient file system
4✔
284
     * traversal and applies filtering rules to exclude unwanted files.
4✔
285
     */
286
    private function scanFiles(
4✔
287
        string $path,
4✔
288
        int $maxLines,
289
        array $config,
290
    ): array {
4✔
291
        $files = [];
4✔
292
        $processedCount = 0;
293
        $realPath = realpath($path);
4✔
294

295
        if (false === $realPath) {
3✔
296
            return $files;
3✔
297
        }
298

299
        // Use RecursiveDirectoryIterator for better performance
300
        $iterator = new RecursiveIteratorIterator(
4✔
301
            new RecursiveDirectoryIterator($realPath, FilesystemIterator::SKIP_DOTS),
3✔
302
            RecursiveIteratorIterator::SELF_FIRST,
303
        );
304

305
        foreach ($iterator as $fileInfo) {
1✔
306
            $path = $fileInfo->getPathname();
307

1✔
308
            // Calculate relative path from project root, not from scan path
1✔
309
            $projectRoot = $this->parameterBag->get('kernel.project_dir');
310
            $relativePath = str_replace($projectRoot . '/', '', $path);
1✔
UNCOV
311

×
312
            if ($fileInfo->isDir()) {
313
                // Use PathFilter like hot reload - directory filtering
314
                if ($this->fileFilter->shouldIgnoreDirectory($fileInfo->getBasename())) {
315
                    $iterator->next(); // Skip this directory
316
                }
1✔
317
            } else {
318
                // Use PathFilter like hot reload - file filtering
1✔
UNCOV
319
                if ($this->fileFilter->shouldIgnorePath($relativePath)) {
×
UNCOV
320
                    continue;
×
321
                }
322

UNCOV
323
                // Apply additional snapshot-specific limits not handled by PathFilter
×
324
                $maxFileSize = ($config['max_file_size'] ?? 1024) * 1024; // Convert KB to bytes
325

326
                if ($maxFileSize > 0) {
327
                    $size = filesize($path);
1✔
328

329
                    if (false !== $size && $size > $maxFileSize) {
1✔
330
                        continue;
1✔
331
                    }
1✔
332
                }
333

334
                // Check file limit BEFORE processing this file
335
                $maxFiles = $config['max_files'] ?? 500;
336

4✔
337
                if ($maxFiles > 0 && $processedCount >= $maxFiles) {
338
                    if (isset($this->io)) {
339
                        $this->io->warning("Maximum file limit ($maxFiles) reached. Processed $processedCount files.");
340
                    }
341

342
                    break;
343
                }
344

345
                // Process file
346
                $fileData = $this->processFile($path, $relativePath, $maxLines, $config);
347

348
                if (null !== $fileData) {
349
                    $files[] = $fileData;
350
                    $processedCount++;
4✔
351
                }
4✔
352
            }
353
        }
354

4✔
355
        return $files;
356
    }
4✔
357

358
    /**
4✔
UNCOV
359
     * Scan files from multiple paths and merge results.
×
UNCOV
360
     *
×
361
     * This method handles path validation, file limit enforcement across
362
     * all paths, and merges results while maintaining processing statistics.
UNCOV
363
     */
×
364
    private function scanMultiplePaths(
365
        array $paths,
366
        int $maxLines,
367
        array $config,
4✔
UNCOV
368
    ): array {
×
UNCOV
369
        $allFiles = [];
×
370
        $processedCount = 0;
371

UNCOV
372
        // Get maxFiles from the dynamic config (from command line, not static config)
×
373
        $maxFiles = $config['max_files'] ?? 500;
374

375
        foreach ($paths as $path) {
376
            // Check file limit before scanning each path
377
            if ($maxFiles > 0 && $processedCount >= $maxFiles) {
4✔
378
                if (isset($this->io)) {
379
                    $this->io->warning("Maximum file limit ($maxFiles) reached. Processed $processedCount files.");
1✔
UNCOV
380
                }
×
UNCOV
381

×
382
                break;
383
            }
UNCOV
384

×
385
            // Validate path
386
            if (!is_dir($path)) {
387
                if (isset($this->io)) {
1✔
388
                    $this->io->warning("Path does not exist or is not a directory: $path");
1✔
389
                }
390

391
                continue;
392
            }
4✔
393

394
            // Update path for validation and scanning
395
            // Merge files from this path
396
            foreach ($this->scanFiles($path, $maxLines, $config) as $file) {
397
                // Check global file limit
398
                if ($maxFiles > 0 && $processedCount >= $maxFiles) {
399
                    if (isset($this->io)) {
400
                        $this->io->warning("Maximum file limit ($maxFiles) reached. Processed $processedCount files.");
401
                    }
402

403
                    break;
404
                }
405

406
                $allFiles[] = $file;
407
                $processedCount++;
408
            }
409
        }
410

411
        return $allFiles;
412
    }
413
}
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