Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type IndexerConfig struct {
MaxFileSize int64 `yaml:"max_file_size"`
IgnorePatterns []string `yaml:"ignore_patterns"`
Workers int `yaml:"workers"`
TreeFileDepth int `yaml:"tree_file_depth"`
}

type ProviderConfig struct {
Expand All @@ -51,9 +52,11 @@ type PromptsConfig struct {
ProjectStructureAnalysis string `yaml:"project_structure_analysis"`
SourceCodeAnalysis string `yaml:"source_code_analysis"`
DirectoryAnalysis string `yaml:"directory_analysis"`
EnrichedOverviewAnalysis string `yaml:"enriched_overview_analysis"`
}

const DefaultMaxFileSize = 1048576 // 1 MB
const DefaultTreeFileDepth = 3

var envVarRegexp = regexp.MustCompile(`\$\{(\w+)\}`)

Expand Down Expand Up @@ -188,6 +191,9 @@ func merge(home, project *Config) *Config {
if project.Indexer.Workers != 0 {
cfg.Indexer.Workers = project.Indexer.Workers
}
if project.Indexer.TreeFileDepth != 0 {
cfg.Indexer.TreeFileDepth = project.Indexer.TreeFileDepth
}

// IgnorePatterns: append project patterns to home patterns
if len(project.Indexer.IgnorePatterns) > 0 {
Expand All @@ -204,6 +210,9 @@ func merge(home, project *Config) *Config {
if project.Prompts.DirectoryAnalysis != "" {
cfg.Prompts.DirectoryAnalysis = project.Prompts.DirectoryAnalysis
}
if project.Prompts.EnrichedOverviewAnalysis != "" {
cfg.Prompts.EnrichedOverviewAnalysis = project.Prompts.EnrichedOverviewAnalysis
}

return &cfg
}
Expand All @@ -226,6 +235,9 @@ func setDefaults(cfg *Config) {
if cfg.Indexer.Workers <= 0 {
cfg.Indexer.Workers = 2
}
if cfg.Indexer.TreeFileDepth <= 0 {
cfg.Indexer.TreeFileDepth = DefaultTreeFileDepth
}
Comment on lines +238 to +240
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TreeFileDepth=0 is documented in walker.Options as a meaningful value ("root only"), but config loading treats 0 as "unset" (merge only copies when != 0, and defaults force <=0 to DefaultTreeFileDepth). This makes it impossible to configure root-only file display via YAML. Consider using a pointer (e.g., *int) to distinguish unset vs 0, or adopting a sentinel like -1 for "unset" and validating negative values.

Copilot uses AI. Check for mistakes.
if cfg.Prompts.ProjectStructureAnalysis == "" {
cfg.Prompts.ProjectStructureAnalysis = prompts.DefaultProjectStructureAnalysis
}
Expand All @@ -235,6 +247,9 @@ func setDefaults(cfg *Config) {
if cfg.Prompts.DirectoryAnalysis == "" {
cfg.Prompts.DirectoryAnalysis = prompts.DefaultDirectoryAnalysis
}
if cfg.Prompts.EnrichedOverviewAnalysis == "" {
cfg.Prompts.EnrichedOverviewAnalysis = prompts.DefaultEnrichedOverviewAnalysis
}
}

// pathToName converts an absolute path to a safe identifier.
Expand Down
11 changes: 11 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
}

func TestLoad_EnvVarNotSet(t *testing.T) {
os.Unsetenv("NONEXISTENT_VAR_VEDCODE")

Check failure on line 154 in internal/config/config_test.go

View workflow job for this annotation

GitHub Actions / lint-and-test

Error return value of `os.Unsetenv` is not checked (errcheck)

yml := `
llm:
Expand Down Expand Up @@ -541,6 +541,9 @@
if cfg.Prompts.DirectoryAnalysis != prompts.DefaultDirectoryAnalysis {
t.Error("expected default DirectoryAnalysis prompt")
}
if cfg.Prompts.EnrichedOverviewAnalysis != prompts.DefaultEnrichedOverviewAnalysis {
t.Error("expected default EnrichedOverviewAnalysis prompt")
}
}

func TestLoad_PromptsFromConfig(t *testing.T) {
Expand All @@ -549,6 +552,7 @@
project_structure_analysis: "Custom structure: ${CONTENT}"
source_code_analysis: "Custom code: ${CONTENT}"
directory_analysis: "Custom dir: ${DIR_PATH}"
enriched_overview_analysis: "Custom enriched: ${FILE_TREE}"
`
path := writeTestConfig(t, yml)

Expand All @@ -565,6 +569,9 @@
if cfg.Prompts.DirectoryAnalysis != "Custom dir: ${DIR_PATH}" {
t.Errorf("directory_analysis = %q, want custom", cfg.Prompts.DirectoryAnalysis)
}
if cfg.Prompts.EnrichedOverviewAnalysis != "Custom enriched: ${FILE_TREE}" {
t.Errorf("enriched_overview_analysis = %q, want custom", cfg.Prompts.EnrichedOverviewAnalysis)
}
}

func TestLoad_VectorSizeDefault(t *testing.T) {
Expand Down Expand Up @@ -645,6 +652,7 @@
project_structure_analysis: "home-structure"
source_code_analysis: "home-code"
directory_analysis: "home-dir"
enriched_overview_analysis: "home-enriched"
`
homePath := writeTestConfig(t, homeYml)

Expand All @@ -667,4 +675,7 @@
if cfg.Prompts.DirectoryAnalysis != "home-dir" {
t.Errorf("directory_analysis = %q, want %q", cfg.Prompts.DirectoryAnalysis, "home-dir")
}
if cfg.Prompts.EnrichedOverviewAnalysis != "home-enriched" {
t.Errorf("enriched_overview_analysis = %q, want %q", cfg.Prompts.EnrichedOverviewAnalysis, "home-enriched")
}
}
77 changes: 77 additions & 0 deletions internal/indexer/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,20 @@ func (t *dirTracker) results() (indexed, skipped, errors int) {
return int(t.indexed.Load()), int(t.skipped.Load()), int(t.errors.Load())
}

// topLevelSummaries returns summaries of top-level directories (direct children of root).
func (t *dirTracker) topLevelSummaries() map[string]string {
t.mu.Lock()
defer t.mu.Unlock()

result := make(map[string]string)
for dir, summary := range t.dirSummary {
if filepath.Dir(dir) == "." {
result[dir] = summary
}
}
return result
}

// Run executes the full indexing cycle for the project.
func Run(configPath string, force bool, logger *slog.Logger) error {
cfg, err := config.Load(configPath)
Expand Down Expand Up @@ -402,6 +416,7 @@ func Run(configPath string, force bool, logger *slog.Logger) error {
RootPath: rootPath,
MaxFileSize: cfg.Indexer.MaxFileSize,
IgnorePatterns: cfg.Indexer.IgnorePatterns,
TreeFileDepth: cfg.Indexer.TreeFileDepth,
})
if err != nil {
return fmt.Errorf("walking project: %w", err)
Expand Down Expand Up @@ -522,6 +537,10 @@ func Run(configPath string, force bool, logger *slog.Logger) error {

logger.Info(fmt.Sprintf("Found %d items to analyze (%d files, %d dirs)", totalItems, len(walkResult.Files), totalDirs))

// Collect root-level file summaries for Phase 3 (enriched overview)
var rootFilesMu sync.Mutex
var rootFileSummaries []*fileInfo

for _, relPath := range walkResult.Files {
absPath := filepath.Join(rootPath, relPath)

Expand All @@ -542,6 +561,14 @@ func Run(configPath string, force bool, logger *slog.Logger) error {
logger.Debug("file skipped (unchanged)", "file", relPath, "hash", hash)
skippedCount++
tracker.fileCompleted(relPath, existing.Summary, existing.FileHash)
if filepath.Dir(relPath) == "." {
rootFilesMu.Lock()
rootFileSummaries = append(rootFileSummaries, &fileInfo{
filePath: relPath,
summary: existing.Summary,
})
rootFilesMu.Unlock()
}
continue
}

Expand Down Expand Up @@ -628,13 +655,50 @@ func Run(configPath string, force bool, logger *slog.Logger) error {
)

tracker.fileCompleted(relPath, analysis.Summary, hash)
if filepath.Dir(relPath) == "." {
rootFilesMu.Lock()
rootFileSummaries = append(rootFileSummaries, &fileInfo{
filePath: relPath,
summary: analysis.Summary,
})
rootFilesMu.Unlock()
}
indexedCount.Add(1)
}(relPath, content, hash)
}
wg.Wait()

dirIndexed, dirSkipped, dirErrors := tracker.results()

// --- Stage 3: Enriched project overview ---
logger.Info("\n--- Stage 3: Enriched project overview ---")

topLevelDirs := tracker.topLevelSummaries()
if len(topLevelDirs) > 0 || len(rootFileSummaries) > 0 {
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rootFileSummaries is built from concurrent file goroutines, but buildFilesSummariesText preserves slice iteration order. That order is nondeterministic, so the enriched overview prompt can change between runs even when inputs are identical. Sort rootFileSummaries by filePath (or sort inside buildFilesSummariesText) before rendering the prompt.

Suggested change
if len(topLevelDirs) > 0 || len(rootFileSummaries) > 0 {
if len(topLevelDirs) > 0 || len(rootFileSummaries) > 0 {
if len(rootFileSummaries) > 0 {
sort.Slice(rootFileSummaries, func(i, j int) bool {
return rootFileSummaries[i].FilePath < rootFileSummaries[j].FilePath
})
}

Copilot uses AI. Check for mistakes.
topDirText := buildTopLevelDirsSummariesText(topLevelDirs)
rootFilesText := buildFilesSummariesText(rootFileSummaries)

enrichedPrompt := prompts.Render(cfg.Prompts.EnrichedOverviewAnalysis, map[string]string{
"FILE_TREE": walkResult.Tree,
"TOP_LEVEL_DIRS_SUMMARIES": topDirText,
"ROOT_FILES_SUMMARIES": rootFilesText,
})

logger.Info("Generating enriched project overview...")
enrichedOverview, err := llm.GenerateContent(enrichedPrompt)
if err != nil {
logger.Error(fmt.Sprintf("Error generating enriched overview: %v", err))
} else {
if err := os.WriteFile(overviewPath, []byte(enrichedOverview), 0o644); err != nil {
logger.Error(fmt.Sprintf("Error saving enriched overview: %v", err))
} else {
logger.Info(fmt.Sprintf("Enriched project overview saved to %s", overviewPath))
Comment on lines +692 to +695
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stage 3 writes the enriched overview back to overviewPath (project_overview.md), overwriting the Stage 1 structure-only overview saved earlier in the run. If both are useful, consider saving the enriched output to a separate file name (or appending a clearly separated section) to avoid losing the original output.

Suggested change
if err := os.WriteFile(overviewPath, []byte(enrichedOverview), 0o644); err != nil {
logger.Error(fmt.Sprintf("Error saving enriched overview: %v", err))
} else {
logger.Info(fmt.Sprintf("Enriched project overview saved to %s", overviewPath))
// Save enriched overview to a separate file to avoid overwriting the structure-only overview.
enrichedOverviewPath := overviewPath
if ext := filepath.Ext(overviewPath); ext != "" {
base := strings.TrimSuffix(overviewPath, ext)
enrichedOverviewPath = base + "_enriched" + ext
} else {
enrichedOverviewPath = overviewPath + "_enriched"
}
if err := os.WriteFile(enrichedOverviewPath, []byte(enrichedOverview), 0o644); err != nil {
logger.Error(fmt.Sprintf("Error saving enriched overview: %v", err))
} else {
logger.Info(fmt.Sprintf("Enriched project overview saved to %s", enrichedOverviewPath))

Copilot uses AI. Check for mistakes.
}
}
} else {
logger.Info("Skipping enriched overview (no directory/file summaries available)")
}

// --- Summary ---
logger.Info("\n=== Indexing complete ===")
logger.Info(fmt.Sprintf("Total files: %d", len(walkResult.Files)))
Expand Down Expand Up @@ -750,6 +814,19 @@ func buildFilesSummariesText(files []*fileInfo) string {
return sb.String()
}

// buildTopLevelDirsSummariesText formats top-level directory summaries for the enriched overview prompt.
func buildTopLevelDirsSummariesText(dirs map[string]string) string {
if len(dirs) == 0 {
return "(no directories)"
}
var lines []string
for dir, summary := range dirs {
lines = append(lines, "- "+dir+"/: "+summary)
}
sort.Strings(lines)
return strings.Join(lines, "\n")
}

// buildSubdirsSummariesText formats subdirectory summaries for the LLM prompt.
func buildSubdirsSummariesText(childDirs []string, dirSummary map[string]string) string {
if len(childDirs) == 0 {
Expand Down
34 changes: 34 additions & 0 deletions internal/prompts/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,40 @@ Based on the files and subdirectories above, provide:

Respond in JSON format.`

// DefaultEnrichedOverviewAnalysis is the built-in prompt for enriched project overview generation
// based on deep analysis of all source files and directories.
const DefaultEnrichedOverviewAnalysis = `You are a senior software architect. Generate a comprehensive project overview based on deep analysis of the codebase.

## File Tree

${FILE_TREE}

## Top-Level Directory Summaries
[[[
${TOP_LEVEL_DIRS_SUMMARIES}
]]]

## Root-Level File Summaries
[[[
${ROOT_FILES_SUMMARIES}
]]]

## Instructions

You have access to the project file tree AND semantic summaries of all top-level directories and root-level files, produced by deep analysis of every source file.

Based on this information, produce a comprehensive project overview:

1. **Framework / Platform** — What programming language(s), framework(s), or platform is this project built on?
2. **Architecture** — What architectural style does the project follow? Include specific patterns observed in the codebase.
3. **Modules / Components** — List the main modules, packages, or top-level components with detailed descriptions of their purpose and responsibilities.
4. **Domains** — Identify the business domains or bounded contexts present in the project.
5. **Patterns** — Note any recognizable design patterns or conventions.

## Output Format

Write a structured overview in Markdown. Be more detailed than a simple file-tree analysis — you have actual semantic knowledge about what each component does. Focus on accuracy and completeness. No more than 500 words.`

// Render replaces all occurrences of ${KEY} variables in the template
// with the provided values. Unknown variables are left as-is.
func Render(template string, vars map[string]string) string {
Expand Down
Loading
Loading