diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000..ac08d12a --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,153 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +All commands run inside `nix develop` shell (or use `nix develop -c ''`): + +```bash +# Build +go build -o spectr # Build binary +nix build # Build via Nix + +# Lint & Format +nix develop -c lint # Run golangci-lint + markdownlint on spectr/ +golangci-lint run # Go linting only + +# Test +nix develop -c tests # Run tests with race detector (gotestsum) +go test ./... # Basic test run +go test -v ./internal/validation/... # Specific package + +# Single test +go test -run TestValidateSpec ./internal/validation/ + +# Format (via treefmt) +nix fmt # Format Go + Nix files +``` + +## Architecture Overview + +Spectr is a CLI tool for spec-driven development. Key concepts: +- specs/ - Current truth: what IS built (requirements + scenarios) +- changes/ - Proposals: what SHOULD change (deltas against specs) +- archive/ - Completed changes with timestamps + +### Code Structure + +``` +cmd/ # CLI commands (thin layer using Kong framework) +├── root.go # CLI setup with kong.Context +├── init.go # spectr init +├── list.go # spectr list +├── validate.go # spectr validate +├── accept.go # spectr accept (converts tasks.md → tasks.jsonc) +├── pr.go # spectr pr archive|new (git worktree + PR creation) +└── view.go # spectr view + +internal/ # Business logic (not importable externally) +├── validation/ # Validation rules for specs and changes +├── parsers/ # Requirement and delta parsing from markdown +├── archive/ # Archive workflow and spec merging +├── discovery/ # File discovery utilities +├── initialize/ # Init wizard and AI tool templates +├── tui/ # Interactive terminal UI (Bubble Tea) +├── domain/ # Core domain types (Spec, Change, Requirement) +├── pr/ # Pull request creation via git worktree +└── git/ # Git operations +``` + +### Key Dependencies +- Kong: CLI framework (`github.com/alecthomas/kong`) +- Bubble Tea/Bubbles/Lipgloss: TUI framework (Charmbracelet) +- Afero: Filesystem abstraction for testing + +### Data Flow +1. Commands in `cmd/` parse flags and call `internal/` packages +2. `discovery/` finds spec/change files in `spectr/` directory +3. `parsers/` extracts requirements and scenarios from markdown +4. `validation/` enforces rules (scenarios required, format checks) +5. `archive/` merges delta specs into main specs + +## Spectr Workflow + +See `spectr/AGENTS.md` for detailed spec-driven development instructions: +- Create proposals in `spectr/changes//` with `proposal.md`, `tasks.md`, delta specs +- Run `spectr validate ` before implementation +- Run `spectr accept ` to convert tasks.md to tasks.jsonc +- Track task status in `tasks.jsonc` during implementation +- Archive completed changes with `spectr pr archive ` + +## Delta Spec Format + +```markdown +## ADDED Requirements +### Requirement: Feature Name +The system SHALL... + +#### Scenario: Success case +- WHEN condition +- THEN result + +## MODIFIED Requirements +### Requirement: Existing Feature +[Complete updated requirement with all scenarios] + +## REMOVED Requirements +### Requirement: Old Feature +Reason: Why removing +Migration: How to handle +``` + +## Testing Patterns + +Tests use table-driven style with `t.Run()`: +```go +tests := []struct { + name string + input string + wantErr bool +}{...} +for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) {...}) +} +``` + +Test fixtures in `testdata/`. TUI tests use `charmbracelet/x/exp/teatest`. + +## Orchestration Model + +This project uses an orchestrator pattern for complex tasks: +- Orchestrator (you): Maintains big picture, creates todo lists, delegates +- coder agent: Implements ONE specific todo item (`.claude/agents/coder.md`) +- tester agent: Verifies implementations with Playwright +- stuck agent: Escalates to human when blocked + +Workflow: Create todos → delegate to coder → verify with tester → mark complete + + +# Spectr Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/spectr/AGENTS.md` when the request: + +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big + performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/spectr/AGENTS.md` to learn: + +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +When delegating tasks from a change proposal to subagents: + +- Provide the proposal path: `spectr/changes//proposal.md` +- Include task context: `spectr/changes//tasks.jsonc` +- Reference delta specs: `spectr/changes//specs//spec.md` + + diff --git a/cmd/discovery.go b/cmd/discovery.go index 03c5b056..efaf1143 100644 --- a/cmd/discovery.go +++ b/cmd/discovery.go @@ -4,12 +4,24 @@ import ( "errors" "fmt" "os" + "sync" "github.com/connerohnesorge/spectr/internal/discovery" ) +// discoveryCache caches the results of GetDiscoveredRoots to avoid +// redundant filesystem traversals within a single command execution. +var ( + cachedRoots []discovery.SpectrRoot + errCachedRoots error + cachedCwd string + discoveryCacheMu sync.Mutex +) + // GetDiscoveredRoots returns all discovered spectr roots from the current // working directory. It wraps discovery.FindSpectrRoots with cwd handling. +// Results are cached per working directory to avoid redundant +// filesystem traversals within a single command execution. func GetDiscoveredRoots() ([]discovery.SpectrRoot, error) { cwd, err := os.Getwd() if err != nil { @@ -19,15 +31,45 @@ func GetDiscoveredRoots() ([]discovery.SpectrRoot, error) { ) } + discoveryCacheMu.Lock() + defer discoveryCacheMu.Unlock() + + // If cwd changed, invalidate cache + if cachedCwd != cwd { + cachedRoots = nil + errCachedRoots = nil + cachedCwd = cwd + } + + // Return cached result if available + if cachedRoots != nil || errCachedRoots != nil { + return cachedRoots, errCachedRoots + } + + // Perform discovery roots, err := discovery.FindSpectrRoots(cwd) if err != nil { - return nil, fmt.Errorf( + errCachedRoots = fmt.Errorf( "failed to discover spectr roots: %w", err, ) + + return nil, errCachedRoots } - return roots, nil + cachedRoots = roots + + return cachedRoots, nil +} + +// ResetDiscoveryCache clears the discovery cache. This is primarily +// intended for testing where the working directory changes. +func ResetDiscoveryCache() { + discoveryCacheMu.Lock() + defer discoveryCacheMu.Unlock() + cachedRoots = nil + errCachedRoots = nil + cachedCwd = "" } // GetSingleRoot returns the first discovered root, or an error if no roots diff --git a/internal/discovery/downward.go b/internal/discovery/downward.go new file mode 100644 index 00000000..84d0b44a --- /dev/null +++ b/internal/discovery/downward.go @@ -0,0 +1,173 @@ +package discovery + +import ( + "fmt" + "os" + "path/filepath" +) + +// downwardContext holds the context for downward directory traversal. +type downwardContext struct { + absStartPath string + cwd string + depthMap map[string]int + maxDepth int + roots *[]SpectrRoot +} + +// appendDownwardRoots performs downward discovery and appends results to roots. +// Downward discovery happens when: +// a) We're NOT inside a git repository (gitRoot is empty), OR +// b) We ARE at the git root itself (to find nested subprojects in monorepos) +// This enables monorepo support where the root contains subprojects with +// their own .git and spectr/ directories. +func appendDownwardRoots(existingRoots []SpectrRoot, absCwd, gitRoot string) []SpectrRoot { + if gitRoot != "" && absCwd != gitRoot { + return existingRoots + } + + downwardRoots, err := findSpectrRootsDownward(absCwd, absCwd, maxDiscoveryDepth) + // Ignore downward discovery errors - upward discovery already succeeded + if err == nil { + return append(existingRoots, downwardRoots...) + } + + return existingRoots +} + +// calculateDepth computes the depth of a directory relative to the start path. +func calculateDepth(path, absStartPath string, depthMap map[string]int) int { + parent := filepath.Dir(path) + if depth, ok := depthMap[parent]; ok { + return depth + 1 + } + + // Fallback: calculate depth from path segments + relPath, relErr := filepath.Rel(absStartPath, path) + if relErr == nil { + return len(filepath.SplitList(relPath)) + } + + return 0 +} + +// processDownwardDirectory handles a single directory during downward discovery. +// Returns filepath.SkipDir if the directory should not be descended into. +// +// Optimization: We check for .git FIRST because: +// 1. A valid spectr root requires BOTH spectr/ AND .git +// 2. If .git exists, we stop descending (git boundary) +// 3. This means we only stat spectr/ for directories that have .git +// +// This reduces stat calls from 3 per directory to 1-2: +// - Before: stat(spectr/) + stat(.git) + stat(.git) for every directory +// - After: stat(.git) for all dirs, stat(spectr/) only when .git exists +func processDownwardDirectory(path string, d os.DirEntry, ctx *downwardContext) error { + // Only process directories + if !d.IsDir() { + return nil + } + + // Calculate and store current depth + currentDepth := calculateDepth(path, ctx.absStartPath, ctx.depthMap) + ctx.depthMap[path] = currentDepth + + // Stop descending if we've hit max depth + if currentDepth > ctx.maxDepth { + return filepath.SkipDir + } + + // Skip descending into common non-project directories + if shouldSkipDirectory(d.Name()) { + return filepath.SkipDir + } + + // Check for .git FIRST - this is the critical optimization + // We only stat .git once, and only check spectr/ if .git exists + gitDir := filepath.Join(path, gitDirName) + _, gitErr := os.Stat(gitDir) + hasGit := gitErr == nil // .git exists (as file or directory) + + if hasGit { + // Found a git repository boundary - check if it also has spectr/ + spectrDir := filepath.Join(path, spectrDirName) + spectrInfo, spectrErr := os.Stat(spectrDir) + if spectrErr == nil && spectrInfo.IsDir() { + // Found valid spectr root (has both spectr/ and .git) + relPath, relErr := filepath.Rel(ctx.cwd, path) + if relErr != nil { + relPath = path // Fallback to absolute + } + + // findGitRoot uses cache, so this is efficient + gitRoot := findGitRoot(path) + + *ctx.roots = append(*ctx.roots, SpectrRoot{ + Path: path, + RelativeTo: relPath, + GitRoot: gitRoot, + }) + } + + // Don't descend into nested git repos (unless start path) + // This is the git boundary - nested repos are treated as separate + if path != ctx.absStartPath { + return filepath.SkipDir + } + } + + return nil +} + +// findSpectrRootsDownward searches for spectr/ directories in subdirectories, +// descending from startPath up to maxDepth levels deep. It discovers nested +// repositories (directories with .git) and their spectr/ directories. +// +// This complements upward discovery to support mono-repo structures where +// multiple nested projects each have their own .git and spectr/ directories. +// +// The function: +// - Uses filepath.WalkDir for efficient traversal +// - Tracks depth with configurable limit (prevents excessive traversal) +// - Finds all spectr/ directories in subdirectories +// - Creates SpectrRoot entries with Path, RelativeTo (from cwd), and GitRoot +// - Skips descending into .git/, node_modules/, vendor/, target/, dist/, build/ +// - Includes directories that CONTAIN .git (nested repos are discovered) +// - Handles permission errors gracefully (continues search) +// - Continues searching after finding spectr/ (doesn't stop at first match) +func findSpectrRootsDownward(startPath, cwd string, maxDepth int) ([]SpectrRoot, error) { + var roots []SpectrRoot + absStartPath, err := filepath.Abs(startPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + // Create context for traversal + ctx := &downwardContext{ + absStartPath: absStartPath, + cwd: cwd, + depthMap: map[string]int{absStartPath: 0}, + maxDepth: maxDepth, + roots: &roots, + } + + err = filepath.WalkDir(absStartPath, func(path string, d os.DirEntry, err error) error { + // Handle permission errors gracefully - continue walking + if err != nil { + // Skip directories we can't read + if d != nil && d.IsDir() { + return filepath.SkipDir + } + + return nil // Continue for non-directory errors + } + + return processDownwardDirectory(path, d, ctx) + }) + + if err != nil { + return nil, fmt.Errorf("failed to walk directory tree: %w", err) + } + + return roots, nil +} diff --git a/internal/discovery/git_root.go b/internal/discovery/git_root.go new file mode 100644 index 00000000..aa688be0 --- /dev/null +++ b/internal/discovery/git_root.go @@ -0,0 +1,61 @@ +package discovery + +import ( + "os" + "path/filepath" + "sync" +) + +// gitRootCache caches git root lookups to avoid repeated filesystem traversals. +var ( + gitRootCache = make(map[string]string) + gitRootCacheMu sync.RWMutex +) + +// findGitRoot walks up from the given path to find the nearest .git directory. +// Returns empty string if no git root is found. +// Results are cached for performance. +func findGitRoot(startPath string) string { + // Check cache first + gitRootCacheMu.RLock() + if cached, ok := gitRootCache[startPath]; ok { + gitRootCacheMu.RUnlock() + + return cached + } + gitRootCacheMu.RUnlock() + + result := findGitRootUncached(startPath) + + // Cache the result + gitRootCacheMu.Lock() + gitRootCache[startPath] = result + gitRootCacheMu.Unlock() + + return result +} + +// findGitRootUncached is the uncached implementation of findGitRoot. +func findGitRootUncached(startPath string) string { + current := startPath + for { + gitDir := filepath.Join(current, gitDirName) + info, err := os.Stat(gitDir) + if err == nil && info.IsDir() { + return current + } + + // Also check for git worktree files (where .git is a file, not dir) + if err == nil && !info.IsDir() { + return current + } + + parent := filepath.Dir(current) + if parent == current { + // Reached filesystem root without finding .git + return "" + } + + current = parent + } +} diff --git a/internal/discovery/roots.go b/internal/discovery/roots.go index 53942165..7299fb80 100644 --- a/internal/discovery/roots.go +++ b/internal/discovery/roots.go @@ -15,7 +15,9 @@ const ( gitDirName = ".git" // maxDiscoveryDepth limits how deep downward discovery will traverse. - maxDiscoveryDepth = 10 + // Set to 5 to balance discovery coverage with performance. + // Nested repos beyond this depth are unlikely in practice. + maxDiscoveryDepth = 5 ) // SpectrRoot represents a discovered spectr/ directory with its location context. @@ -185,230 +187,6 @@ func findSpectrRootsFromCwd(cwd string) ([]SpectrRoot, error) { return roots, nil } -// appendDownwardRoots performs downward discovery and appends results to roots. -// Downward discovery happens when: -// a) We're NOT inside a git repository (gitRoot is empty), OR -// b) We ARE at the git root itself (to find nested subprojects in monorepos) -// This enables monorepo support where the root contains subprojects with -// their own .git and spectr/ directories. -func appendDownwardRoots(existingRoots []SpectrRoot, absCwd, gitRoot string) []SpectrRoot { - if gitRoot != "" && absCwd != gitRoot { - return existingRoots - } - - downwardRoots, err := findSpectrRootsDownward(absCwd, absCwd, maxDiscoveryDepth) - // Ignore downward discovery errors - upward discovery already succeeded - if err == nil { - return append(existingRoots, downwardRoots...) - } - - return existingRoots -} - -// hasGitAtLevel checks if a .git directory (or file for worktrees) exists at the given path. -// This is used to validate that a spectr/ directory belongs to a real git repository. -func hasGitAtLevel(path string) bool { - gitDir := filepath.Join(path, gitDirName) - _, err := os.Stat(gitDir) - - return err == nil -} - -// findGitRoot walks up from the given path to find the nearest .git directory. -// Returns empty string if no git root is found. -func findGitRoot(startPath string) string { - current := startPath - for { - gitDir := filepath.Join(current, gitDirName) - info, err := os.Stat(gitDir) - if err == nil && info.IsDir() { - return current - } - - // Also check for git worktree files (where .git is a file, not dir) - if err == nil && !info.IsDir() { - return current - } - - parent := filepath.Dir(current) - if parent == current { - // Reached filesystem root without finding .git - return "" - } - - current = parent - } -} - -// shouldSkipDirectory returns true if the directory should be skipped during downward discovery. -func shouldSkipDirectory(dirName string) bool { - skipDirs := []string{gitDirName, "node_modules", "vendor", "target", "dist", "build"} - for _, skip := range skipDirs { - if dirName == skip { - return true - } - } - - return false -} - -// calculateDepth computes the depth of a directory relative to the start path. -func calculateDepth(path, absStartPath string, depthMap map[string]int) int { - parent := filepath.Dir(path) - if depth, ok := depthMap[parent]; ok { - return depth + 1 - } - - // Fallback: calculate depth from path segments - relPath, relErr := filepath.Rel(absStartPath, path) - if relErr == nil { - return len(filepath.SplitList(relPath)) - } - - return 0 -} - -// addSpectrRootIfExists checks if a directory contains a spectr/ subdirectory -// and adds it to the roots slice if it does. -// Only directories that also have a .git at the same level are considered valid. -func addSpectrRootIfExists(path, cwd string, roots *[]SpectrRoot) { - spectrDir := filepath.Join(path, spectrDirName) - info, statErr := os.Stat(spectrDir) - if statErr != nil || !info.IsDir() { - return - } - - // Only add as root if .git exists at same level - // This prevents test fixtures and example directories from being discovered - if !hasGitAtLevel(path) { - return - } - - // Found a valid spectr/ directory with .git at same level! - // Calculate relative path from original cwd - relPath, relErr := filepath.Rel(cwd, path) - if relErr != nil { - relPath = path // Fallback to absolute - } - - // Find git root for this spectr root - gitRoot := findGitRoot(path) - - *roots = append(*roots, SpectrRoot{ - Path: path, - RelativeTo: relPath, - GitRoot: gitRoot, - }) -} - -// shouldSkipGitBoundary checks if a directory contains a .git subdirectory -// and should not be descended into (unless it's the start path). -func shouldSkipGitBoundary(path, absStartPath string) bool { - if path == absStartPath { - return false // Don't skip the start path itself - } - - gitDir := filepath.Join(path, gitDirName) - info, err := os.Stat(gitDir) - // If .git exists (as dir or file for worktrees), skip descending - return err == nil && (info.IsDir() || !info.IsDir()) -} - -// downwardContext holds the context for downward directory traversal. -type downwardContext struct { - absStartPath string - cwd string - depthMap map[string]int - maxDepth int - roots *[]SpectrRoot -} - -// processDownwardDirectory handles a single directory during downward discovery. -// Returns filepath.SkipDir if the directory should not be descended into. -func processDownwardDirectory(path string, d os.DirEntry, ctx *downwardContext) error { - // Only process directories - if !d.IsDir() { - return nil - } - - // Calculate and store current depth - currentDepth := calculateDepth(path, ctx.absStartPath, ctx.depthMap) - ctx.depthMap[path] = currentDepth - - // Stop descending if we've hit max depth - if currentDepth > ctx.maxDepth { - return filepath.SkipDir - } - - // Skip descending into common non-project directories - if shouldSkipDirectory(d.Name()) { - return filepath.SkipDir - } - - // Check if this directory contains a spectr/ subdirectory and add it if so - addSpectrRootIfExists(path, ctx.cwd, ctx.roots) - - // Check if we should skip descending into this directory (git boundary) - if shouldSkipGitBoundary(path, ctx.absStartPath) { - return filepath.SkipDir - } - - return nil -} - -// findSpectrRootsDownward searches for spectr/ directories in subdirectories, -// descending from startPath up to maxDepth levels deep. It discovers nested -// repositories (directories with .git) and their spectr/ directories. -// -// This complements upward discovery to support mono-repo structures where -// multiple nested projects each have their own .git and spectr/ directories. -// -// The function: -// - Uses filepath.WalkDir for efficient traversal -// - Tracks depth with configurable limit (prevents excessive traversal) -// - Finds all spectr/ directories in subdirectories -// - Creates SpectrRoot entries with Path, RelativeTo (from cwd), and GitRoot -// - Skips descending into .git/, node_modules/, vendor/, target/, dist/, build/ -// - Includes directories that CONTAIN .git (nested repos are discovered) -// - Handles permission errors gracefully (continues search) -// - Continues searching after finding spectr/ (doesn't stop at first match) -func findSpectrRootsDownward(startPath, cwd string, maxDepth int) ([]SpectrRoot, error) { - var roots []SpectrRoot - absStartPath, err := filepath.Abs(startPath) - if err != nil { - return nil, fmt.Errorf("failed to get absolute path: %w", err) - } - - // Create context for traversal - ctx := &downwardContext{ - absStartPath: absStartPath, - cwd: cwd, - depthMap: map[string]int{absStartPath: 0}, - maxDepth: maxDepth, - roots: &roots, - } - - err = filepath.WalkDir(absStartPath, func(path string, d os.DirEntry, err error) error { - // Handle permission errors gracefully - continue walking - if err != nil { - // Skip directories we can't read - if d != nil && d.IsDir() { - return filepath.SkipDir - } - - return nil // Continue for non-directory errors - } - - return processDownwardDirectory(path, d, ctx) - }) - - if err != nil { - return nil, fmt.Errorf("failed to walk directory tree: %w", err) - } - - return roots, nil -} - // deduplicateRoots removes duplicate SpectrRoot entries based on their Path field. // Preserves the order of first occurrence. func deduplicateRoots(roots []SpectrRoot) []SpectrRoot { diff --git a/internal/discovery/roots_test.go b/internal/discovery/roots_test.go index e9825d43..7cc473a0 100644 --- a/internal/discovery/roots_test.go +++ b/internal/discovery/roots_test.go @@ -1179,7 +1179,7 @@ func TestFindSpectrRoots_MonorepoWithSubprojects(t *testing.T) { }) } -// TestFindSpectrRoots_DepthLimit verifies that the 10-level depth limit is enforced +// TestFindSpectrRoots_DepthLimit verifies that the 5-level depth limit is enforced // during downward discovery, preventing excessive directory traversal. func TestFindSpectrRoots_DepthLimit(t *testing.T) { // Note: This test directly tests findSpectrRootsDownward since FindSpectrRoots @@ -1189,23 +1189,23 @@ func TestFindSpectrRoots_DepthLimit(t *testing.T) { // Due to how WalkDir and calculateDepth work together, the effective depths are: // - tmpDir (start path): depth 0 in depthMap initialization, but calculated as depth 1 // - tmpDir/a: depth 2 - // - tmpDir/a/b/c/d/e/f/g/h/i: depth 10 (at limit) - // - tmpDir/a/b/c/d/e/f/g/h/i/j: depth 11 (exceeds limit) + // - tmpDir/a/b/c/d: depth 5 (at limit) + // - tmpDir/a/b/c/d/e: depth 6 (exceeds limit) tmpDir := t.TempDir() // Create shallow spectr with .git (depth 2 with current implementation) mustMkdirAll(t, filepath.Join(tmpDir, "shallow", ".git")) mustMkdirAll(t, filepath.Join(tmpDir, "shallow", "spectr")) - // Create at-limit spectr with .git (depth 10) - // Using 9 path segments: a/b/c/d/e/f/g/h/i - atLimitPath := filepath.Join(tmpDir, "a", "b", "c", "d", "e", "f", "g", "h", "i") + // Create at-limit spectr with .git (depth 5) + // Using 4 path segments: a/b/c/d + atLimitPath := filepath.Join(tmpDir, "a", "b", "c", "d") mustMkdirAll(t, filepath.Join(atLimitPath, ".git")) mustMkdirAll(t, filepath.Join(atLimitPath, "spectr")) - // Create too-deep spectr with .git (depth 11, should not be found due to depth limit) - // Using 10 path segments: a/b/c/d/e/f/g/h/i/j - tooDeepPath := filepath.Join(tmpDir, "a", "b", "c", "d", "e", "f", "g", "h", "i", "j") + // Create too-deep spectr with .git (depth 6, should not be found due to depth limit) + // Using 5 path segments: a/b/c/d/e + tooDeepPath := filepath.Join(tmpDir, "a", "b", "c", "d", "e") mustMkdirAll(t, filepath.Join(tooDeepPath, ".git")) mustMkdirAll(t, filepath.Join(tooDeepPath, "spectr")) diff --git a/internal/discovery/skip_dirs.go b/internal/discovery/skip_dirs.go new file mode 100644 index 00000000..dac472d3 --- /dev/null +++ b/internal/discovery/skip_dirs.go @@ -0,0 +1,75 @@ +package discovery + +// skipDirsSet is a pre-computed set for O(1) directory skip lookups. +// Includes common large directories that should not be traversed. +var skipDirsSet = map[string]struct{}{ + gitDirName: {}, + "node_modules": {}, + "vendor": {}, + "target": {}, + "dist": {}, + "build": {}, + ".cache": {}, + ".local": {}, + ".npm": {}, + ".pnpm": {}, + ".yarn": {}, + ".cargo": {}, + ".rustup": {}, + "__pycache__": {}, + ".venv": {}, + "venv": {}, + ".tox": {}, + ".nox": {}, + ".eggs": {}, + "*.egg-info": {}, + ".pytest_cache": {}, + ".mypy_cache": {}, + ".ruff_cache": {}, + "coverage": {}, + ".coverage": {}, + ".gradle": {}, + ".m2": {}, + ".ivy2": {}, + "bin": {}, + "obj": {}, + "out": {}, + ".next": {}, + ".nuxt": {}, + ".svelte-kit": {}, + ".vercel": {}, + ".netlify": {}, + "_build": {}, + "site-packages": {}, + ".terraform": {}, + ".pulumi": {}, + ".serverless": {}, + "testdata": {}, + "fixtures": {}, + ".direnv": {}, + ".devenv": {}, + "result": {}, // Nix build output symlink + ".nix-defexpr": {}, + ".nix-profile": {}, + "zig-cache": {}, + "zig-out": {}, + ".zig-cache": {}, + "bazel-bin": {}, + "bazel-out": {}, + "bazel-testlogs": {}, +} + +// shouldSkipDirectory returns true if the directory should be skipped during downward discovery. +func shouldSkipDirectory(dirName string) bool { + // Fast path: check the pre-computed set + if _, skip := skipDirsSet[dirName]; skip { + return true + } + + // Skip hidden directories (except .git which is handled separately) + if len(dirName) > 1 && dirName[0] == '.' && dirName != gitDirName { + return true + } + + return false +}