Skip to content
Merged
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
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
type Config struct {
Repo string `yaml:"repo"`
Ignore []string `yaml:"ignore"`
TestGlobs []string `yaml:"test_globs"`
Extractors []string `yaml:"extractors"`
Explainers []string `yaml:"explainers"`
Renderers []string `yaml:"renderers"`
Expand Down Expand Up @@ -68,6 +69,12 @@ func Default() *Config {
"**/Pods/**",
"**/.gradle/**",
},
// TestGlobs identify test/spec files. They stay ignored for normal indexing
// (still listed in Ignore above) — production architecture facts must not
// include test symbols — but the engine collects them separately for
// reference-only extraction so the dead-code detector can see that a
// production symbol is exercised by a test and not mis-report it as dead.
TestGlobs: []string{"**/*_spec.rb", "**/*_test.rb"},
Extractors: []string{"cpp", "go", "java", "kotlin", "openapi", "php", "python", "typescript", "swift", "ruby"},
Explainers: []string{"cycles", "layers", "crossrepo", "coverage", "unused-routes", "god-class", "hotspots", "dependency-depth", "exported-surface", "complexity-outliers"},
Renderers: []string{"llm_context"},
Expand Down
11 changes: 10 additions & 1 deletion internal/engine/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ import (
// v15: C/C++ salvage function-pointer refs from file-scope ERROR regions (macro-opened structs like MACHINE_START/DT_MACHINE_START ... MACHINE_END).
// v16: extend that salvage to file-scope assignment_expression/field_expression fragments (machine_desc blocks parse that way when surrounded by other code).
// v17: full-tree salvage of `.field = fn` macro-struct debris (machine_desc) regardless of where tree-sitter scatters it (skips function bodies).
const cacheVersion = "v17"
// v18: Ruby records custom class-body macro names + resolves self/self.class receivers as call edges (dead-code precision). KindTestRef facts index outbound refs from spec/test files.
// v19: Ruby captures call edges outside method bodies — class/module-body qualified & argument-position calls attach to the class fact; top-level/file-scope calls (fixtures, after_initialize blocks) attach to a new KindFileRef fact folded by the orphan collector.
// v20: Ruby call capture outside method bodies generalized to a per-scope pass (whole class/module body + top-level program), so assignment RHS and all non-`call` statements — e.g. `x = GlobalSetting.foo`, `CONST = { Proc.new { Group.bar } }` — are covered, not just bare call statements.
// v21: Ruby records the static prefix of interpolated symbols (`:"report_#{type}"` -> "report_") as a KindFileRef prop, so the dead-code detector treats dynamically dispatched (public_send/send) same-prefix methods as used.
// v22: Ruby records `super` as a call to the same-named ancestor method, and literal-symbol dispatch args (`obj.try(:foo)`, `send(:bar)`, `respond_to?(:baz)`) as calls to the named method.
// v23: Ruby captures no-arg calls on a chained receiver (ActiveRecord scope/class-method chains `Model.scope.final`, `assoc.class_method`, `x.class.method`; cheap attribute reads skipped) and indexes .rake/Rakefile files.
// v24: Ruby walks method default-parameter values for calls (`def f(x = self.class.foo)`) and records single-level predicate/bang calls (`viewer.rich?`, `x.save!`) which — unlike plain attribute reads — are unambiguously method invocations.
// v25: Ruby folds `delegate :a, :b, ..., to: X` method names as calls, and records calls on a bare-method (non-local identifier) receiver when the method name is scope-like (`some_relation.pluck_job_id`).
// v26: Ruby records scope-like (underscored) method calls on ANY identifier receiver, including local relation variables (`items = ...; items.preload_relations`).
const cacheVersion = "v26"

// extractorCache holds per-extractor facts keyed by a content hash of the files
// the extractor depends on. It is loaded from disk at the start of a snapshot and
Expand Down
111 changes: 104 additions & 7 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/enola-labs/enola/internal/facts"
"github.com/enola-labs/enola/internal/linkers/crossrepo"
"github.com/enola-labs/enola/internal/renderers"
"github.com/enola-labs/enola/pkg/plugin"
)

// Engine orchestrates the snapshot generation pipeline.
Expand Down Expand Up @@ -184,12 +185,12 @@ func (e *Engine) GenerateSnapshot(ctx context.Context, repoPath string, appendMo

// 1. Walk repository and collect files
tStage := time.Now()
files, err := e.walkRepo(absRepo)
files, testFiles, err := e.walkRepo(absRepo)
if err != nil {
return nil, fmt.Errorf("walking repo: %w", err)
}
tWalk = time.Since(tStage)
log.Printf("[engine] found %d files in %s", len(files), absRepo)
log.Printf("[engine] found %d files (%d test files) in %s", len(files), len(testFiles), absRepo)

// 2. Compute file hashes (for snapshot metadata)
tStage = time.Now()
Expand Down Expand Up @@ -218,6 +219,11 @@ func (e *Engine) GenerateSnapshot(ctx context.Context, repoPath string, appendMo
}
}
}
// Reference-only extraction over test/spec files. Runs every snapshot (not
// cached with the main extractors) and adds only KindTestRef facts, so a
// production symbol exercised solely by a test is not mis-reported as dead.
e.runTestRefExtractors(ctx, absRepo, testFiles)

tExtract = time.Since(tStage)
newCount := e.store.Count()
log.Printf("[engine] extracted %d facts using %d extractors", newCount, len(usedExtractors))
Expand Down Expand Up @@ -388,10 +394,12 @@ func (e *Engine) flagUnmatchedRoutes() {
}
}

// walkRepo collects all files in the repo, applying ignore patterns.
func (e *Engine) walkRepo(repoPath string) ([]string, error) {
var files []string
err := filepath.WalkDir(repoPath, func(path string, d fs.DirEntry, err error) error {
// walkRepo collects all files in the repo, applying ignore patterns. It returns
// the indexable source files plus, separately, the test/spec files matched by
// TestGlobs — those are excluded from normal indexing but collected for
// reference-only extraction (see runTestRefExtractors).
func (e *Engine) walkRepo(repoPath string) (files, testFiles []string, err error) {
err = filepath.WalkDir(repoPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
Expand All @@ -406,6 +414,12 @@ func (e *Engine) walkRepo(repoPath string) ([]string, error) {
if d.IsDir() {
return filepath.SkipDir
}
// An ignored FILE that is a test/spec is not indexed as production
// source, but is collected for reference-only extraction so a
// production symbol exercised only by a test does not look dead.
if e.matchesTestGlob(relPath) {
testFiles = append(testFiles, relPath)
}
return nil
}

Expand All @@ -414,7 +428,49 @@ func (e *Engine) walkRepo(repoPath string) ([]string, error) {
}
return nil
})
return files, err
return files, testFiles, err
}

// matchesTestGlob reports whether a repo-relative path matches any TestGlob.
func (e *Engine) matchesTestGlob(relPath string) bool {
return matchAnyGlob(filepath.ToSlash(relPath), e.cfg.TestGlobs)
}

// matchAnyGlob reports whether a forward-slash path matches any of the patterns,
// mirroring the "**/<name>/**", trailing-"/**", and "**/<glob>" handling of
// isIgnored so test-glob matching stays consistent with ignore matching.
func matchAnyGlob(relPath string, patterns []string) bool {
for _, pattern := range patterns {
if strings.HasPrefix(pattern, "**/") && strings.HasSuffix(pattern, "/**") {
seg := strings.TrimSuffix(strings.TrimPrefix(pattern, "**/"), "/**")
if seg != "" && !strings.Contains(seg, "/") {
for _, part := range strings.Split(relPath, "/") {
if part == seg {
return true
}
}
}
}
if strings.HasSuffix(pattern, "/**") {
dirPrefix := strings.TrimSuffix(pattern, "/**")
if relPath == dirPrefix || strings.HasPrefix(relPath, dirPrefix+"/") {
return true
}
}
if m, err := filepath.Match(pattern, relPath); err == nil && m {
return true
}
if strings.HasPrefix(pattern, "**/") {
sub := strings.TrimPrefix(pattern, "**/")
if m, err := filepath.Match(sub, filepath.Base(relPath)); err == nil && m {
return true
}
if m, err := filepath.Match(sub, relPath); err == nil && m {
return true
}
}
}
return false
}

// isIgnored checks whether a path matches any ignore pattern.
Expand Down Expand Up @@ -530,6 +586,47 @@ func (e *Engine) runExtractors(ctx context.Context, repoPath string, files []str
return usedNames, nil
}

// runTestRefExtractors runs reference-only extraction over the test/spec files
// for every enabled, detected extractor that implements plugin.TestRefExtractor.
// It scopes each extractor to the test files it owns and adds the resulting
// KindTestRef facts to the store. Errors are logged, not fatal.
func (e *Engine) runTestRefExtractors(ctx context.Context, repoPath string, testFiles []string) {
if len(testFiles) == 0 {
return
}
for _, ext := range e.extractors.All() {
if !e.cfg.IsExtractorEnabled(ext.Name()) {
continue
}
tr, ok := ext.(plugin.TestRefExtractor)
if !ok {
continue
}
if detected, err := ext.Detect(repoPath); err != nil || !detected {
continue
}
owned := testFiles
if fo, ok := ext.(plugin.FileOwner); ok {
owned = owned[:0:0]
for _, f := range testFiles {
if fo.OwnsFile(f) {
owned = append(owned, f)
}
}
}
if len(owned) == 0 {
continue
}
refFacts, err := tr.ExtractTestRefs(ctx, repoPath, owned)
if err != nil {
log.Printf("[engine] extractor %s test-ref error: %v", ext.Name(), err)
continue
}
e.store.Add(refFacts...)
log.Printf("[engine] extractor %s: emitted %d test-ref facts from %d files", ext.Name(), len(refFacts), len(owned))
}
}

// runExplainers runs all enabled explainers.
func (e *Engine) runExplainers(ctx context.Context) ([]facts.Insight, []string, error) {
var allInsights []facts.Insight
Expand Down
39 changes: 37 additions & 2 deletions internal/extractors/rubyextractor/ruby.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,35 @@ func (e *RubyExtractor) Extract(ctx context.Context, repoPath string, files []st
return allFacts, nil
}

// ExtractTestRefs implements plugin.TestRefExtractor. It parses test/spec files
// for the SOLE purpose of capturing their outbound references into production
// code, emitting one facts.KindTestRef fact per file that carries only RelCalls
// edges — no symbols. Test methods therefore never become dead-code candidates
// (which the orphans package explicitly excludes), and no symbol/module/route
// explainer is affected, while the dead-code detector can still see that a
// production symbol is exercised by a test and not mis-report it as dead.
func (e *RubyExtractor) ExtractTestRefs(ctx context.Context, repoPath string, files []string) ([]facts.Fact, error) {
var rbFiles []string
for _, relFile := range files {
if isRubyFile(relFile) {
rbFiles = append(rbFiles, relFile)
}
}
perFile := parallel.MapFiles(ctx, rbFiles, func(relFile string) []facts.Fact {
src, err := os.ReadFile(filepath.Join(repoPath, relFile))
if err != nil {
log.Printf("[ruby-extractor] error reading test file %s: %v", relFile, err)
return nil
}
return extractTestRefsAST(src, relFile)
})
var out []facts.Fact
for _, ff := range perFile {
out = append(out, ff...)
}
return out, nil
}

// --- Rails detection ---

func detectRailsProject(repoPath string) bool {
Expand All @@ -127,9 +156,15 @@ func detectRailsProject(repoPath string) bool {
return false
}

// isRubyFile returns true if the file has a .rb extension.
// isRubyFile returns true if the file is Ruby source: a .rb/.rake file or a
// Rakefile. Rake tasks are Ruby and call into app code (e.g. from lib/tasks/),
// so indexing them lets those calls resolve (dead-code precision).
func isRubyFile(path string) bool {
return strings.HasSuffix(strings.ToLower(path), ".rb")
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".rb") || strings.HasSuffix(lower, ".rake") {
return true
}
return filepath.Base(path) == "Rakefile"
}

// OwnsFile implements plugin.FileOwner for incremental caching.
Expand Down
Loading
Loading