From 3b4facca82dd90e079e8e13acb78a75da6f8ec81 Mon Sep 17 00:00:00 2001 From: butschster Date: Thu, 1 May 2025 22:41:09 +0200 Subject: [PATCH 1/3] feat: Add global exclusion pattern system Implements a global configuration option to exclude sensitive files from being included in any context document. This system allows for: - Global pattern-based exclusions (glob patterns like `**/*.key`) - Path-specific exclusions (directories and specific files) --- docs/example/config/exclude-config.yaml | 28 +++++ json-schema.json | 22 +++- .../Bootloader/ExcludeBootloader.php | 29 +++++ src/Application/Kernel.php | 4 +- src/Config/Exclude/AbstractExclusion.php | 42 +++++++ src/Config/Exclude/ExcludeParserPlugin.php | 97 ++++++++++++++++ src/Config/Exclude/ExcludeRegistry.php | 105 ++++++++++++++++++ .../Exclude/ExcludeRegistryInterface.php | 28 +++++ .../Exclude/ExclusionPatternInterface.php | 21 ++++ src/Config/Exclude/PathExclusion.php | 42 +++++++ src/Config/Exclude/PatternExclusion.php | 41 +++++++ src/Config/Import/PathMatcher.php | 30 ++++- src/Config/context.yaml | 7 ++ src/Source/Composer/ComposerSourceFetcher.php | 4 +- src/Source/File/FileSourceBootloader.php | 1 + src/Source/File/FileSourceFetcher.php | 2 +- src/Source/File/SymfonyFinder.php | 34 +++++- src/Source/context.yaml | 15 +-- .../FileSource/exclude-test.yaml | 22 ++++ .../GenerateCommand/CompilingResult.php | 2 +- .../Console/GenerateCommand/ExcludeTest.php | 103 +++++++++++++++++ tests/src/context.yaml | 4 +- 22 files changed, 656 insertions(+), 27 deletions(-) create mode 100644 docs/example/config/exclude-config.yaml create mode 100644 src/Application/Bootloader/ExcludeBootloader.php create mode 100644 src/Config/Exclude/AbstractExclusion.php create mode 100644 src/Config/Exclude/ExcludeParserPlugin.php create mode 100644 src/Config/Exclude/ExcludeRegistry.php create mode 100644 src/Config/Exclude/ExcludeRegistryInterface.php create mode 100644 src/Config/Exclude/ExclusionPatternInterface.php create mode 100644 src/Config/Exclude/PathExclusion.php create mode 100644 src/Config/Exclude/PatternExclusion.php create mode 100644 tests/fixtures/Console/GenerateCommand/FileSource/exclude-test.yaml create mode 100644 tests/src/Feature/Console/GenerateCommand/ExcludeTest.php diff --git a/docs/example/config/exclude-config.yaml b/docs/example/config/exclude-config.yaml new file mode 100644 index 00000000..ef8977b1 --- /dev/null +++ b/docs/example/config/exclude-config.yaml @@ -0,0 +1,28 @@ +# Example configuration with exclusion patterns + +# Global exclusion patterns +exclude: + # File patterns to exclude globally (glob patterns with wildcards) + patterns: + - "**/.env*" + - "**/config/secrets.yaml" + - "**/*.pem" + - "**/*.key" + - "**/id_rsa" + - "**/credentials.json" + + # Paths to exclude globally (exact directories or files) + paths: + - ".secrets/" + - "config/credentials/" + - "node_modules" + - "vendor" + +# Regular configuration continues +documents: + - description: "Project Documentation" + outputPath: "docs/project.md" + sources: + - type: file + sourcePaths: + - "src/" diff --git a/json-schema.json b/json-schema.json index f3b44ebf..0903dbea 100644 --- a/json-schema.json +++ b/json-schema.json @@ -44,6 +44,26 @@ "$ref": "#/definitions/prompt" } }, + "exclude": { + "type": "object", + "description": "Global exclusion patterns for filtering files from being included in documents", + "properties": { + "patterns": { + "type": "array", + "description": "Glob patterns to exclude files globally (e.g., '**/.env*', '**/*.pem')", + "items": { + "type": "string" + } + }, + "paths": { + "type": "array", + "description": "Specific paths to exclude globally (directories or files)", + "items": { + "type": "string" + } + } + } + }, "variables": { "type": "object", "description": "Custom variables to use throughout the configuration", @@ -705,7 +725,7 @@ } } }, - "allOf": [ + "anyOf": [ { "if": { "properties": { diff --git a/src/Application/Bootloader/ExcludeBootloader.php b/src/Application/Bootloader/ExcludeBootloader.php new file mode 100644 index 00000000..801ac20c --- /dev/null +++ b/src/Application/Bootloader/ExcludeBootloader.php @@ -0,0 +1,29 @@ + ExcludeRegistry::class, + ]; + } + + public function boot(ConfigLoaderBootloader $configLoader, ExcludeParserPlugin $excludeParser): void + { + // Register the exclude parser plugin + $configLoader->registerParserPlugin($excludeParser); + } +} diff --git a/src/Application/Kernel.php b/src/Application/Kernel.php index cc393508..564b0c26 100644 --- a/src/Application/Kernel.php +++ b/src/Application/Kernel.php @@ -10,6 +10,7 @@ use Butschster\ContextGenerator\Application\Bootloader\ConsoleBootloader; use Butschster\ContextGenerator\Application\Bootloader\ContentRendererBootloader; use Butschster\ContextGenerator\Application\Bootloader\CoreBootloader; +use Butschster\ContextGenerator\Application\Bootloader\ExcludeBootloader; use Butschster\ContextGenerator\Application\Bootloader\GithubClientBootloader; use Butschster\ContextGenerator\Application\Bootloader\GitlabClientBootloader; use Butschster\ContextGenerator\Application\Bootloader\HttpClientBootloader; @@ -58,9 +59,10 @@ protected function defineBootloaders(): array GithubClientBootloader::class, ComposerClientBootloader::class, ConfigLoaderBootloader::class, + VariableBootloader::class, + ExcludeBootloader::class, ModifierBootloader::class, ContentRendererBootloader::class, - VariableBootloader::class, SourceFetcherBootloader::class, SourceRegistryBootloader::class, diff --git a/src/Config/Exclude/AbstractExclusion.php b/src/Config/Exclude/AbstractExclusion.php new file mode 100644 index 00000000..f21835ad --- /dev/null +++ b/src/Config/Exclude/AbstractExclusion.php @@ -0,0 +1,42 @@ +pattern; + } + + /** + * Normalize a pattern for consistent comparison + */ + protected function normalizePattern(string $pattern): string + { + $pattern = \preg_replace('#^\./#', '', $pattern); + + // Remove trailing slash + return \rtrim($pattern, '/'); + } + + /** + * Abstract method to check if a path matches this pattern + */ + abstract public function matches(string $path): bool; +} diff --git a/src/Config/Exclude/ExcludeParserPlugin.php b/src/Config/Exclude/ExcludeParserPlugin.php new file mode 100644 index 00000000..2a7029c4 --- /dev/null +++ b/src/Config/Exclude/ExcludeParserPlugin.php @@ -0,0 +1,97 @@ +supports($config)) { + return null; + } + + $excludeConfig = $config['exclude']; + + // Parse patterns + if (isset($excludeConfig['patterns']) && \is_array($excludeConfig['patterns'])) { + $this->parsePatterns($excludeConfig['patterns']); + } + + // Parse paths + if (isset($excludeConfig['paths']) && \is_array($excludeConfig['paths'])) { + $this->parsePaths($excludeConfig['paths']); + } + + $this->logger?->info('Parsed exclusion configuration', [ + 'patternCount' => \count($this->registry->getPatterns()), + ]); + + return $this->registry; + } + + public function updateConfig(array $config, string $rootPath): array + { + // We don't need to modify the config, just return it as is + return $config; + } + + /** + * Parse glob pattern exclusions + */ + private function parsePatterns(array $patterns): void + { + foreach ($patterns as $pattern) { + if (!\is_string($pattern) || empty($pattern)) { + $this->logger?->warning('Invalid exclusion pattern, skipping', [ + 'pattern' => $pattern, + ]); + continue; + } + + $this->registry->addPattern(new PatternExclusion($pattern)); + } + } + + /** + * Parse path exclusions + */ + private function parsePaths(array $paths): void + { + foreach ($paths as $path) { + if (!\is_string($path) || empty($path)) { + $this->logger?->warning('Invalid exclusion path, skipping', [ + 'path' => $path, + ]); + continue; + } + + $this->registry->addPattern(new PathExclusion($path)); + } + } +} diff --git a/src/Config/Exclude/ExcludeRegistry.php b/src/Config/Exclude/ExcludeRegistry.php new file mode 100644 index 00000000..94a47d6b --- /dev/null +++ b/src/Config/Exclude/ExcludeRegistry.php @@ -0,0 +1,105 @@ + + */ +#[Singleton] +final class ExcludeRegistry implements ExcludeRegistryInterface, RegistryInterface +{ + /** @var array */ + private array $patterns = []; + + public function __construct( + #[LoggerPrefix(prefix: 'exclude-registry')] + private readonly ?LoggerInterface $logger = null, + ) {} + + /** + * Add a new exclusion pattern + */ + public function addPattern(ExclusionPatternInterface $pattern): self + { + $this->patterns[] = $pattern; + + $this->logger?->debug('Added exclusion pattern', [ + 'pattern' => $pattern->getPattern(), + ]); + + return $this; + } + + /** + * Check if a path should be excluded + */ + public function shouldExclude(string $path): bool + { + foreach ($this->patterns as $pattern) { + if ($pattern->matches($path)) { + $this->logger?->debug('Path excluded by pattern', [ + 'path' => $path, + 'pattern' => $pattern->getPattern(), + ]); + + return true; + } + } + + return false; + } + + /** + * Get all registered exclusion patterns + */ + public function getPatterns(): array + { + return $this->patterns; + } + + /** + * Get the registry type + */ + public function getType(): string + { + return 'exclude'; + } + + /** + * Get all items in the registry + */ + public function getItems(): array + { + return $this->patterns; + } + + /** + * Make the registry iterable + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->patterns); + } + + /** + * JSON serialization + */ + public function jsonSerialize(): array + { + return [ + 'patterns' => \array_map( + static fn(ExclusionPatternInterface $pattern) => $pattern->jsonSerialize(), + $this->patterns, + ), + ]; + } +} diff --git a/src/Config/Exclude/ExcludeRegistryInterface.php b/src/Config/Exclude/ExcludeRegistryInterface.php new file mode 100644 index 00000000..adf3c6d5 --- /dev/null +++ b/src/Config/Exclude/ExcludeRegistryInterface.php @@ -0,0 +1,28 @@ + + */ + public function getPatterns(): array; +} diff --git a/src/Config/Exclude/ExclusionPatternInterface.php b/src/Config/Exclude/ExclusionPatternInterface.php new file mode 100644 index 00000000..f23f94dc --- /dev/null +++ b/src/Config/Exclude/ExclusionPatternInterface.php @@ -0,0 +1,21 @@ +normalizedPattern = $this->normalizePattern($pattern); + } + + /** + * Check if a path matches this exclusion pattern + * + * A path matches if it's exactly the same as the pattern + * or if it's a file within the directory specified by the pattern + */ + public function matches(string $path): bool + { + $normalizedPath = $this->normalizePattern($path); + + return $normalizedPath === $this->normalizedPattern || + \str_contains($normalizedPath, $this->normalizedPattern); + } + + public function jsonSerialize(): array + { + return [ + 'pattern' => $this->pattern, + ]; + } +} diff --git a/src/Config/Exclude/PatternExclusion.php b/src/Config/Exclude/PatternExclusion.php new file mode 100644 index 00000000..e7a3660f --- /dev/null +++ b/src/Config/Exclude/PatternExclusion.php @@ -0,0 +1,41 @@ +matcher = new PathMatcher($pattern); + } + + /** + * Check if a path matches this exclusion pattern + * + * Uses glob pattern matching via PathMatcher + */ + public function matches(string $path): bool + { + return $this->matcher->isMatch($path); + } + + public function jsonSerialize(): array + { + return [ + 'pattern' => $this->pattern, + ]; + } +} diff --git a/src/Config/Import/PathMatcher.php b/src/Config/Import/PathMatcher.php index a123db4d..696d99bd 100644 --- a/src/Config/Import/PathMatcher.php +++ b/src/Config/Import/PathMatcher.php @@ -16,7 +16,7 @@ */ public function __construct(private string $pattern) { - $this->regex = $this->globToRegex($this->pattern); + $this->regex = $this->isRegex($this->pattern) ? $this->pattern : $this->globToRegex($this->pattern); } /** @@ -68,10 +68,9 @@ private function globToRegex(string $pattern): string $escaping = false; $inSquareBrackets = false; $inCurlyBraces = false; - $regex = ''; // Start at the beginning of the string - $regex .= '~^'; + $regex = '~^'; $length = \strlen($pattern); for ($i = 0; $i < $length; $i++) { @@ -169,4 +168,29 @@ private function globToRegex(string $pattern): string return $regex; } + + /** + * Checks whether the string is a regex. + */ + private function isRegex(string $str): bool + { + $availableModifiers = 'imsxuADUn'; + + if (preg_match('/^(.{3,}?)[' . $availableModifiers . ']*$/', $str, $m)) { + $start = substr($m[1], 0, 1); + $end = substr($m[1], -1); + + if ($start === $end) { + return !preg_match('/[*?[:alnum:] \\\\]/', $start); + } + + foreach ([['{', '}'], ['(', ')'], ['[', ']'], ['<', '>']] as $delimiters) { + if ($start === $delimiters[0] && $end === $delimiters[1]) { + return true; + } + } + } + + return false; + } } diff --git a/src/Config/context.yaml b/src/Config/context.yaml index 3f98bf39..d1eae9f5 100644 --- a/src/Config/context.yaml +++ b/src/Config/context.yaml @@ -17,6 +17,13 @@ documents: sourcePaths: - ./Import + - description: Configuration Exclude + outputPath: core/config-exclude.md + sources: + - type: file + sourcePaths: + - ./Exclude + - description: Configuration Parser outputPath: core/config-parser.md sources: diff --git a/src/Source/Composer/ComposerSourceFetcher.php b/src/Source/Composer/ComposerSourceFetcher.php index 5b356ae0..fa3b5989 100644 --- a/src/Source/Composer/ComposerSourceFetcher.php +++ b/src/Source/Composer/ComposerSourceFetcher.php @@ -28,9 +28,9 @@ public function __construct( private ComposerProviderInterface $provider, + SymfonyFinder $finder, private string $basePath = '.', private ContentBuilderFactory $builderFactory = new ContentBuilderFactory(), - private FileTreeBuilder $treeBuilder = new FileTreeBuilder(), private VariableResolver $variableResolver = new VariableResolver(), #[LoggerPrefix(prefix: 'composer-source-fetcher')] private ?LoggerInterface $logger = null, @@ -38,7 +38,7 @@ public function __construct( // Create a FileSourceFetcher to handle the actual file fetching $this->fileSourceFetcher = new FileSourceFetcher( basePath: $this->basePath, - finder: new SymfonyFinder($this->treeBuilder), + finder: $finder, builderFactory: $this->builderFactory, logger: $this->logger instanceof LoggerInterface ? $this->logger : new NullLogger(), ); diff --git a/src/Source/File/FileSourceBootloader.php b/src/Source/File/FileSourceBootloader.php index bcbe5a1c..a9e35e65 100644 --- a/src/Source/File/FileSourceBootloader.php +++ b/src/Source/File/FileSourceBootloader.php @@ -25,6 +25,7 @@ public function defineSingletons(): array HasPrefixLoggerInterface $logger, ): FileSourceFetcher => $factory->make(FileSourceFetcher::class, [ 'basePath' => (string) $dirs->getRootPath(), + 'finder' => $factory->make(SymfonyFinder::class), ]), ]; } diff --git a/src/Source/File/FileSourceFetcher.php b/src/Source/File/FileSourceFetcher.php index 50a494c9..179c5059 100644 --- a/src/Source/File/FileSourceFetcher.php +++ b/src/Source/File/FileSourceFetcher.php @@ -21,7 +21,7 @@ { public function __construct( private string $basePath, - private FinderInterface $finder = new SymfonyFinder(), + private FinderInterface $finder, private ContentBuilderFactory $builderFactory = new ContentBuilderFactory(), #[LoggerPrefix(prefix: 'file-source')] private ?LoggerInterface $logger = null, diff --git a/src/Source/File/SymfonyFinder.php b/src/Source/File/SymfonyFinder.php index 50749af6..c11df87a 100644 --- a/src/Source/File/SymfonyFinder.php +++ b/src/Source/File/SymfonyFinder.php @@ -4,6 +4,7 @@ namespace Butschster\ContextGenerator\Source\File; +use Butschster\ContextGenerator\Config\Exclude\ExcludeRegistryInterface; use Butschster\ContextGenerator\Lib\Finder\FinderInterface; use Butschster\ContextGenerator\Lib\Finder\FinderResult; use Butschster\ContextGenerator\Lib\TreeBuilder\FileTreeBuilder; @@ -16,6 +17,7 @@ final readonly class SymfonyFinder implements FinderInterface { public function __construct( + private ExcludeRegistryInterface $excludeRegistry, private FileTreeBuilder $fileTreeBuilder = new FileTreeBuilder(), ) {} @@ -24,6 +26,7 @@ public function __construct( * * @param FilterableSourceInterface $source Source configuration with filter criteria * @param string $basePath Optional base path to normalize file paths in the tree view + * @param array $options Additional options for the finder * @return FinderResult The result containing found files and tree view */ public function find(FilterableSourceInterface $source, string $basePath = '', array $options = []): FinderResult @@ -110,6 +113,8 @@ public function find(FilterableSourceInterface $source, string $basePath = '', a $count = 0; foreach ($finder as $file) { + trap($file); + $limitedFiles[] = $file; $count++; @@ -124,9 +129,20 @@ public function find(FilterableSourceInterface $source, string $basePath = '', a ); } - // No limit, return all files + // No limit, filter out excluded files + $files = []; + foreach ($finder as $file) { + // Skip files that would be excluded by path patterns + if ($this->shouldExcludeFile($file->getPathname())) { + continue; + } + + $files[] = $file; + } + + // Return filtered files return new FinderResult( - files: \array_values(\iterator_to_array($finder->getIterator())), + files: $files, treeView: $treeView, ); } @@ -136,6 +152,7 @@ public function find(FilterableSourceInterface $source, string $basePath = '', a * * @param Finder $finder The Symfony Finder instance with results * @param string $basePath Optional base path to normalize file paths + * @param array $options Additional options for tree view generation * @return string Text representation of the file tree */ private function generateTreeView(Finder $finder, string $basePath, array $options): string @@ -143,6 +160,11 @@ private function generateTreeView(Finder $finder, string $basePath, array $optio $filePaths = []; foreach ($finder as $file) { + // Skip excluded files in tree view + if ($this->shouldExcludeFile($file->getPathname())) { + continue; + } + $filePaths[] = $file->getRealPath(); } @@ -152,4 +174,12 @@ private function generateTreeView(Finder $finder, string $basePath, array $optio return $this->fileTreeBuilder->buildTree($filePaths, $basePath, $options); } + + /** + * Check if a file should be excluded based on global exclusion patterns + */ + private function shouldExcludeFile(string $filePath): bool + { + return $this->excludeRegistry->shouldExclude($filePath); + } } diff --git a/src/Source/context.yaml b/src/Source/context.yaml index 14218282..14fdca94 100644 --- a/src/Source/context.yaml +++ b/src/Source/context.yaml @@ -18,7 +18,6 @@ documents: - type: file sourcePaths: ./File filePattern: '*.php' - showTreeView: true - description: '[source] GitHub' outputPath: sources/github-source.md @@ -29,7 +28,6 @@ documents: - ../Lib/GithubClient/GithubClientInterface.php - ../Lib/PathFilter filePattern: '*.php' - showTreeView: true - description: '[source] Gitlab' outputPath: sources/gitlab-source.md @@ -40,7 +38,6 @@ documents: - ../Lib/GitlabClient/GitlabClientInterface.php - ../Lib/PathFilter filePattern: '*.php' - showTreeView: true - description: '[source] URL' outputPath: sources/url-source.md @@ -51,7 +48,6 @@ documents: - ./Lib/Html - ../Lib/HttpClient/HttpClientInterface.php filePattern: '*.php' - showTreeView: true - description: '[source] Docs' outputPath: sources/docs-source.md @@ -61,7 +57,6 @@ documents: - ./Docs - ../Lib/HttpClient/HttpClientInterface.php filePattern: '*.php' - showTreeView: true - description: '[source] Text' outputPath: sources/text-source.md @@ -69,7 +64,6 @@ documents: - type: file sourcePaths: ./Text filePattern: '*.php' - showTreeView: true - description: '[source] MCP' outputPath: sources/mcp-source.md @@ -77,7 +71,6 @@ documents: - type: file sourcePaths: ./MCP filePattern: '*.php' - showTreeView: true - description: '[source] Git Diff' outputPath: sources/git-diff-source.md @@ -85,7 +78,6 @@ documents: - type: file sourcePaths: ./GitDiff filePattern: '*.php' - showTreeView: true - description: '[source] Composer' outputPath: sources/composer-source.md @@ -93,7 +85,6 @@ documents: - type: file sourcePaths: ./Composer filePattern: '*.php' - showTreeView: true - description: '[source] Tree' outputPath: sources/tree-source.md @@ -101,7 +92,6 @@ documents: - type: file sourcePaths: ./Tree filePattern: '*.php' - showTreeView: true - description: Source Tree builder outputPath: sources/tree-builder.md @@ -109,7 +99,6 @@ documents: - type: file sourcePaths: ../Lib/TreeBuilder filePattern: '*.php' - showTreeView: true - description: Source Token counter outputPath: sources/token-counter.md @@ -117,12 +106,10 @@ documents: - type: file sourcePaths: ../Lib/TokenCounter filePattern: '*.php' - showTreeView: true - description: Document source renderer outputPath: sources/source-renderer.md sources: - type: file sourcePaths: ../Lib/Content - filePattern: '*.php' - showTreeView: true \ No newline at end of file + filePattern: '*.php' \ No newline at end of file diff --git a/tests/fixtures/Console/GenerateCommand/FileSource/exclude-test.yaml b/tests/fixtures/Console/GenerateCommand/FileSource/exclude-test.yaml new file mode 100644 index 00000000..ede9b4ef --- /dev/null +++ b/tests/fixtures/Console/GenerateCommand/FileSource/exclude-test.yaml @@ -0,0 +1,22 @@ +# Test configuration for exclusion patterns + +# Global exclusion patterns +exclude: + # File patterns to exclude globally (glob patterns with wildcards) + patterns: + - "*.js" # Exclude all JavaScript files + - "*.txt" # Exclude all text files + + # Paths to exclude globally (exact directories or files) + paths: + - "nested" # Exclude the nested directory + +documents: + - description: "Exclusion Test" + outputPath: "exclude-test.md" + sources: + - type: file + description: "Files with Exclusions" + sourcePaths: "./" + # This would normally include all files, but exclusions should filter them + filePattern: [ "*.php", "*.js", "*.txt" ] diff --git a/tests/src/Feature/Console/GenerateCommand/CompilingResult.php b/tests/src/Feature/Console/GenerateCommand/CompilingResult.php index 9426f194..1a289285 100644 --- a/tests/src/Feature/Console/GenerateCommand/CompilingResult.php +++ b/tests/src/Feature/Console/GenerateCommand/CompilingResult.php @@ -104,7 +104,7 @@ public function assertMissedContext(string $document): self * @param array $notContains Strings that should NOT be in the document * @return self For method chaining */ - public function assertContext(string $document, array $contains, array $notContains = []): self + public function assertContext(string $document, array $contains = [], array $notContains = []): self { foreach ($this->result['result'] as $documentData) { if ($documentData['context_path'] === $document) { diff --git a/tests/src/Feature/Console/GenerateCommand/ExcludeTest.php b/tests/src/Feature/Console/GenerateCommand/ExcludeTest.php new file mode 100644 index 00000000..5f34296e --- /dev/null +++ b/tests/src/Feature/Console/GenerateCommand/ExcludeTest.php @@ -0,0 +1,103 @@ + ['generate']; + yield 'build' => ['build']; + yield 'compile' => ['compile']; + } + + /** + * Test that global exclusion patterns work correctly + */ + #[Test] + #[DataProvider('commandsProvider')] + public function global_exclusion_patterns_should_work(string $command): void + { + $this + ->buildContext( + workDir: $this->outputDir, + configPath: $this->getFixturesDir('Console/GenerateCommand/FileSource/exclude-test.yaml'), + command: $command, + ) + ->assertDocumentsCompiled() + // TestClass.php should be included + ->assertContext( + document: 'exclude-test.md', + contains: [ + 'TestClass.php', + 'class TestClass', + ], + ) + // JavaScript files should be excluded by pattern + ->assertContext( + document: 'exclude-test.md', + notContains: [ + 'script.js', + 'function helloWorld', + ], + ) + // Text files should be excluded by pattern + ->assertContext( + document: 'exclude-test.md', + notContains: [ + 'sample.txt', + 'This is a sample text file', + ], + ) + // Nested directory should be excluded by path + ->assertContext( + document: 'exclude-test.md', + notContains: [ + 'NestedClass.php', + 'class NestedClass', + ], + ); + } + + /** + * Set up the test + */ + #[\Override] + protected function setUp(): void + { + parent::setUp(); + $this->outputDir = $this->createTempDir(); + } + + /** + * Helper method to build context + */ + protected function buildContext( + string $workDir, + ?string $configPath = null, + ?string $inlineJson = null, + ?string $envFile = null, + string $command = 'generate', + bool $asJson = true, + ): CompilingResult { + return (new ContextBuilder($this->getConsole()))->build( + workDir: $workDir, + configPath: $configPath, + inlineJson: $inlineJson, + envFile: $envFile, + command: $command, + asJson: $asJson, + ); + } +} diff --git a/tests/src/context.yaml b/tests/src/context.yaml index f52f141e..50727aab 100644 --- a/tests/src/context.yaml +++ b/tests/src/context.yaml @@ -44,12 +44,12 @@ documents: sources: - type: file sourcePaths: - - Feature/Console + - Feature/Console/GenerateCommand/FileSourceTest.php - TestApp.php - TestCase.php showTreeView: true - type: file sourcePaths: - - ../../../fixtures/Console + - ../fixtures/Console/GenerateCommand/FileSource showTreeView: true \ No newline at end of file From 34189054c69b3ec9780c0884ce203720ad8f5b0c Mon Sep 17 00:00:00 2001 From: butschster Date: Thu, 1 May 2025 23:35:09 +0200 Subject: [PATCH 2/3] feat: Implement global exclusion patterns in GitHub and GitLab finders --- src/Config/Exclude/AbstractExclusion.php | 12 ++++---- src/Config/Exclude/ExcludeParserPlugin.php | 1 + src/Config/Import/PathMatcher.php | 8 ++--- src/Source/Composer/ComposerSourceFetcher.php | 1 - src/Source/File/SymfonyFinder.php | 16 +++++++--- src/Source/Github/GithubFinder.php | 29 +++++++++++++++++++ src/Source/Gitlab/GitlabFinder.php | 29 +++++++++++++++++++ src/Source/Tree/TreeSourceFetcher.php | 2 +- 8 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/Config/Exclude/AbstractExclusion.php b/src/Config/Exclude/AbstractExclusion.php index f21835ad..7974a420 100644 --- a/src/Config/Exclude/AbstractExclusion.php +++ b/src/Config/Exclude/AbstractExclusion.php @@ -24,6 +24,11 @@ public function getPattern(): string return $this->pattern; } + /** + * Abstract method to check if a path matches this pattern + */ + abstract public function matches(string $path): bool; + /** * Normalize a pattern for consistent comparison */ @@ -32,11 +37,6 @@ protected function normalizePattern(string $pattern): string $pattern = \preg_replace('#^\./#', '', $pattern); // Remove trailing slash - return \rtrim($pattern, '/'); + return \rtrim((string) $pattern, '/'); } - - /** - * Abstract method to check if a path matches this pattern - */ - abstract public function matches(string $path): bool; } diff --git a/src/Config/Exclude/ExcludeParserPlugin.php b/src/Config/Exclude/ExcludeParserPlugin.php index 2a7029c4..3e86f69f 100644 --- a/src/Config/Exclude/ExcludeParserPlugin.php +++ b/src/Config/Exclude/ExcludeParserPlugin.php @@ -36,6 +36,7 @@ public function parse(array $config, string $rootPath): ?RegistryInterface return null; } + \assert($this->registry instanceof RegistryInterface); $excludeConfig = $config['exclude']; // Parse patterns diff --git a/src/Config/Import/PathMatcher.php b/src/Config/Import/PathMatcher.php index 696d99bd..6be57c21 100644 --- a/src/Config/Import/PathMatcher.php +++ b/src/Config/Import/PathMatcher.php @@ -176,12 +176,12 @@ private function isRegex(string $str): bool { $availableModifiers = 'imsxuADUn'; - if (preg_match('/^(.{3,}?)[' . $availableModifiers . ']*$/', $str, $m)) { - $start = substr($m[1], 0, 1); - $end = substr($m[1], -1); + if (\preg_match('/^(.{3,}?)[' . $availableModifiers . ']*$/', $str, $m)) { + $start = \substr($m[1], 0, 1); + $end = \substr($m[1], -1); if ($start === $end) { - return !preg_match('/[*?[:alnum:] \\\\]/', $start); + return !\preg_match('/[*?[:alnum:] \\\\]/', $start); } foreach ([['{', '}'], ['(', ')'], ['[', ']'], ['<', '>']] as $delimiters) { diff --git a/src/Source/Composer/ComposerSourceFetcher.php b/src/Source/Composer/ComposerSourceFetcher.php index fa3b5989..edb5a785 100644 --- a/src/Source/Composer/ComposerSourceFetcher.php +++ b/src/Source/Composer/ComposerSourceFetcher.php @@ -6,7 +6,6 @@ use Butschster\ContextGenerator\Application\Logger\LoggerPrefix; use Butschster\ContextGenerator\Lib\Content\ContentBuilderFactory; -use Butschster\ContextGenerator\Lib\TreeBuilder\FileTreeBuilder; use Butschster\ContextGenerator\Lib\Variable\VariableResolver; use Butschster\ContextGenerator\Modifier\ModifiersApplierInterface; use Butschster\ContextGenerator\Source\Composer\Provider\ComposerProviderInterface; diff --git a/src/Source/File/SymfonyFinder.php b/src/Source/File/SymfonyFinder.php index c11df87a..c58df7e9 100644 --- a/src/Source/File/SymfonyFinder.php +++ b/src/Source/File/SymfonyFinder.php @@ -10,6 +10,7 @@ use Butschster\ContextGenerator\Lib\TreeBuilder\FileTreeBuilder; use Butschster\ContextGenerator\Source\Fetcher\FilterableSourceInterface; use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\SplFileInfo; /** * Implementation of FinderInterface using Symfony's Finder component @@ -113,8 +114,6 @@ public function find(FilterableSourceInterface $source, string $basePath = '', a $count = 0; foreach ($finder as $file) { - trap($file); - $limitedFiles[] = $file; $count++; @@ -133,7 +132,7 @@ public function find(FilterableSourceInterface $source, string $basePath = '', a $files = []; foreach ($finder as $file) { // Skip files that would be excluded by path patterns - if ($this->shouldExcludeFile($file->getPathname())) { + if ($this->shouldExcludeFile($this->getPath($file, $basePath))) { continue; } @@ -161,7 +160,7 @@ private function generateTreeView(Finder $finder, string $basePath, array $optio foreach ($finder as $file) { // Skip excluded files in tree view - if ($this->shouldExcludeFile($file->getPathname())) { + if ($this->shouldExcludeFile($this->getPath($file, $basePath))) { continue; } @@ -182,4 +181,13 @@ private function shouldExcludeFile(string $filePath): bool { return $this->excludeRegistry->shouldExclude($filePath); } + + private function getPath(SplFileInfo|\SplFileInfo $file, string $basePath) + { + if ($file instanceof SplFileInfo) { + return $file->getRelativePathname(); + } + + return \ltrim(\str_replace($basePath, '', $file->getRealPath()), '/'); + } } diff --git a/src/Source/Github/GithubFinder.php b/src/Source/Github/GithubFinder.php index 5fb352b3..6a35c24e 100644 --- a/src/Source/Github/GithubFinder.php +++ b/src/Source/Github/GithubFinder.php @@ -4,6 +4,7 @@ namespace Butschster\ContextGenerator\Source\Github; +use Butschster\ContextGenerator\Config\Exclude\ExcludeRegistryInterface; use Butschster\ContextGenerator\Lib\Finder\FinderInterface; use Butschster\ContextGenerator\Lib\Finder\FinderResult; use Butschster\ContextGenerator\Lib\GithubClient\GithubClientInterface; @@ -16,6 +17,8 @@ use Butschster\ContextGenerator\Lib\TreeBuilder\FileTreeBuilder; use Butschster\ContextGenerator\Lib\Variable\VariableResolver; use Butschster\ContextGenerator\Source\Fetcher\FilterableSourceInterface; +use Butschster\ContextGenerator\Application\Logger\LoggerPrefix; +use Psr\Log\LoggerInterface; /** * GitHub content finder implementation @@ -36,8 +39,11 @@ final class GithubFinder implements FinderInterface */ public function __construct( private readonly GithubClientInterface $githubClient, + private readonly ExcludeRegistryInterface $excludeRegistry, private readonly VariableResolver $variableResolver = new VariableResolver(), private readonly FileTreeBuilder $fileTreeBuilder = new FileTreeBuilder(), + #[LoggerPrefix(prefix: 'github-finder')] + private readonly ?LoggerInterface $logger = null, ) {} /** @@ -75,11 +81,15 @@ public function find(FilterableSourceInterface $source, string $basePath = '', a $files = []; $this->buildResultStructure($filteredItems, $repository, $files); + // Apply content filters $files = (new ContentsFilter( contains: $source->contains(), notContains: $source->notContains(), ))->apply($files); + // Apply global exclusion registry + $files = $this->applyGlobalExclusions($files); + /** @psalm-suppress InvalidArgument */ $tree = \array_map(static fn(GithubFileInfo $file): string => $file->getRelativePathname(), $files); @@ -102,6 +112,25 @@ public function applyFilters(array $items): array return $items; } + /** + * Apply global exclusion patterns to filter files + */ + private function applyGlobalExclusions(array $files): array + { + return \array_filter($files, function (GithubFileInfo $file): bool { + $path = $file->getRelativePathname(); + + if ($this->excludeRegistry->shouldExclude($path)) { + $this->logger?->debug('File excluded by global exclusion pattern', [ + 'path' => $path, + ]); + return false; + } + + return true; + }); + } + /** * Initialize path filters based on source configuration * diff --git a/src/Source/Gitlab/GitlabFinder.php b/src/Source/Gitlab/GitlabFinder.php index 35fdba54..04433021 100644 --- a/src/Source/Gitlab/GitlabFinder.php +++ b/src/Source/Gitlab/GitlabFinder.php @@ -4,6 +4,7 @@ namespace Butschster\ContextGenerator\Source\Gitlab; +use Butschster\ContextGenerator\Config\Exclude\ExcludeRegistryInterface; use Butschster\ContextGenerator\Lib\Finder\FinderInterface; use Butschster\ContextGenerator\Lib\Finder\FinderResult; use Butschster\ContextGenerator\Lib\GitlabClient\GitlabClientInterface; @@ -16,6 +17,8 @@ use Butschster\ContextGenerator\Lib\TreeBuilder\FileTreeBuilder; use Butschster\ContextGenerator\Lib\Variable\VariableResolver; use Butschster\ContextGenerator\Source\Fetcher\FilterableSourceInterface; +use Butschster\ContextGenerator\Application\Logger\LoggerPrefix; +use Psr\Log\LoggerInterface; final class GitlabFinder implements FinderInterface { @@ -28,8 +31,11 @@ final class GitlabFinder implements FinderInterface public function __construct( private readonly GitlabClientInterface $gitlabClient, + private readonly ExcludeRegistryInterface $excludeRegistry, private readonly VariableResolver $variableResolver = new VariableResolver(), private readonly FileTreeBuilder $fileTreeBuilder = new FileTreeBuilder(), + #[LoggerPrefix(prefix: 'gitlab-finder')] + private readonly ?LoggerInterface $logger = null, ) {} public function find(FilterableSourceInterface $source, string $basePath = '', array $options = []): FinderResult @@ -63,11 +69,15 @@ public function find(FilterableSourceInterface $source, string $basePath = '', a $files = []; $this->buildResultStructure($filteredItems, $repository, $files); + // Apply content filters $files = (new ContentsFilter( contains: $source->contains(), notContains: $source->notContains(), ))->apply($files); + // Apply global exclusion registry + $files = $this->applyGlobalExclusions($files); + /** @psalm-suppress InvalidArgument */ $tree = \array_map(static fn(GitlabFileInfo $file): string => $file->getRelativePathname(), $files); @@ -87,6 +97,25 @@ public function applyFilters(array $items): array return $items; } + /** + * Apply global exclusion patterns to filter files + */ + private function applyGlobalExclusions(array $files): array + { + return \array_filter($files, function (GitlabFileInfo $file): bool { + $path = $file->getRelativePathname(); + + if ($this->excludeRegistry->shouldExclude($path)) { + $this->logger?->debug('File excluded by global exclusion pattern', [ + 'path' => $path, + ]); + return false; + } + + return true; + }); + } + /** * Configure the GitLab client based on source configuration */ diff --git a/src/Source/Tree/TreeSourceFetcher.php b/src/Source/Tree/TreeSourceFetcher.php index c6ca9d4e..e37a3c90 100644 --- a/src/Source/Tree/TreeSourceFetcher.php +++ b/src/Source/Tree/TreeSourceFetcher.php @@ -20,8 +20,8 @@ { public function __construct( private string $basePath, + private SymfonyFinder $finder, private ContentBuilderFactory $builderFactory = new ContentBuilderFactory(), - private SymfonyFinder $finder = new SymfonyFinder(), #[LoggerPrefix(prefix: 'tree-source')] private ?LoggerInterface $logger = null, ) {} From 52195409cd0ae8a74ef8fc310746d5df6b132788 Mon Sep 17 00:00:00 2001 From: butschster Date: Sat, 3 May 2025 12:23:33 +0200 Subject: [PATCH 3/3] fix: Update source paths --- tests/src/context.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/src/context.yaml b/tests/src/context.yaml index 50727aab..a63963fe 100644 --- a/tests/src/context.yaml +++ b/tests/src/context.yaml @@ -44,12 +44,12 @@ documents: sources: - type: file sourcePaths: - - Feature/Console/GenerateCommand/FileSourceTest.php + - Feature/Console - TestApp.php - TestCase.php showTreeView: true - type: file sourcePaths: - - ../fixtures/Console/GenerateCommand/FileSource + - ../fixtures/Console showTreeView: true \ No newline at end of file