From 93294f6fd3979e9eb2843ba8392d7cd8e3e487ae Mon Sep 17 00:00:00 2001 From: Ismo Vuorinen Date: Sat, 3 Jan 2026 00:55:09 +0200 Subject: [PATCH] feat: ignore vendored directories (#135) * feat: ignore vendored directories * chore: cr tweaks * fix: sonarcloud detected issues * fix: sonarcloud detected issues --- appconstants/constants.go | 66 +++++- appconstants/test_constants.go | 16 +- integration_test.go | 50 ++--- internal/config.go | 14 +- internal/dependencies/analyzer_test.go | 6 +- internal/generator.go | 13 +- internal/generator_test.go | 26 +-- internal/parser.go | 96 ++++++--- internal/parser_test.go | 285 +++++++++++++++++++++++++ internal/viper_helper.go | 1 + main.go | 37 +++- main_test.go | 6 +- testutil/testutil.go | 17 +- 13 files changed, 542 insertions(+), 91 deletions(-) create mode 100644 internal/parser_test.go diff --git a/appconstants/constants.go b/appconstants/constants.go index 9b8c4de..56a0e1a 100644 --- a/appconstants/constants.go +++ b/appconstants/constants.go @@ -87,8 +87,6 @@ const ( const ( // ContextKeyError is used as a key for error information in context maps. ContextKeyError = "error" - // ContextKeyTheme is used as a key for theme information. - ContextKeyTheme = "theme" // ContextKeyConfig is used as a key for configuration information. ContextKeyConfig = "config" ) @@ -182,6 +180,8 @@ const ( ConfigKeyVerbose = "verbose" // ConfigKeyQuiet is the configuration key for quiet mode. ConfigKeyQuiet = "quiet" + // ConfigKeyIgnoredDirectories is the configuration key for ignored directories during discovery. + ConfigKeyIgnoredDirectories = "ignored_directories" // GitHub Integration // ConfigKeyGitHubToken is the configuration key for GitHub token. @@ -261,6 +261,26 @@ func GetConfigSearchPaths() []string { return paths } +// defaultIgnoredDirectories lists directories to ignore during file discovery. +var defaultIgnoredDirectories = []string{ + DirGit, DirGitHub, DirGitLab, DirSVN, // VCS + DirNodeModules, DirBowerComponents, // JavaScript + DirVendor, // Go/PHP + DirVenvDot, DirVenv, DirEnv, DirTox, DirPycache, // Python + DirDist, DirBuild, DirTarget, DirOut, // Build outputs + DirIdea, DirVscode, // IDEs + DirCache, DirTmpDot, DirTmp, // Cache/temp +} + +// GetDefaultIgnoredDirectories returns a copy of the default ignored directory names. +// Returns a new slice to prevent external modification of the internal list. +func GetDefaultIgnoredDirectories() []string { + dirs := make([]string, len(defaultIgnoredDirectories)) + copy(dirs, defaultIgnoredDirectories) + + return dirs +} + // Output format constants. const ( // OutputFormatMarkdown is the Markdown output format. @@ -317,6 +337,46 @@ const ( EnvPrefix = "GH_ACTION_README" ) +// Directory names commonly ignored during file discovery. +// These constants are used to exclude build artifacts, dependencies, +// version control, and temporary files from action file discovery. +const ( + // Version Control System directories + // DirGit = ".git" (already defined above in "Directory and path constants"). + DirGitHub = ".github" + DirGitLab = ".gitlab" + DirSVN = ".svn" + + // JavaScript/Node.js dependencies. + DirNodeModules = "node_modules" + DirBowerComponents = "bower_components" + + // Package manager vendor directories. + DirVendor = "vendor" + + // Python virtual environments and cache. + DirVenv = "venv" + DirVenvDot = ".venv" + DirEnv = "env" + DirTox = ".tox" + DirPycache = "__pycache__" + + // Build output directories. + DirDist = "dist" + DirBuild = "build" + DirTarget = "target" + DirOut = "out" + + // IDE configuration directories. + DirIdea = ".idea" + DirVscode = ".vscode" + + // Cache and temporary directories. + DirCache = ".cache" + DirTmp = "tmp" + DirTmpDot = ".tmp" +) + // Git constants. const ( // GitCommand is the git command name. @@ -485,6 +545,8 @@ const ( FlagOutput = "output" // FlagRecursive is the recursive flag name. FlagRecursive = "recursive" + // FlagIgnoreDirs is the ignore-dirs flag name. + FlagIgnoreDirs = "ignore-dirs" ) // Field names for validation. diff --git a/appconstants/test_constants.go b/appconstants/test_constants.go index bc9f4d5..cfbd1d7 100644 --- a/appconstants/test_constants.go +++ b/appconstants/test_constants.go @@ -46,8 +46,6 @@ const ( // Test file path constants. const ( - TestPathActionYML = "action.yml" - TestPathActionYAML = "action.yaml" TestPathConfigYML = "config.yml" TestPathCustomConfigYML = "custom-config.yml" TestPathNonexistentYML = "nonexistent.yml" @@ -67,6 +65,18 @@ const ( // Config directories. TestDirConfigGhActionReadme = ".config/gh-action-readme" TestDirDotConfig = ".config" - TestDirDotGitHub = ".github" TestDirCacheGhActionReadme = ".cache/gh-action-readme" ) + +// (Test file permission constants removed - use production constants from appconstants/constants.go) + +// Test YAML content for parser tests. +const ( + TestYAMLRoot = "name: root" + TestYAMLNodeModules = "name: node_modules" + TestYAMLVendor = "name: vendor" + TestYAMLGit = "name: git" + TestYAMLSrc = "name: src" + TestYAMLNested = "name: nested" + TestYAMLSub = "name: sub" +) diff --git a/integration_test.go b/integration_test.go index 4b6684f..a507e26 100644 --- a/integration_test.go +++ b/integration_test.go @@ -136,7 +136,7 @@ func buildTestBinary(t *testing.T) string { // setupCompleteWorkflow creates a realistic project structure for testing. func setupCompleteWorkflow(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) testutil.WriteTestFile(t, filepath.Join(tmpDir, "README.md"), "# Old README") testutil.WriteTestFile(t, filepath.Join(tmpDir, ".gitignore"), testutil.GitIgnoreContent) @@ -146,7 +146,7 @@ func setupCompleteWorkflow(t *testing.T, tmpDir string) { // setupMultiActionWorkflow creates a project with multiple actions. func setupMultiActionWorkflow(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) testutil.CreateActionSubdir(t, tmpDir, "actions/deploy", appconstants.TestFixtureDockerBasic) @@ -156,14 +156,14 @@ func setupMultiActionWorkflow(t *testing.T, tmpDir string) { // setupConfigWorkflow creates a simple action for config testing. func setupConfigWorkflow(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) } // setupErrorWorkflow creates an invalid action file for error testing. func setupErrorWorkflow(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureInvalidMissingDescription)) } @@ -171,7 +171,7 @@ func setupErrorWorkflow(t *testing.T, tmpDir string) { func setupConfigurationHierarchy(t *testing.T, tmpDir string) { t.Helper() // Create action file - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) // Create global config @@ -193,7 +193,7 @@ func setupConfigurationHierarchy(t *testing.T, tmpDir string) { func setupMultiActionWithTemplates(t *testing.T, tmpDir string) { t.Helper() // Root action - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) // Nested actions with different types @@ -239,7 +239,7 @@ func setupDependencyAnalysisWorkflow(t *testing.T, tmpDir string) { "actions/upload-artifact@v3", }, ) - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), compositeAction) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), compositeAction) // Add package.json with npm dependencies testutil.WriteTestFile(t, filepath.Join(tmpDir, "package.json"), testutil.PackageJSONContent) @@ -256,14 +256,14 @@ func setupDependencyAnalysisWorkflow(t *testing.T, tmpDir string) { "aws-actions/configure-aws-credentials@v2", }, ) - testutil.WriteTestFile(t, filepath.Join(nestedDir, appconstants.TestPathActionYML), nestedAction) + testutil.WriteTestFile(t, filepath.Join(nestedDir, appconstants.ActionFileNameYML), nestedAction) } // setupConfigurationHierarchyWorkflow creates a comprehensive configuration hierarchy. func setupConfigurationHierarchyWorkflow(t *testing.T, tmpDir string) { t.Helper() // Create action file - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) // Set up XDG config home @@ -305,7 +305,7 @@ output_dir: docs` func setupTemplateErrorScenario(t *testing.T, tmpDir string) { t.Helper() // Create valid action file - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) // Create a broken template directory structure @@ -323,7 +323,7 @@ func setupTemplateErrorScenario(t *testing.T, tmpDir string) { func setupConfigurationErrorScenario(t *testing.T, tmpDir string) { t.Helper() // Create valid action file - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) // Create invalid configuration files @@ -362,7 +362,7 @@ func setupFileDiscoveryErrorScenario(t *testing.T, tmpDir string) { func setupServiceIntegrationErrorScenario(t *testing.T, tmpDir string) { t.Helper() // Valid action at root - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) // Invalid action in subdirectory @@ -753,7 +753,7 @@ type errorScenario struct { func testProjectSetup(t *testing.T, binaryPath, tmpDir string) { t.Helper() // Create a new GitHub Action project - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureMyNewAction)) // Validate the action @@ -791,7 +791,7 @@ func testDocumentationGeneration(t *testing.T, binaryPath, tmpDir string) { func testDependencyManagement(t *testing.T, binaryPath, tmpDir string) { t.Helper() // Update action to be composite with dependencies - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) // List dependencies @@ -1164,7 +1164,7 @@ func TestStressTestWorkflow(t *testing.T) { actionContent := strings.ReplaceAll(testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple), "Simple Action", "Action "+string(rune('A'+i))) - testutil.WriteTestFile(t, filepath.Join(actionDir, appconstants.TestPathActionYML), actionContent) + testutil.WriteTestFile(t, filepath.Join(actionDir, appconstants.ActionFileNameYML), actionContent) } // Test recursive processing @@ -1295,7 +1295,7 @@ func TestErrorRecoveryWorkflow(t *testing.T) { // Create a project with mixed valid and invalid files // Note: validation looks for files named exactly "action.yml" or "action.yaml" - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) testutil.CreateActionSubdir(t, tmpDir, appconstants.TestDirSubdir, @@ -1345,7 +1345,7 @@ func TestConfigurationWorkflow(t *testing.T) { configHome := filepath.Join(tmpDir, "config") t.Setenv("XDG_CONFIG_HOME", configHome) - testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) var err error @@ -1418,7 +1418,7 @@ func verifyProgressIndicators(t *testing.T, tmpDir string) { // The actual progress output is captured during the workflow step execution // Here we verify the infrastructure was set up correctly - actionFile := filepath.Join(tmpDir, appconstants.TestPathActionYML) + actionFile := filepath.Join(tmpDir, appconstants.ActionFileNameYML) if _, err := os.Stat(actionFile); err != nil { t.Error("action file missing, progress tracking test setup failed") @@ -1440,10 +1440,10 @@ func verifyProgressIndicators(t *testing.T, tmpDir string) { func verifyFileDiscovery(t *testing.T, tmpDir string) { t.Helper() expectedActions := []string{ - filepath.Join(tmpDir, appconstants.TestPathActionYML), - filepath.Join(tmpDir, "actions", "composite", appconstants.TestPathActionYML), - filepath.Join(tmpDir, "actions", "docker", appconstants.TestPathActionYML), - filepath.Join(tmpDir, "actions", "minimal", appconstants.TestPathActionYML), + filepath.Join(tmpDir, appconstants.ActionFileNameYML), + filepath.Join(tmpDir, "actions", "composite", appconstants.ActionFileNameYML), + filepath.Join(tmpDir, "actions", "docker", appconstants.ActionFileNameYML), + filepath.Join(tmpDir, "actions", "minimal", appconstants.ActionFileNameYML), } // Verify action files were set up correctly and exist @@ -1482,13 +1482,13 @@ func verifyTemplateRendering(t *testing.T, tmpDir string) { actionFiles, _ := filepath.Glob(filepath.Join(tmpDir, "**/action.yml")) if len(actionFiles) == 0 { // Try different pattern - actionFiles, _ = filepath.Glob(filepath.Join(tmpDir, appconstants.TestPathActionYML)) + actionFiles, _ = filepath.Glob(filepath.Join(tmpDir, appconstants.ActionFileNameYML)) if len(actionFiles) == 0 { t.Error("no action files found for template rendering verification") t.Logf( "Checked patterns: %s and %s", filepath.Join(tmpDir, "**/action.yml"), - filepath.Join(tmpDir, appconstants.TestPathActionYML), + filepath.Join(tmpDir, appconstants.ActionFileNameYML), ) return @@ -1530,7 +1530,7 @@ func verifyCompleteServiceChain(t *testing.T, tmpDir string) { // Verify the complete test environment was set up correctly requiredComponents := []string{ - filepath.Join(tmpDir, appconstants.TestPathActionYML), + filepath.Join(tmpDir, appconstants.ActionFileNameYML), filepath.Join(tmpDir, "package.json"), filepath.Join(tmpDir, ".gitignore"), } diff --git a/internal/config.go b/internal/config.go index b0a6671..639efd0 100644 --- a/internal/config.go +++ b/internal/config.go @@ -56,8 +56,9 @@ type AppConfig struct { RepoOverrides map[string]AppConfig `mapstructure:"repo_overrides" yaml:"repo_overrides,omitempty"` // Behavior - Verbose bool `mapstructure:"verbose" yaml:"verbose"` - Quiet bool `mapstructure:"quiet" yaml:"quiet"` + Verbose bool `mapstructure:"verbose" yaml:"verbose"` + Quiet bool `mapstructure:"quiet" yaml:"quiet"` + IgnoredDirectories []string `mapstructure:"ignored_directories" yaml:"ignored_directories,omitempty"` // Default values for action.yml files (legacy) Defaults DefaultValues `mapstructure:"defaults" yaml:"defaults,omitempty"` @@ -243,8 +244,9 @@ func DefaultAppConfig() *AppConfig { RepoOverrides: map[string]AppConfig{}, // Behavior - Verbose: false, - Quiet: false, + Verbose: false, + Quiet: false, + IgnoredDirectories: appconstants.GetDefaultIgnoredDirectories(), // Default values for action.yml files (legacy) Defaults: DefaultValues{ @@ -318,6 +320,10 @@ func mergeSliceFields(dst *AppConfig, src *AppConfig) { dst.RunsOn = make([]string, len(src.RunsOn)) copy(dst.RunsOn, src.RunsOn) } + if len(src.IgnoredDirectories) > 0 { + dst.IgnoredDirectories = make([]string, len(src.IgnoredDirectories)) + copy(dst.IgnoredDirectories, src.IgnoredDirectories) + } } // mergeBooleanFields merges boolean fields from src to dst if true. diff --git a/internal/dependencies/analyzer_test.go b/internal/dependencies/analyzer_test.go index b78b01f..2ee62ff 100644 --- a/internal/dependencies/analyzer_test.go +++ b/internal/dependencies/analyzer_test.go @@ -71,7 +71,7 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() - actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML) + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) testutil.WriteTestFile(t, actionPath, tt.actionYML) // Create analyzer with mock GitHub client @@ -432,7 +432,7 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) { // Create a test action file with composite steps actionContent := testutil.MustReadFixture(appconstants.TestFixtureTestCompositeAction) - actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML) + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) testutil.WriteTestFile(t, actionPath, actionContent) // Create analyzer @@ -551,7 +551,7 @@ func TestAnalyzer_WithoutGitHubClient(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() - actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML) + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) deps, err := analyzer.AnalyzeActionFile(actionPath) diff --git a/internal/generator.go b/internal/generator.go index 75213b2..b283a39 100644 --- a/internal/generator.go +++ b/internal/generator.go @@ -139,8 +139,8 @@ func (g *Generator) GenerateFromFile(actionPath string) error { // DiscoverActionFiles finds action.yml and action.yaml files in the given directory // using the centralized parser function and adds verbose logging. -func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, error) { - actionFiles, err := DiscoverActionFiles(dir, recursive) +func (g *Generator) DiscoverActionFiles(dir string, recursive bool, ignoredDirs []string) ([]string, error) { + actionFiles, err := DiscoverActionFiles(dir, recursive, ignoredDirs) if err != nil { return nil, err } @@ -161,9 +161,14 @@ func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, e // DiscoverActionFilesWithValidation discovers action files with centralized error handling and validation. // This function consolidates the duplicated file discovery logic across the codebase. -func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool, context string) ([]string, error) { +func (g *Generator) DiscoverActionFilesWithValidation( + dir string, + recursive bool, + ignoredDirs []string, + context string, +) ([]string, error) { // Discover action files - actionFiles, err := g.DiscoverActionFiles(dir, recursive) + actionFiles, err := g.DiscoverActionFiles(dir, recursive, ignoredDirs) if err != nil { g.Output.ErrorWithContext( appconstants.ErrCodeFileNotFound, diff --git a/internal/generator_test.go b/internal/generator_test.go index 3e0fe73..c89e846 100644 --- a/internal/generator_test.go +++ b/internal/generator_test.go @@ -60,7 +60,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { testutil.WriteActionFixtureAs( t, tmpDir, - appconstants.TestPathActionYAML, + appconstants.ActionFileNameYAML, appconstants.TestFixtureJavaScriptSimple, ) }, @@ -75,7 +75,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { testutil.WriteActionFixtureAs( t, tmpDir, - appconstants.TestPathActionYAML, + appconstants.ActionFileNameYAML, appconstants.TestFixtureMinimalAction, ) }, @@ -145,7 +145,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { testDir = filepath.Join(tmpDir, "nonexistent") } - files, err := generator.DiscoverActionFiles(testDir, tt.recursive) + files, err := generator.DiscoverActionFiles(testDir, tt.recursive, []string{}) if tt.expectError { testutil.AssertError(t, err) @@ -160,8 +160,8 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { for _, file := range files { testutil.AssertFileExists(t, file) - if !strings.HasSuffix(file, appconstants.TestPathActionYML) && - !strings.HasSuffix(file, appconstants.TestPathActionYAML) { + if !strings.HasSuffix(file, appconstants.ActionFileNameYML) && + !strings.HasSuffix(file, appconstants.ActionFileNameYAML) { t.Errorf("discovered file is not an action file: %s", file) } } @@ -237,7 +237,7 @@ func TestGenerator_GenerateFromFile(t *testing.T) { testutil.SetupTestTemplates(t, tmpDir) // Write action file - actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML) + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) testutil.WriteTestFile(t, actionPath, tt.actionYML) // Create generator with explicit template path @@ -341,8 +341,8 @@ func TestGenerator_ProcessBatch(t *testing.T) { dirs := createTestDirs(t, tmpDir, "action1", "action2") files := []string{ - filepath.Join(dirs[0], appconstants.TestPathActionYML), - filepath.Join(dirs[1], appconstants.TestPathActionYML), + filepath.Join(dirs[0], appconstants.ActionFileNameYML), + filepath.Join(dirs[1], appconstants.ActionFileNameYML), } testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) testutil.WriteTestFile(t, files[1], testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) @@ -360,8 +360,8 @@ func TestGenerator_ProcessBatch(t *testing.T) { dirs := createTestDirs(t, tmpDir, "valid-action", "invalid-action") files := []string{ - filepath.Join(dirs[0], appconstants.TestPathActionYML), - filepath.Join(dirs[1], appconstants.TestPathActionYML), + filepath.Join(dirs[0], appconstants.ActionFileNameYML), + filepath.Join(dirs[1], appconstants.ActionFileNameYML), } testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) testutil.WriteTestFile( @@ -567,7 +567,7 @@ func TestGenerator_WithDifferentThemes(t *testing.T) { // Set up test templates for this theme test testutil.SetupTestTemplates(t, tmpDir) - actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML) + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) config := &AppConfig{ @@ -611,7 +611,7 @@ func TestGenerator_ErrorHandling(t *testing.T) { Quiet: true, } generator := NewGenerator(config) - actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML) + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) testutil.WriteTestFile( t, actionPath, @@ -640,7 +640,7 @@ func TestGenerator_ErrorHandling(t *testing.T) { Template: filepath.Join(tmpDir, "templates", "readme.tmpl"), } generator := NewGenerator(config) - actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML) + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) testutil.WriteTestFile( t, actionPath, diff --git a/internal/parser.go b/internal/parser.go index 87d63d1..b84048e 100644 --- a/internal/parser.go +++ b/internal/parser.go @@ -58,46 +58,84 @@ func ParseActionYML(path string) (*ActionYML, error) { return &a, nil } +// shouldIgnoreDirectory checks if a directory name matches the ignore list. +func shouldIgnoreDirectory(dirName string, ignoredDirs []string) bool { + for _, ignored := range ignoredDirs { + if strings.HasPrefix(ignored, ".") { + // Pattern match: ".git" matches ".git", ".github", etc. + if strings.HasPrefix(dirName, ignored) { + return true + } + } else { + // Exact match for non-hidden dirs + if dirName == ignored { + return true + } + } + } + + return false +} + +// actionFileWalker encapsulates the logic for walking directories and finding action files. +type actionFileWalker struct { + ignoredDirs []string + actionFiles []string +} + +// walkFunc is the callback function for filepath.Walk. +func (w *actionFileWalker) walkFunc(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + if shouldIgnoreDirectory(info.Name(), w.ignoredDirs) { + return filepath.SkipDir + } + + return nil + } + + // Check for action.yml or action.yaml files + filename := strings.ToLower(info.Name()) + if filename == appconstants.ActionFileNameYML || filename == appconstants.ActionFileNameYAML { + w.actionFiles = append(w.actionFiles, path) + } + + return nil +} + // DiscoverActionFiles finds action.yml and action.yaml files in the given directory. // This consolidates the file discovery logic from both generator.go and dependencies/parser.go. -func DiscoverActionFiles(dir string, recursive bool) ([]string, error) { - var actionFiles []string - +func DiscoverActionFiles(dir string, recursive bool, ignoredDirs []string) ([]string, error) { // Check if dir exists if _, err := os.Stat(dir); os.IsNotExist(err) { return nil, fmt.Errorf("directory does not exist: %s", dir) } if recursive { - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - if info.IsDir() { - return nil - } - - // Check for action.yml or action.yaml files - filename := strings.ToLower(info.Name()) - if filename == appconstants.ActionFileNameYML || filename == appconstants.ActionFileNameYAML { - actionFiles = append(actionFiles, path) - } - - return nil - }) - if err != nil { + walker := &actionFileWalker{ignoredDirs: ignoredDirs} + if err := filepath.Walk(dir, walker.walkFunc); err != nil { return nil, fmt.Errorf("failed to walk directory %s: %w", dir, err) } - } else { - // Check only the specified directory - for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} { - path := filepath.Join(dir, filename) - if _, err := os.Stat(path); err == nil { - actionFiles = append(actionFiles, path) - } + + return walker.actionFiles, nil + } + + // Check only the specified directory (non-recursive) + return discoverActionFilesNonRecursive(dir), nil +} + +// discoverActionFilesNonRecursive finds action files in a single directory. +func discoverActionFilesNonRecursive(dir string) []string { + var actionFiles []string + for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} { + path := filepath.Join(dir, filename) + if _, err := os.Stat(path); err == nil { + actionFiles = append(actionFiles, path) } } - return actionFiles, nil + return actionFiles } diff --git a/internal/parser_test.go b/internal/parser_test.go new file mode 100644 index 0000000..3026225 --- /dev/null +++ b/internal/parser_test.go @@ -0,0 +1,285 @@ +package internal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// createTestDirWithAction creates a directory with an action.yml file and returns both paths. +func createTestDirWithAction(t *testing.T, baseDir, dirName, yamlContent string) (string, string) { + t.Helper() + dirPath := filepath.Join(baseDir, dirName) + if err := os.Mkdir(dirPath, appconstants.FilePermDir); err != nil { // nolint:gosec + t.Fatalf(testutil.ErrCreateDir(dirName), err) + } + actionPath := filepath.Join(dirPath, appconstants.ActionFileNameYML) + if err := os.WriteFile( + actionPath, []byte(yamlContent), appconstants.FilePermDefault, + ); err != nil { // nolint:gosec + t.Fatalf(testutil.ErrCreateFile(dirName+"/action.yml"), err) + } + + return dirPath, actionPath +} + +// createTestFile creates a file with the given content and returns its path. +func createTestFile(t *testing.T, baseDir, fileName, content string) string { + t.Helper() + filePath := filepath.Join(baseDir, fileName) + if err := os.WriteFile( + filePath, []byte(content), appconstants.FilePermDefault, + ); err != nil { // nolint:gosec + t.Fatalf(testutil.ErrCreateFile(fileName), err) + } + + return filePath +} + +// validateDiscoveredFiles checks if discovered files match expected count and paths. +func validateDiscoveredFiles(t *testing.T, files []string, wantCount int, wantPaths []string) { + t.Helper() + + if len(files) != wantCount { + t.Errorf("DiscoverActionFiles() returned %d files, want %d", len(files), wantCount) + t.Logf("Got files: %v", files) + t.Logf("Want files: %v", wantPaths) + } + + // Check that all expected files are present + fileMap := make(map[string]bool) + for _, f := range files { + fileMap[f] = true + } + + for _, wantPath := range wantPaths { + if !fileMap[wantPath] { + t.Errorf("Expected file %s not found in results", wantPath) + } + } +} + +// TestShouldIgnoreDirectory tests the directory filtering logic. +func TestShouldIgnoreDirectory(t *testing.T) { + tests := []struct { + name string + dirName string + ignoredDirs []string + want bool + }{ + { + name: "exact match - node_modules", + dirName: appconstants.DirNodeModules, + ignoredDirs: []string{appconstants.DirNodeModules, appconstants.DirVendor}, + want: true, + }, + { + name: "exact match - vendor", + dirName: appconstants.DirVendor, + ignoredDirs: []string{appconstants.DirNodeModules, appconstants.DirVendor}, + want: true, + }, + { + name: "no match", + dirName: "src", + ignoredDirs: []string{appconstants.DirNodeModules, appconstants.DirVendor}, + want: false, + }, + { + name: "empty ignore list", + dirName: appconstants.DirNodeModules, + ignoredDirs: []string{}, + want: false, + }, + { + name: "dot prefix match - .git", + dirName: appconstants.DirGit, + ignoredDirs: []string{appconstants.DirGit}, + want: true, + }, + { + name: "dot prefix pattern match - .github", + dirName: appconstants.DirGitHub, + ignoredDirs: []string{appconstants.DirGit}, + want: true, + }, + { + name: "dot prefix pattern match - .gitlab", + dirName: appconstants.DirGitLab, + ignoredDirs: []string{appconstants.DirGit}, + want: true, + }, + { + name: "dot prefix no match", + dirName: ".config", + ignoredDirs: []string{appconstants.DirGit}, + want: false, + }, + { + name: "case sensitive - NODE_MODULES vs node_modules", + dirName: "NODE_MODULES", + ignoredDirs: []string{appconstants.DirNodeModules}, + want: false, + }, + { + name: "partial name not matched", + dirName: "my_vendor", + ignoredDirs: []string{appconstants.DirVendor}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldIgnoreDirectory(tt.dirName, tt.ignoredDirs) + if got != tt.want { + t.Errorf("shouldIgnoreDirectory(%q, %v) = %v, want %v", + tt.dirName, tt.ignoredDirs, got, tt.want) + } + }) + } +} + +// TestDiscoverActionFilesWithIgnoredDirectories tests file discovery with directory filtering. +func TestDiscoverActionFilesWithIgnoredDirectories(t *testing.T) { + // Create temporary directory structure + tmpDir := t.TempDir() + + // Create directory structure: + // tmpDir/ + // action.yml (should be found) + // node_modules/ + // action.yml (should be ignored) + // vendor/ + // action.yml (should be ignored) + // .git/ + // action.yml (should be ignored) + // src/ + // action.yml (should be found) + + // Create root action.yml + rootAction := createTestFile(t, tmpDir, appconstants.ActionFileNameYML, appconstants.TestYAMLRoot) + + // Create directories with action.yml files + _, nodeModulesAction := createTestDirWithAction( + t, + tmpDir, + appconstants.DirNodeModules, + appconstants.TestYAMLNodeModules, + ) + _, vendorAction := createTestDirWithAction(t, tmpDir, appconstants.DirVendor, appconstants.TestYAMLVendor) + _, gitAction := createTestDirWithAction(t, tmpDir, appconstants.DirGit, appconstants.TestYAMLGit) + _, srcAction := createTestDirWithAction(t, tmpDir, "src", appconstants.TestYAMLSrc) + + tests := []struct { + name string + ignoredDirs []string + wantCount int + wantPaths []string + }{ + { + name: "with default ignore list", + ignoredDirs: []string{appconstants.DirGit, appconstants.DirNodeModules, appconstants.DirVendor}, + wantCount: 2, + wantPaths: []string{rootAction, srcAction}, + }, + { + name: "with empty ignore list", + ignoredDirs: []string{}, + wantCount: 5, + wantPaths: []string{rootAction, gitAction, nodeModulesAction, srcAction, vendorAction}, + }, + { + name: "ignore only node_modules", + ignoredDirs: []string{appconstants.DirNodeModules}, + wantCount: 4, + wantPaths: []string{rootAction, gitAction, srcAction, vendorAction}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + files, err := DiscoverActionFiles(tmpDir, true, tt.ignoredDirs) + if err != nil { + t.Fatalf(testutil.ErrDiscoverActionFiles(), err) + } + + validateDiscoveredFiles(t, files, tt.wantCount, tt.wantPaths) + }) + } +} + +// TestDiscoverActionFilesNestedIgnoredDirs tests that subdirectories of ignored dirs are skipped. +func TestDiscoverActionFilesNestedIgnoredDirs(t *testing.T) { + tmpDir := t.TempDir() + + // Create directory structure: + // tmpDir/ + // node_modules/ + // deep/ + // nested/ + // action.yml (should be ignored) + + nodeModulesDir := filepath.Join(tmpDir, appconstants.DirNodeModules, "deep", "nested") + if err := os.MkdirAll(nodeModulesDir, appconstants.FilePermDir); err != nil { // nolint:gosec + t.Fatalf(testutil.ErrCreateDir("nested"), err) + } + + nestedAction := filepath.Join(nodeModulesDir, appconstants.ActionFileNameYML) + if err := os.WriteFile( + nestedAction, []byte(appconstants.TestYAMLNested), appconstants.FilePermDefault, + ); err != nil { // nolint:gosec + t.Fatalf(testutil.ErrCreateFile("nested action.yml"), err) + } + + files, err := DiscoverActionFiles(tmpDir, true, []string{appconstants.DirNodeModules}) + if err != nil { + t.Fatalf(testutil.ErrDiscoverActionFiles(), err) + } + + if len(files) != 0 { + t.Errorf("DiscoverActionFiles() returned %d files, want 0 (nested dirs should be skipped)", len(files)) + t.Logf("Got files: %v", files) + } +} + +// TestDiscoverActionFilesNonRecursive tests that non-recursive mode ignores the filter. +func TestDiscoverActionFilesNonRecursive(t *testing.T) { + tmpDir := t.TempDir() + + // Create action.yml in root + rootAction := filepath.Join(tmpDir, appconstants.ActionFileNameYML) + if err := os.WriteFile( + rootAction, []byte(appconstants.TestYAMLRoot), appconstants.FilePermDefault, + ); err != nil { // nolint:gosec + t.Fatalf(testutil.ErrCreateFile("action.yml"), err) + } + + // Create subdirectory (should not be searched in non-recursive mode) + subDir := filepath.Join(tmpDir, "sub") + if err := os.Mkdir(subDir, appconstants.FilePermDir); err != nil { // nolint:gosec + t.Fatalf(testutil.ErrCreateDir("sub"), err) + } + subAction := filepath.Join(subDir, appconstants.ActionFileNameYML) + if err := os.WriteFile( + subAction, []byte(appconstants.TestYAMLSub), appconstants.FilePermDefault, + ); err != nil { // nolint:gosec + t.Fatalf(testutil.ErrCreateFile("sub/action.yml"), err) + } + + files, err := DiscoverActionFiles(tmpDir, false, []string{}) + if err != nil { + t.Fatalf(testutil.ErrDiscoverActionFiles(), err) + } + + if len(files) != 1 { + t.Errorf("DiscoverActionFiles() non-recursive returned %d files, want 1", len(files)) + } + + if len(files) > 0 && files[0] != rootAction { + t.Errorf("DiscoverActionFiles() = %v, want %v", files[0], rootAction) + } +} diff --git a/internal/viper_helper.go b/internal/viper_helper.go index f82903d..b687bd3 100644 --- a/internal/viper_helper.go +++ b/internal/viper_helper.go @@ -64,6 +64,7 @@ func setConfigDefaults(v *viper.Viper, defaults *AppConfig) { v.SetDefault(appconstants.ConfigKeyShowSecurityInfo, defaults.ShowSecurityInfo) v.SetDefault(appconstants.ConfigKeyVerbose, defaults.Verbose) v.SetDefault(appconstants.ConfigKeyQuiet, defaults.Quiet) + v.SetDefault(appconstants.ConfigKeyIgnoredDirectories, defaults.IgnoredDirectories) v.SetDefault(appconstants.ConfigKeyDefaultsName, defaults.Defaults.Name) v.SetDefault(appconstants.ConfigKeyDefaultsDescription, defaults.Defaults.Description) v.SetDefault(appconstants.ConfigKeyDefaultsBrandingIcon, defaults.Defaults.Branding.Icon) diff --git a/main.go b/main.go index 4160a96..d119dfb 100644 --- a/main.go +++ b/main.go @@ -183,6 +183,11 @@ Examples: cmd.Flags().StringP(appconstants.FlagOutput, "", "", "custom output filename (overrides default naming)") cmd.Flags().StringP(appconstants.ConfigKeyTheme, "t", "", "template theme: github, gitlab, minimal, professional") cmd.Flags().BoolP(appconstants.FlagRecursive, "r", false, "search for action.yml files recursively") + cmd.Flags().StringSlice( + appconstants.FlagIgnoreDirs, + []string{}, + "directories to ignore during recursive discovery (comma-separated)", + ) return cmd } @@ -241,9 +246,17 @@ func genHandler(cmd *cobra.Command, args []string) { workingDir = absTargetPath generator := internal.NewGenerator(globalConfig) // Temporary generator for discovery recursive, _ := cmd.Flags().GetBool(appconstants.FlagRecursive) + + // Get ignored directories from CLI flag or use config defaults + ignoredDirs, _ := cmd.Flags().GetStringSlice(appconstants.FlagIgnoreDirs) + if len(ignoredDirs) == 0 { + ignoredDirs = globalConfig.IgnoredDirectories + } + actionFiles, err = generator.DiscoverActionFilesWithValidation( workingDir, recursive, + ignoredDirs, "documentation generation", ) if err != nil { @@ -350,6 +363,7 @@ func validateHandler(_ *cobra.Command, _ []string) { actionFiles, err := generator.DiscoverActionFilesWithValidation( currentDir, true, + globalConfig.IgnoredDirectories, "validation", ) // Recursive for validation if err != nil { @@ -589,7 +603,12 @@ func depsListHandler(_ *cobra.Command, _ []string) { } generator := internal.NewGenerator(globalConfig) - actionFiles, err := generator.DiscoverActionFilesWithValidation(currentDir, true, "dependency listing") + actionFiles, err := generator.DiscoverActionFilesWithValidation( + currentDir, + true, + globalConfig.IgnoredDirectories, + "dependency listing", + ) if err != nil { // For deps list, we can continue if no files found (show warning instead of error) output.Warning(appconstants.ErrNoActionFilesFound) @@ -668,7 +687,12 @@ func depsSecurityHandler(_ *cobra.Command, _ []string) { } generator := internal.NewGenerator(globalConfig) - actionFiles, err := generator.DiscoverActionFilesWithValidation(currentDir, true, "security analysis") + actionFiles, err := generator.DiscoverActionFilesWithValidation( + currentDir, + true, + globalConfig.IgnoredDirectories, + "security analysis", + ) if err != nil { os.Exit(1) } @@ -766,7 +790,12 @@ func depsOutdatedHandler(_ *cobra.Command, _ []string) { } generator := internal.NewGenerator(globalConfig) - actionFiles, err := generator.DiscoverActionFilesWithValidation(currentDir, true, "outdated dependency analysis") + actionFiles, err := generator.DiscoverActionFilesWithValidation( + currentDir, + true, + globalConfig.IgnoredDirectories, + "outdated dependency analysis", + ) if err != nil { // For deps outdated, we can continue if no files found (show warning instead of error) output.Warning(appconstants.ErrNoActionFilesFound) @@ -897,7 +926,7 @@ func depsUpgradeHandler(cmd *cobra.Command, _ []string) { // setupDepsUpgrade handles initial setup and validation for dependency upgrades. func setupDepsUpgrade(output *internal.ColoredOutput, currentDir string) (*dependencies.Analyzer, []string) { generator := internal.NewGenerator(globalConfig) - actionFiles, err := generator.DiscoverActionFiles(currentDir, true) + actionFiles, err := generator.DiscoverActionFiles(currentDir, true, globalConfig.IgnoredDirectories) if err != nil { output.Error("Error discovering action files: %v", err) os.Exit(1) diff --git a/main_test.go b/main_test.go index 3681751..385927c 100644 --- a/main_test.go +++ b/main_test.go @@ -125,7 +125,7 @@ func TestCLICommands(t *testing.T) { args: []string{"deps", "list"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML) + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) }, wantExit: 0, @@ -305,7 +305,7 @@ func TestCLIErrorHandling(t *testing.T) { t.Helper() testutil.WriteTestFile( t, - filepath.Join(tmpDir, appconstants.TestPathActionYML), + filepath.Join(tmpDir, appconstants.ActionFileNameYML), "invalid: yaml: content: [", ) }, @@ -588,7 +588,7 @@ func runTestCommand(binaryPath string, args []string, dir string) cmdResult { // It writes the specified fixture to action.yml in the given temporary directory. func createTestActionFile(t *testing.T, tmpDir, fixture string) { t.Helper() - actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML) + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(fixture)) } diff --git a/testutil/testutil.go b/testutil/testutil.go index bdcbe05..05b0fc2 100644 --- a/testutil/testutil.go +++ b/testutil/testutil.go @@ -168,7 +168,7 @@ func WriteTestFile(t *testing.T, path, content string) { // WriteActionFixture writes an action fixture to a standard action.yml file. func WriteActionFixture(t *testing.T, dir, fixturePath string) string { t.Helper() - actionPath := filepath.Join(dir, appconstants.TestPathActionYML) + actionPath := filepath.Join(dir, appconstants.ActionFileNameYML) fixture := MustLoadActionFixture(t, fixturePath) WriteTestFile(t, actionPath, fixture.Content) @@ -585,3 +585,18 @@ func GetGitHubTokenHierarchyTests() []GitHubTokenTestCase { }, } } + +// ErrCreateFile returns a formatted error message for file creation failures. +func ErrCreateFile(name string) string { + return fmt.Sprintf("Failed to create %s: %s", name, "%v") +} + +// ErrCreateDir returns a formatted error message for directory creation failures. +func ErrCreateDir(name string) string { + return fmt.Sprintf("Failed to create %s dir: %s", name, "%v") +} + +// ErrDiscoverActionFiles returns the error format string for DiscoverActionFiles failures. +func ErrDiscoverActionFiles() string { + return "DiscoverActionFiles() error = %v" +}