Skip to content

Conversation

@yhakbar
Copy link
Collaborator

@yhakbar yhakbar commented Jan 31, 2026

Description

Redesigns the internal/discovery package to use a multi-phase pipeline architecture that lazily evaluates components based on filter requirements. This eliminates unnecessary parsing when there's a nuanced set of requirements for which components need parsing.

For example, say a user uses the following filter query:

terragrunt run --filter './foo' --filter '{./bar}...' --filter '!./baz'

Currently, the logic in discovery sees that dependency expression on bar and decides that everything discovered needs parsing by setting RequiresParse to true. That's a lot more coarse than things need to be. We don't, for example, need to parse baz unless it's in the dependency graph of bar. We also don't need to parse anything that isn't in the dependency graph of bar or foo.

The goal of this update is to classify the filter expressions provided and make decisions during discovery as to whether any particular component can be dropped earlier in discovery based on whether the filters users use make it impossible to end up in the final result set.

Key changes:

  • Replaced monolithic discovery call with 5-phase pipeline: Filesystem → Parse → Graph → Relationship → Filter
  • Added filter.Classifier that analyzes filter expressions upfront to determine minimum evaluation requirements
  • Implemented dual-channel output (discovered/candidates) enabling lazy evaluation between phases
  • Components only parsed when filters require it (e.g., reading=config/, source=)

Components are classified into three statuses during discovery:

  • StatusDiscovered: Matches a positive filter (path, attribute, or git expression). These are considered fully discovered and will end up at the final filter stage.
  • StatusCandidate: Needs further evaluation (graph target, requires parsing, or potential dependent). These are considered candidates for discovery and are sent through the subsequent discovery phases to see if something can qualify it (e.g. it's confirmed to be a dependency of something in a graph expression).
  • StatusExcluded: Only matches negated filters, or positive filters exist but none match. These are disqualified early and are not sent to the final filter phase.

Phase Flow

  1. Filesystem + Worktree (concurrent): Walk directories, classify via CandidacyClassifier. For Git filters ([ref...ref]), discover in temporary worktrees
  2. Parse (if needed): Parse HCL for candidates with CandidacyReasonRequiresParse, re-classify
  3. Graph (if needed): Traverse dependencies (target|N) and/or dependents (...target)
  4. Relationship (optional): Build complete dependency graph for execution ordering (this phase is skipped for discovery commands without the use of --dag).
  5. Final: Merge, deduplicate, apply final filter evaluation, cycle detection

Side-effects

This PR started out because #5469 was too hacky, and we needed a fix for #5467.

We were silently ignoring failed parsing due to logic like ShouldSkipUnitWithoutTerraform and WithSuppressParseErrors.

Removing this logic resulted in quite a lot of fallout, as we had tests that were silently passing due to the suppressions and skips. It's likely that there are users out there that had Terragrunt silently ignoring parsing errors, and that they'll need to start explicitly excluding things with negated filters or fix their configurations.

TODOs

Read the Gruntwork contribution guidelines.

  • I authored this code entirely myself
  • I am submitting code based on open source software (e.g. MIT, MPL-2.0, Apache)]
  • I am adding or upgrading a dependency or adapted code and confirm it has a compatible open source license
  • Update the docs.
  • Run the relevant tests successfully, including pre-commit checks.
  • Include release notes. If this PR is backward incompatible, include a migration guide.

Release Notes (draft)

Added / Removed / Updated [X].

Migration Guide

Summary by CodeRabbit

  • Refactor

    • Discovery redesigned into a multi‑phase pipeline (filesystem, parse, graph, relationship, worktree); legacy discovery subsystems removed.
  • New Features

    • Worktree/git-aware discovery, graph-target pruning, and a classifier-driven filtering model with candidacy analysis and parse/graph phases.
  • Bug Fixes

    • Parse errors now surface by default; units are no longer skipped for lacking Terraform files.
  • Documentation & Tests

    • Expanded package docs and extensive new integration/unit tests; several older tests were removed or disabled.

@vercel
Copy link

vercel bot commented Jan 31, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
terragrunt-docs Ready Ready Preview, Comment Feb 2, 2026 4:38pm

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 31, 2026

📝 Walkthrough

Walkthrough

Replaces legacy monolithic discovery with a new multi‑phase pipeline (Filesystem, Worktree, Parse, Graph, Relationship, Final); adds classifier/matcher, candidacy analysis, phases, helpers, errors, and many tests; removes old Dependency/Dependent/Relationship/Worktree discovery implementations and queue-based exclude logic.

Changes

Cohort / File(s) Summary
Core model & API
internal/discovery/types.go, internal/discovery/doc.go, internal/discovery/constructor.go, internal/discovery/discovery.go
Introduce Discovery model, Phase types, new constructor New(workingDir) and fluent With* API; refactor Discover into multi‑phase orchestration.
Phases
internal/discovery/phase_filesystem.go, internal/discovery/phase_parse.go, internal/discovery/phase_graph.go, internal/discovery/phase_relationship.go, internal/discovery/phase_worktree.go
Add Filesystem, Parse, Graph, Relationship, Worktree phases implementing Phase.Run with worker pools, channels and concurrent traversal/graph logic.
Helpers & types
internal/discovery/helpers.go, internal/discovery/errors.go, internal/discovery/types.go
Add helpers (DefaultConfigFilenames, isExternal, merge/dedup), new error types (PhaseError, ClassificationError, MaxDepthReachedError), and Discovery/Phase I/O types.
Filter subsystem
internal/filter/classifier.go, internal/filter/candidacy.go, internal/filter/matcher.go, internal/filter/walk.go, internal/filter/evaluator.go
Add pre‑classifier (Analyze/Classify), candidacy analysis, expression walker, component matcher and evaluator refinements (GraphDirection public, path/attribute/git matching changes).
Queue / exclude logic removed
internal/discovery/queue_filters.go (deleted)
Remove legacy queue-based exclude/apply logic that marked units excluded based on terragrunt exclude blocks.
Removed legacy discovery implementations
internal/discovery/dependencydiscovery.go (deleted), internal/discovery/dependentdiscovery.go (deleted), internal/discovery/relationshipdiscovery.go (deleted), internal/discovery/worktreediscovery.go (deleted)
Delete old Dependency/Dependent/Relationship/Worktree discovery subsystems — functionality migrated into new phase implementations.
Worktree integration & tests
internal/discovery/phase_worktree.go, internal/discovery/phase_worktree_test.go, internal/discovery/phase_worktree_integration_test.go
Add worktree discovery, component pairing, directory hashing and extensive worktree tests.
Parse / read-files plumbing
internal/discovery/phase_parse.go, internal/discovery/helpers.go
Introduce parse-on-demand, parseIncludes/parseExclude/readFiles flags, parsing workers and reclassification logic.
Tests: added & removed
internal/discovery/discovery_integration_test.go, internal/discovery/filter_test.go, internal/discovery/phase_test.go, internal/filter/*_test.go, internal/discovery/filter_integration_test.go (deleted), internal/discovery/worktreediscovery_test.go (deleted)
Add extensive new unit/integration tests for phases, classifier, graph filters and worktrees; remove large legacy integration tests.
Runner & unit handling changes
internal/runner/runnerpool/builder_helpers.go, internal/runner/runnerpool/runner.go, internal/runner/runnerpool/helpers_test.go
Stop applying WithSuppressParseErrors in builder; remove ShouldSkipUnitWithoutTerraform and related tests — units without TF files are no longer skipped.
Component & util changes
internal/component/component.go, internal/util/file.go
Switch path resolution to new util.ResolvePath for consistent symlink handling.
Fixtures & integration updates
test/fixtures/..., test/integration_*.go
Add fixtures for parse-error and units-reading tests; update integration tests to surface parse errors and skip selected flaky tests.
Parser tweaks
internal/filter/parser.go, internal/filter/evaluator_test.go
Collapse consecutive negations in parser; small evaluator test expectation update.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Discovery
    participant Filesystem as FilesystemPhase
    participant Worktree as WorktreePhase
    participant Parse as ParsePhase
    participant Graph as GraphPhase
    participant Relationship as RelationshipPhase

    Client->>Discovery: Discover(ctx, logger, opts)
    Discovery->>Filesystem: Run(PhaseInput)
    Filesystem-->>Discovery: Discovered/Candidates/Errors

    alt Worktrees configured
        Discovery->>Worktree: Run(PhaseInput)
        Worktree-->>Discovery: Discovered/Candidates/Errors
    end

    Discovery->>Parse: Run(PhaseInput)
    Parse-->>Discovery: Discovered/Candidates/Errors

    Discovery->>Graph: Run(PhaseInput)
    Graph-->>Discovery: Discovered/Candidates/Errors

    Discovery->>Relationship: Run(PhaseInput)
    Relationship-->>Discovery: Discovered/Candidates/Errors

    Discovery-->>Client: Aggregated components, error
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • ThisGuyCodes
  • denis256
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 79.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: refactoring discovery to handle parsing more nuanced and selectively based on filter requirements.
Description check ✅ Passed The PR description comprehensively documents the redesign with clear explanations of the multi-phase pipeline, component classification statuses, phase flow, and migration implications for users.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch chore/refactor-discovery-for-more-nuanced-parse-handling

Comment @coderabbitai help to get the list of available commands and usage tips.

@yhakbar
Copy link
Collaborator Author

yhakbar commented Jan 31, 2026

@CodeRabbit review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 31, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 13

🤖 Fix all issues with AI agents
In `@internal/discovery/discovery_integration_test.go`:
- Around line 109-132: The test TestDiscovery_StackHiddenAllowed claims to
verify behavior with NoHidden but never enables it; update the discovery builder
to call WithNoHidden() on the discovery.New(tmpDir) chain (i.e., replace or
augment discovery.New(tmpDir).WithDiscoveryContext(...) with
discovery.New(tmpDir).WithNoHidden().WithDiscoveryContext(...)) so the
Discover(ctx, l, opts) call runs with NoHidden enabled and the assertion that
components.Filter(component.UnitKind).Paths() contains stackHiddenDir is
meaningful.

In `@internal/discovery/discovery_test.go`:
- Around line 205-209: The test currently ignores the error return from
classifier.Classify(c, ctx); capture the third return value (err) when calling
classifier.Classify in the test (alongside status and reason) and assert that
err is nil (e.g., use require.NoError(t, err) or assert.NoError(t, err)) before
checking status and reason to ensure the call succeeded; the call site to update
is the classifier.Classify(c, ctx) invocation that uses
filter.ClassificationContext{}.

In `@internal/discovery/doc.go`:
- Around line 66-74: Replace the example's call to discovery.New(workingDir)
with the package-recommended constructor NewDiscovery(workingDir); update the
example chain to start with NewDiscovery(...) so it benefits from the smarter
initialization (CPU-aware worker count and discoveryContext) used by
NewForDiscoveryCommand and NewForHCLCommand while keeping the same fluent calls
(.WithFilters, .WithDiscoveryContext, .WithRelationships,
.WithMaxDependencyDepth) and subsequent Discover invocation.

In `@internal/discovery/helpers.go`:
- Around line 70-75: The current check uses strings.HasPrefix(relPath, "..")
which false-positives paths like "..foo"; change the test to only treat true
parent escapes by checking relPath == ".." or starts with ".." +
filepath.Separator (e.g. relPath == ".." || strings.HasPrefix(relPath,
".."+string(os.PathSeparator))). Update the return expression that currently
uses relPath to use this precise check (referencing the relPath variable and
filepath package) so only actual parent-directory escapes are considered
external.
- Around line 132-175: mergeResults depends on data channels
(PhaseOutput.Discovered, .Candidates, .Errors) being closed before the Done
channel to avoid fragile ordering from LIFO defers; update phase goroutines (the
implementations that write to these channels) to explicitly close Discovered,
Candidates and Errors before signaling Done (i.e., close data channels first,
then close/send on Done) rather than relying on defer ordering, or add
documentation in those phase functions describing the required close sequence so
mergeResults can safely range over the channels after Done.

In `@internal/discovery/phase_filesystem.go`:
- Around line 176-189: The hidden-directory check in
FilesystemPhase.skipDirIfIgnorable currently skips any directory whose base
begins with "." (via discovery.noHidden), which unintentionally excludes
".terragrunt-stack"; update the method to exempt ".terragrunt-stack" from being
skipped by adding a special-case in skipDirIfIgnorable (method) so that when
discovery.noHidden is true it returns nil instead of filepath.SkipDir for base
== ".terragrunt-stack" (i.e., allow ".terragrunt-stack" to be discovered while
still skipping other dot-prefixed dirs).

In `@internal/discovery/phase_graph.go`:
- Around line 604-611: The code in phase_graph.go uses discovery.configFilenames
directly when checking and creating candidates; add the same defensive fallback
as in phase_filesystem.go by defaulting an empty discovery.configFilenames to
DefaultConfigFilenames before using it (i.e., ensure a local variable or
overwrite discovery.configFilenames with DefaultConfigFilenames if len==0), then
use that variable in the slices.Contains check and when calling
createComponentFromPath(discovery.configFilenames, discovery.discoveryContext)
so the phase is safe if configFilenames is not set.
- Around line 764-804: The nested g.Go call inside the goroutine that ranges
over discoveredDependents violates errgroup rules; remove the inner g.Go and
invoke p.discoverDependentsUpstream inline instead, keeping creation of
freshVisitedDirs and the filepath.Dir(dependent.Path()) arguments the same; if
discoverDependentsUpstream returns an error, lock errMu, append to errs, unlock,
and continue (return nil only for the outer goroutine), and ensure no new g.Go
calls are made from within running goroutines (all g.Go calls must happen before
g.Wait()).

In `@internal/discovery/phase_worktree.go`:
- Around line 424-442: The goroutines in the shaGroup are writing to the shared
outer variable err (and implicitly to fromSHA/toSHA) causing a data race; fix by
using per-goroutine local result and error variables (e.g. fsha, ferr and tsha,
terr) inside each shaGroup.Go closure, return the local error from the closure,
and after shaGroup.Wait() copy the local fsha/tsha into the outer fromSHA/toSHA
and handle/return the error if any; reference GenerateDirSHA256 and shaGroup.Go
closures to find the affected code.
- Around line 162-168: The current handling after discoveryGroup.Wait() drops
errors when the errors channel is full; change it to a context-aware blocking
send so errors aren't lost: replace the non-blocking select (select { case
errors <- err: default: }) with a blocking send that waits for either the send
to succeed or context cancellation (select { case errors <- err: case
<-ctx.Done(): }) using the existing ctx passed into this scope (or add one if
missing); alternatively, if you prefer buffering, increase the capacity where
the errors channel is created to accommodate expected concurrent errors
(reference discoveryGroup.Wait(), the errors channel variable, and ctx).

In `@internal/filter/classifier.go`:
- Around line 216-222: The conditional inside the parse-required check is dead
code because both branches return the same values; in the method using
c.parseExprs and ctx.ParseDataAvailable, remove the redundant inner if (the
matchesFilesystem || matchesGit check) and replace it with a single return
StatusCandidate, CandidacyReasonRequiresParse, -1 so that the code simply checks
if len(c.parseExprs) > 0 && !ctx.ParseDataAvailable and then returns the
candidacy result without the pointless branch (update the block containing
c.parseExprs, ctx.ParseDataAvailable, matchesFilesystem, matchesGit
accordingly).

In `@internal/filter/matcher.go`:
- Around line 19-41: MatchComponent currently treats PrefixExpression with
operator "!" as a no-op and evaluates InfixExpression with "|" like an AND;
update it so PrefixExpression with Operator "!" returns the inverted result of
MatchComponent(c, node.Right) (i.e., call MatchComponent and return !match along
with any error), and change InfixExpression with Operator "|" to implement
short-circuit OR: evaluate left with MatchComponent(c, node.Left) and if true
return true, otherwise evaluate and return MatchComponent(c, node.Right); keep
error propagation from the recursive calls (e.g., in MatchComponent,
PrefixExpression, InfixExpression).

In `@test/integration_debug_test.go`:
- Around line 300-305: Replace the unconditional t.Skip in
test/integration_debug_test.go with a documented quarantine: either re-enable
the test, or gate its skip behind an explicit opt-out (e.g., check an env var
like SKIP_INTEGRATION_DEBUG) and call t.Skipf only when the opt-out is set,
and/or add a TODO comment referencing a tracked issue and timeline (include the
issue number like `#5469`) so the test remains discoverable and the regression gap
is tracked; ensure you update the test’s t.Skip/t.Skipf call and add the issue
reference and expected timeline in the test file.
🧹 Nitpick comments (7)
internal/filter/classifier.go (2)

251-257: Silently ignoring errors from MatchComponent.

Errors from MatchComponent are discarded throughout the matching helpers (matchesAnyNegated, matchesAnyPositive, matchesFilesystemExpression, matchesGraphExpressionTarget). While this may be intentional to avoid blocking classification on transient errors, consider at minimum debug-level logging to aid troubleshooting.

♻️ Optional: Log matching errors at debug level
 func (c *Classifier) matchesAnyNegated(comp component.Component) bool {
 	return slices.ContainsFunc(c.negatedExprs, func(expr Expression) bool {
-		match, _ := MatchComponent(comp, expr)
+		match, err := MatchComponent(comp, expr)
+		if err != nil {
+			c.logger.Debugf("error matching component %s against negated expression: %v", comp.Path(), err)
+		}
 		return match
 	})
 }

117-135: Analyze always returns nil error.

The method signature indicates it can return an error, but it never does. This could be intentional for future extensibility, but currently it's misleading for callers.

Consider either:

  1. Changing the signature to not return an error if none is expected
  2. Adding a comment explaining this is for future extensibility
internal/discovery/phase_worktree_test.go (1)

534-547: Potential out-of-bounds access in getRelativePath helper.

The helper function assumes c.Path() always starts with dc.WorkingDir. If this assumption is violated (e.g., path doesn't have the working directory prefix), the slice operation at line 541 could produce unexpected results or panic if len(dc.WorkingDir) > len(c.Path()).

Consider using filepath.Rel or strings.TrimPrefix for safer path handling:

🔧 Safer implementation using strings.TrimPrefix
 func getRelativePath(c component.Component) string {
 	dc := c.DiscoveryContext()
 	if dc == nil {
 		return c.Path()
 	}
 
-	rel := c.Path()[len(dc.WorkingDir):]
+	rel := strings.TrimPrefix(c.Path(), dc.WorkingDir)
 	if rel == "" {
 		return "/"
 	}
 
 	return filepath.Clean(rel)
 }
internal/discovery/phase_worktree.go (3)

292-298: Redundant error check appears unreachable.

If g.Wait() returns an error (line 292), we return early. If it returns nil, all goroutines succeeded (returned nil), meaning no errors were appended to errs. The check at lines 296-298 appears to be dead code.

Consider removing the redundant check or, if intentional (e.g., for defensive coding), add a comment explaining when this path could be taken.


396-402: Same redundant error check pattern as in discoverChangesInWorktreeStacks.

The check at lines 400-402 follows the same pattern - it's only reachable when discoveryGroup.Wait() returns nil, which means no goroutine returned an error and errs should be empty.


461-489: Consider using a map for O(n) matching instead of O(n×m) nested loops.

The current implementation has quadratic complexity. For large worktrees with many components, building a map of suffixes could improve performance.

♻️ Proposed optimization
 func MatchComponentPairs(
 	fromComponents component.Components,
 	toComponents component.Components,
 ) []ComponentPair {
 	componentPairs := make([]ComponentPair, 0, max(len(fromComponents), len(toComponents)))
 
+	// Build index of to-components by their relative path suffix
+	toIndex := make(map[string]component.Component, len(toComponents))
+	for _, toComponent := range toComponents {
+		suffix := filepath.Clean(strings.TrimPrefix(
+			toComponent.Path(),
+			toComponent.DiscoveryContext().WorkingDir,
+		))
+		toIndex[suffix] = toComponent
+	}
+
 	for _, fromComponent := range fromComponents {
 		fromComponentSuffix := strings.TrimPrefix(
 			fromComponent.Path(),
 			fromComponent.DiscoveryContext().WorkingDir,
 		)
+		cleanedSuffix := filepath.Clean(fromComponentSuffix)
 
-		for _, toComponent := range toComponents {
-			toComponentSuffix := strings.TrimPrefix(
-				toComponent.Path(),
-				toComponent.DiscoveryContext().WorkingDir,
-			)
-
-			if filepath.Clean(fromComponentSuffix) == filepath.Clean(toComponentSuffix) {
-				componentPairs = append(componentPairs, ComponentPair{
-					FromComponent: fromComponent,
-					ToComponent:   toComponent,
-				})
-			}
+		if toComponent, ok := toIndex[cleanedSuffix]; ok {
+			componentPairs = append(componentPairs, ComponentPair{
+				FromComponent: fromComponent,
+				ToComponent:   toComponent,
+			})
 		}
 	}
 
 	return componentPairs
 }
internal/discovery/discovery.go (1)

552-573: Consider logging removed components during cycle removal.

The cycle removal silently removes components. Adding debug logging would help users understand which components were removed to break cycles.

📝 Suggested enhancement

Since this function doesn't have access to a logger, consider returning the removed paths for logging by the caller, or accept a logger parameter:

-func removeCycles(components component.Components) component.Components {
+func removeCycles(l log.Logger, components component.Components) component.Components {
 	var (
 		c   component.Component
 		err error
 	)
 
 	for range maxCycleRemovalAttempts {
 		c, err = components.CycleCheck()
 		if err == nil {
 			break
 		}
 
 		if c == nil {
 			break
 		}
 
+		l.Debugf("Removing component %s to break dependency cycle", c.Path())
 		components = components.RemoveByPath(c.Path())
 	}
 
 	return components
 }

Comment on lines 109 to 132
// TestDiscovery_StackHiddenAllowed tests that .terragrunt-stack directories are discovered even with NoHidden.
func TestDiscovery_StackHiddenAllowed(t *testing.T) {
t.Parallel()

tmpDir := helpers.TmpDirWOSymlinks(t)
stackHiddenDir := filepath.Join(tmpDir, ".terragrunt-stack", "u")
require.NoError(t, os.MkdirAll(stackHiddenDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(stackHiddenDir, "terragrunt.hcl"), []byte(""), 0644))

l := logger.CreateLogger()
opts := &options.TerragruntOptions{
WorkingDir: tmpDir,
}

ctx := t.Context()

// Even with NoHidden, .terragrunt-stack contents should be discovered
d := discovery.New(tmpDir).
WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir})

components, err := d.Discover(ctx, l, opts)
require.NoError(t, err)
assert.Contains(t, components.Filter(component.UnitKind).Paths(), stackHiddenDir)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Test doesn’t enable NoHidden despite the stated intent.

This test never calls WithNoHidden(), so it only validates the default behavior.

🛠️ Suggested fix
-	d := discovery.New(tmpDir).
-		WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir})
+	d := discovery.New(tmpDir).
+		WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
+		WithNoHidden()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// TestDiscovery_StackHiddenAllowed tests that .terragrunt-stack directories are discovered even with NoHidden.
func TestDiscovery_StackHiddenAllowed(t *testing.T) {
t.Parallel()
tmpDir := helpers.TmpDirWOSymlinks(t)
stackHiddenDir := filepath.Join(tmpDir, ".terragrunt-stack", "u")
require.NoError(t, os.MkdirAll(stackHiddenDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(stackHiddenDir, "terragrunt.hcl"), []byte(""), 0644))
l := logger.CreateLogger()
opts := &options.TerragruntOptions{
WorkingDir: tmpDir,
}
ctx := t.Context()
// Even with NoHidden, .terragrunt-stack contents should be discovered
d := discovery.New(tmpDir).
WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir})
components, err := d.Discover(ctx, l, opts)
require.NoError(t, err)
assert.Contains(t, components.Filter(component.UnitKind).Paths(), stackHiddenDir)
}
// TestDiscovery_StackHiddenAllowed tests that .terragrunt-stack directories are discovered even with NoHidden.
func TestDiscovery_StackHiddenAllowed(t *testing.T) {
t.Parallel()
tmpDir := helpers.TmpDirWOSymlinks(t)
stackHiddenDir := filepath.Join(tmpDir, ".terragrunt-stack", "u")
require.NoError(t, os.MkdirAll(stackHiddenDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(stackHiddenDir, "terragrunt.hcl"), []byte(""), 0644))
l := logger.CreateLogger()
opts := &options.TerragruntOptions{
WorkingDir: tmpDir,
}
ctx := t.Context()
// Even with NoHidden, .terragrunt-stack contents should be discovered
d := discovery.New(tmpDir).
WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
WithNoHidden()
components, err := d.Discover(ctx, l, opts)
require.NoError(t, err)
assert.Contains(t, components.Filter(component.UnitKind).Paths(), stackHiddenDir)
}
🤖 Prompt for AI Agents
In `@internal/discovery/discovery_integration_test.go` around lines 109 - 132, The
test TestDiscovery_StackHiddenAllowed claims to verify behavior with NoHidden
but never enables it; update the discovery builder to call WithNoHidden() on the
discovery.New(tmpDir) chain (i.e., replace or augment
discovery.New(tmpDir).WithDiscoveryContext(...) with
discovery.New(tmpDir).WithNoHidden().WithDiscoveryContext(...)) so the
Discover(ctx, l, opts) call runs with NoHidden enabled and the assertion that
components.Filter(component.UnitKind).Paths() contains stackHiddenDir is
meaningful.

Comment on lines 205 to 217
ctx := filter.ClassificationContext{}
status, reason, _ := classifier.Classify(c, ctx)

// Compare basic component properties
require.Len(t, components, len(wantDiscovery))

components = components.Sort()
wantDiscovery = wantDiscovery.Sort()

for i, c := range components {
want := wantDiscovery[i]
assert.Equal(t, evalPath(want.Path()), evalPath(c.Path()), "Component path mismatch at index %d", i)
assert.Equal(t, want.Kind(), c.Kind(), "Component kind mismatch at index %d", i)
assert.Equal(t, want.External(), c.External(), "Component external flag mismatch at index %d", i)

// Compare dependencies
cfgDeps := c.Dependencies().Sort()
wantDeps := want.Dependencies().Sort()
require.Len(t, cfgDeps, len(wantDeps), "Dependencies count mismatch for %s", c.Path())

for j, dep := range cfgDeps {
wantDep := wantDeps[j]
assert.Equal(t, evalPath(wantDep.Path()), evalPath(dep.Path()), "Dependency path mismatch at component %d, dependency %d", i, j)
assert.Equal(t, wantDep.Kind(), dep.Kind(), "Dependency kind mismatch at component %d, dependency %d", i, j)
assert.Equal(t, wantDep.External(), dep.External(), "Dependency external flag mismatch at component %d, dependency %d", i, j)

// Compare nested dependencies (one level deep)
depDeps := dep.Dependencies().Sort()
wantDepDeps := wantDep.Dependencies().Sort()
require.Len(t, depDeps, len(wantDepDeps), "Nested dependencies count mismatch for %s -> %s", c.Path(), dep.Path())

for k, nestedDep := range depDeps {
wantNestedDep := wantDepDeps[k]
assert.Equal(t, evalPath(wantNestedDep.Path()), evalPath(nestedDep.Path()), "Nested dependency path mismatch")
}
}
}
assert.Equal(t, tt.expectStatus, status, "status mismatch")
assert.Equal(t, tt.expectReason, reason, "reason mismatch")
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Error return value from Classify is ignored.

The third return value (error) from classifier.Classify(c, ctx) is discarded. For thorough test coverage, consider asserting that no error occurs.

💚 Proposed fix
 		ctx := filter.ClassificationContext{}
-		status, reason, _ := classifier.Classify(c, ctx)
+		status, reason, classifyErr := classifier.Classify(c, ctx)
+		require.NoError(t, classifyErr)
 
 		assert.Equal(t, tt.expectStatus, status, "status mismatch")
 		assert.Equal(t, tt.expectReason, reason, "reason mismatch")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ctx := filter.ClassificationContext{}
status, reason, _ := classifier.Classify(c, ctx)
// Compare basic component properties
require.Len(t, components, len(wantDiscovery))
components = components.Sort()
wantDiscovery = wantDiscovery.Sort()
for i, c := range components {
want := wantDiscovery[i]
assert.Equal(t, evalPath(want.Path()), evalPath(c.Path()), "Component path mismatch at index %d", i)
assert.Equal(t, want.Kind(), c.Kind(), "Component kind mismatch at index %d", i)
assert.Equal(t, want.External(), c.External(), "Component external flag mismatch at index %d", i)
// Compare dependencies
cfgDeps := c.Dependencies().Sort()
wantDeps := want.Dependencies().Sort()
require.Len(t, cfgDeps, len(wantDeps), "Dependencies count mismatch for %s", c.Path())
for j, dep := range cfgDeps {
wantDep := wantDeps[j]
assert.Equal(t, evalPath(wantDep.Path()), evalPath(dep.Path()), "Dependency path mismatch at component %d, dependency %d", i, j)
assert.Equal(t, wantDep.Kind(), dep.Kind(), "Dependency kind mismatch at component %d, dependency %d", i, j)
assert.Equal(t, wantDep.External(), dep.External(), "Dependency external flag mismatch at component %d, dependency %d", i, j)
// Compare nested dependencies (one level deep)
depDeps := dep.Dependencies().Sort()
wantDepDeps := wantDep.Dependencies().Sort()
require.Len(t, depDeps, len(wantDepDeps), "Nested dependencies count mismatch for %s -> %s", c.Path(), dep.Path())
for k, nestedDep := range depDeps {
wantNestedDep := wantDepDeps[k]
assert.Equal(t, evalPath(wantNestedDep.Path()), evalPath(nestedDep.Path()), "Nested dependency path mismatch")
}
}
}
assert.Equal(t, tt.expectStatus, status, "status mismatch")
assert.Equal(t, tt.expectReason, reason, "reason mismatch")
ctx := filter.ClassificationContext{}
status, reason, classifyErr := classifier.Classify(c, ctx)
require.NoError(t, classifyErr)
assert.Equal(t, tt.expectStatus, status, "status mismatch")
assert.Equal(t, tt.expectReason, reason, "reason mismatch")
🤖 Prompt for AI Agents
In `@internal/discovery/discovery_test.go` around lines 205 - 209, The test
currently ignores the error return from classifier.Classify(c, ctx); capture the
third return value (err) when calling classifier.Classify in the test (alongside
status and reason) and assert that err is nil (e.g., use require.NoError(t, err)
or assert.NoError(t, err)) before checking status and reason to ensure the call
succeeded; the call site to update is the classifier.Classify(c, ctx) invocation
that uses filter.ClassificationContext{}.

Comment on lines +132 to +207
// mergeResults merges discovered and candidate results from a phase output.
func mergeResults(output PhaseOutput) ([]DiscoveryResult, []DiscoveryResult, []error) {
var (
discovered []DiscoveryResult
candidates []DiscoveryResult
errs []error
)

// Drain all channels
done := false
for !done {
select {
case result, ok := <-output.Discovered:
if ok {
discovered = append(discovered, result)
}
case result, ok := <-output.Candidates:
if ok {
candidates = append(candidates, result)
}
case err, ok := <-output.Errors:
if ok && err != nil {
errs = append(errs, err)
}
case <-output.Done:
done = true
}
}

// Drain remaining items after done signal
for result := range output.Discovered {
discovered = append(discovered, result)
}

for result := range output.Candidates {
candidates = append(candidates, result)
}

for err := range output.Errors {
if err != nil {
errs = append(errs, err)
}
}

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's find the PhaseOutput definition
rg -n "type PhaseOutput" internal/discovery -g'*.go' -A 10

Repository: gruntwork-io/terragrunt

Length of output: 820


🏁 Script executed:

#!/bin/bash
# Find all files in the discovery package
git ls-files internal/discovery | grep '\.go$'

Repository: gruntwork-io/terragrunt

Length of output: 754


🏁 Script executed:

#!/bin/bash
# Search for where Done channel is closed
rg -n "close.*Done" internal/discovery -g'*.go' -B 2 -A 2

Repository: gruntwork-io/terragrunt

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Search for where Discovered/Candidates/Errors channels are closed
rg -n "close\(" internal/discovery -g'*.go' -B 3 -A 1 | head -100

Repository: gruntwork-io/terragrunt

Length of output: 3333


Verify defer execution order for channel closure in phase implementations.

The channel-closure order does have a potential issue: all phase implementations use defer statements in LIFO order, causing Done to close before Discovered, Candidates, and Errors. However, this does not cause mergeResults to hang because all defer statements execute atomically when the goroutine exits—there is no window for new values to arrive on the data channels after Done closes.

That said, the current pattern is fragile. To improve clarity and robustness, consider explicitly closing the data channels before Done (by closing them before returning from the phase goroutine, rather than relying on defer order), or document the expected closure sequence.

🤖 Prompt for AI Agents
In `@internal/discovery/helpers.go` around lines 132 - 175, mergeResults depends
on data channels (PhaseOutput.Discovered, .Candidates, .Errors) being closed
before the Done channel to avoid fragile ordering from LIFO defers; update phase
goroutines (the implementations that write to these channels) to explicitly
close Discovered, Candidates and Errors before signaling Done (i.e., close data
channels first, then close/send on Done) rather than relying on defer ordering,
or add documentation in those phase functions describing the required close
sequence so mergeResults can safely range over the channels after Done.

Comment on lines +162 to +168
if err := discoveryGroup.Wait(); err != nil {
select {
case errors <- err:
default:
}

return
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Error silently dropped when channel is full.

The default case silently discards errors if the error channel is full. Consider increasing the buffer size or using a blocking send (with context cancellation) to ensure errors aren't lost.

🛠️ Suggested alternatives

Option 1 - Use context-aware blocking send:

 	if err := discoveryGroup.Wait(); err != nil {
 		select {
 		case errors <- err:
-		default:
+		case <-ctx.Done():
 		}
 
 		return
 	}

Option 2 - Increase buffer size to accommodate potential errors:

-	errors := make(chan error, p.numWorkers)
+	errors := make(chan error, p.numWorkers*channelBufferMultiplier)
🤖 Prompt for AI Agents
In `@internal/discovery/phase_worktree.go` around lines 162 - 168, The current
handling after discoveryGroup.Wait() drops errors when the errors channel is
full; change it to a context-aware blocking send so errors aren't lost: replace
the non-blocking select (select { case errors <- err: default: }) with a
blocking send that waits for either the send to succeed or context cancellation
(select { case errors <- err: case <-ctx.Done(): }) using the existing ctx
passed into this scope (or add one if missing); alternatively, if you prefer
buffering, increase the capacity where the errors channel is created to
accommodate expected concurrent errors (reference discoveryGroup.Wait(), the
errors channel variable, and ctx).

Comment on lines +19 to +41
case *PrefixExpression:
if node.Operator != "!" {
return false, nil
}

return MatchComponent(c, node.Right)

case *InfixExpression:
if node.Operator != "|" {
return false, nil
}

leftMatch, err := MatchComponent(c, node.Left)
if err != nil {
return false, err
}

if !leftMatch {
return false, nil
}

return MatchComponent(c, node.Right)

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix negation and OR evaluation in MatchComponent.
!expr should invert the match result, and | should behave as OR. The current logic evaluates as AND and ignores negation.

🐛 Proposed fix
 case *PrefixExpression:
 	if node.Operator != "!" {
 		return false, nil
 	}
 
-	return MatchComponent(c, node.Right)
+	match, err := MatchComponent(c, node.Right)
+	if err != nil {
+		return false, err
+	}
+	return !match, nil
 
 case *InfixExpression:
 	if node.Operator != "|" {
 		return false, nil
 	}
 
 	leftMatch, err := MatchComponent(c, node.Left)
 	if err != nil {
 		return false, err
 	}
 
-	if !leftMatch {
-		return false, nil
-	}
-
-	return MatchComponent(c, node.Right)
+	if leftMatch {
+		return true, nil
+	}
+	return MatchComponent(c, node.Right)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case *PrefixExpression:
if node.Operator != "!" {
return false, nil
}
return MatchComponent(c, node.Right)
case *InfixExpression:
if node.Operator != "|" {
return false, nil
}
leftMatch, err := MatchComponent(c, node.Left)
if err != nil {
return false, err
}
if !leftMatch {
return false, nil
}
return MatchComponent(c, node.Right)
case *PrefixExpression:
if node.Operator != "!" {
return false, nil
}
match, err := MatchComponent(c, node.Right)
if err != nil {
return false, err
}
return !match, nil
case *InfixExpression:
if node.Operator != "|" {
return false, nil
}
leftMatch, err := MatchComponent(c, node.Left)
if err != nil {
return false, err
}
if leftMatch {
return true, nil
}
return MatchComponent(c, node.Right)
🤖 Prompt for AI Agents
In `@internal/filter/matcher.go` around lines 19 - 41, MatchComponent currently
treats PrefixExpression with operator "!" as a no-op and evaluates
InfixExpression with "|" like an AND; update it so PrefixExpression with
Operator "!" returns the inverted result of MatchComponent(c, node.Right) (i.e.,
call MatchComponent and return !match along with any error), and change
InfixExpression with Operator "|" to implement short-circuit OR: evaluate left
with MatchComponent(c, node.Left) and if true return true, otherwise evaluate
and return MatchComponent(c, node.Right); keep error propagation from the
recursive calls (e.g., in MatchComponent, PrefixExpression, InfixExpression).

Comment on lines 300 to 306
// This test is kind of wild. I don't know if it's worth keeping.
// Removing it for now to avoid blocking the merge of #5469
// which is more important.

t.Skip("Skipping this test to avoid blocking the merge of #5469")

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid merging with permanently skipped integration coverage.
Skipping this test leaves a regression gap; please re-enable or quarantine with a tracked issue and timeline (or gate behind an explicit opt-out flag).

🤖 Prompt for AI Agents
In `@test/integration_debug_test.go` around lines 300 - 305, Replace the
unconditional t.Skip in test/integration_debug_test.go with a documented
quarantine: either re-enable the test, or gate its skip behind an explicit
opt-out (e.g., check an env var like SKIP_INTEGRATION_DEBUG) and call t.Skipf
only when the opt-out is set, and/or add a TODO comment referencing a tracked
issue and timeline (include the issue number like `#5469`) so the test remains
discoverable and the regression gap is tracked; ensure you update the test’s
t.Skip/t.Skipf call and add the issue reference and expected timeline in the
test file.

@yhakbar
Copy link
Collaborator Author

yhakbar commented Feb 1, 2026

@CodeRabbit review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 1, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@internal/discovery/phase_graph.go`:
- Around line 152-154: Assigning input.Components to allComponents then
appending can mutate the caller's slice via shared backing array; fix by making
an explicit copy of input.Components before appending
resultsToComponents(input.Candidates) so appends operate on a new slice.
Concretely, create a new slice with capacity
len(input.Components)+len(resultsToComponents(...)), copy input.Components into
it, then append the converted candidates and pass that safe slice into
component.NewThreadSafeComponents; update references to allComponents,
input.Components, resultsToComponents, and component.NewThreadSafeComponents
accordingly.
🧹 Nitpick comments (1)
internal/discovery/phase_worktree.go (1)

466-495: Consider O(n²) complexity for large component sets.

MatchComponentPairs uses nested loops, resulting in O(n×m) complexity. For typical Terragrunt projects this is fine, but extremely large monorepos might see slowdowns.

♻️ Optional: Use map for O(n+m) matching
 func MatchComponentPairs(
 	fromComponents component.Components,
 	toComponents component.Components,
 ) []ComponentPair {
 	componentPairs := make([]ComponentPair, 0, max(len(fromComponents), len(toComponents)))
 
+	// Build lookup map for O(1) matching
+	toMap := make(map[string]component.Component, len(toComponents))
+	for _, toComponent := range toComponents {
+		suffix := filepath.Clean(strings.TrimPrefix(
+			toComponent.Path(),
+			toComponent.DiscoveryContext().WorkingDir,
+		))
+		toMap[suffix] = toComponent
+	}
+
 	for _, fromComponent := range fromComponents {
 		fromComponentSuffix := strings.TrimPrefix(
 			fromComponent.Path(),
 			fromComponent.DiscoveryContext().WorkingDir,
 		)
+		cleanedSuffix := filepath.Clean(fromComponentSuffix)
 
-		for _, toComponent := range toComponents {
-			toComponentSuffix := strings.TrimPrefix(
-				toComponent.Path(),
-				toComponent.DiscoveryContext().WorkingDir,
-			)
-
-			if filepath.Clean(fromComponentSuffix) == filepath.Clean(toComponentSuffix) {
-				componentPairs = append(componentPairs, ComponentPair{
-					FromComponent: fromComponent,
-					ToComponent:   toComponent,
-				})
-			}
+		if toComponent, ok := toMap[cleanedSuffix]; ok {
+			componentPairs = append(componentPairs, ComponentPair{
+				FromComponent: fromComponent,
+				ToComponent:   toComponent,
+			})
 		}
 	}
 
 	return componentPairs
 }

@yhakbar
Copy link
Collaborator Author

yhakbar commented Feb 1, 2026

@CodeRabbit review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 1, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@internal/filter/candidacy.go`:
- Around line 113-124: The merge for InfixExpression in
analyzeExpressionCandidacy only combines boolean flags and neglects graph
metadata; update the block handling *InfixExpression so that when either
leftInfo or rightInfo has RequiresGraphDiscovery true you also merge
GraphDirection, DependencyDepth, DependentDepth and ExcludeTarget into info
(e.g., prefer the non-zero/stronger value or normalize/OR where sensible), so
info carries the graph-related fields from the side that required graph
discovery; keep the existing boolean merges for
RequiresFilesystemOnly/RequiresParsing/RequiresGraphDiscovery and ensure this
logic is applied inside the case for *InfixExpression in
analyzeExpressionCandidacy to preserve graph metadata in CandidacyInfo.

In `@internal/filter/classifier.go`:
- Around line 173-185: The negated PrefixExpression branch only records
node.Right in c.negatedExprs and may miss nested GraphExpression objects,
preventing the graph phase from running for exclusions; update the
PrefixExpression '!' handling in analyzeExpression (the case for
*PrefixExpression) to also extract graph expressions from node.Right by calling
GetGraphExpressions(node.Right) and appending each result to c.graphExprs (in
addition to existing c.negatedExprs and c.parseExprs logic) so HasGraphFilters()
will be true for negated graph filters; also add a matching branch in
MatchComponent to handle *GraphExpression nodes (invoking the same
graph-evaluation logic used for positive graph filters) so negated graph
expressions can be evaluated during classification.

@yhakbar
Copy link
Collaborator Author

yhakbar commented Feb 2, 2026

@CodeRabbit review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 2, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@internal/discovery/phase_test.go`:
- Around line 401-506: The test case in
TestCandidacyClassifier_AnalyzesFiltersCorrectly uses an invalid filter string
"..../foo"; replace that entry in the test table's filterStrings for the
"dependent graph filter" case with the correct graph-target syntax (e.g. use
"...{./foo}" for a relative path target or "...foo" for an identifier) so
filter.ParseFilterQueries and classifier.Analyze receive a valid graph filter;
update only the filterStrings value in that test row (no other logic changes to
NewClassifier, Analyze, HasGraphFilters, or GraphExpressions).

In `@internal/filter/classifier.go`:
- Around line 238-313: The code is adding inner expressions of negated filters
into c.parseExprs, causing negated parse filters to be treated as positive; fix
by ensuring negated expressions are NOT added to c.parseExprs (only to
c.negatedExprs) in the PrefixExpression handling (the block that appends to
c.negatedExprs and c.parseExprs), and/or make matchesAnyPositive skip any
expressions that are present in c.negatedExprs when iterating c.parseExprs (use
MatchComponent to compare). Update the PrefixExpression branch and
matchesAnyPositive so parseExprs contains only non-negated expressions and
negated expressions are checked only via matchesAnyNegated/negated logic.
🧹 Nitpick comments (1)
internal/discovery/discovery_integration_test.go (1)

934-961: Consider using fmt.Sprintf for clearer unit naming.

The expression string(rune('a'+i)) at line 942 works but is less readable than using fmt.Sprintf("unit%c", 'a'+i) or simply fmt.Sprintf("unit%d", i).

♻️ Optional refactor for clarity
 	// Create a few test units
-	for i := range 5 {
-		dir := filepath.Join(tmpDir, "unit"+string(rune('a'+i)))
+	for i := 0; i < 5; i++ {
+		dir := filepath.Join(tmpDir, fmt.Sprintf("unit%c", 'a'+i))
 		require.NoError(t, os.MkdirAll(dir, 0755))
 		require.NoError(t, os.WriteFile(filepath.Join(dir, "terragrunt.hcl"), []byte(""), 0644))
 	}

Comment on lines +401 to +506
// TestCandidacyClassifier_AnalyzesFiltersCorrectly tests the candidacy classifier analysis.
func TestCandidacyClassifier_AnalyzesFiltersCorrectly(t *testing.T) {
t.Parallel()

l := logger.CreateLogger()

tests := []struct {
name string
filterStrings []string
expectHasPositive bool
expectHasParseRequired bool
expectHasGraphFilters bool
expectGraphExprCount int
}{
{
name: "empty filters",
filterStrings: []string{},
expectHasPositive: false,
},
{
name: "simple path filter",
filterStrings: []string{"./foo"},
expectHasPositive: true,
},
{
name: "negated path filter only",
filterStrings: []string{"!./foo"},
expectHasPositive: false,
},
{
name: "path filter with negation",
filterStrings: []string{"./foo", "!./bar"},
expectHasPositive: true,
},
{
name: "reading attribute filter",
filterStrings: []string{"reading=config/*"},
expectHasPositive: true,
expectHasParseRequired: true,
},
{
name: "dependency graph filter",
filterStrings: []string{"./foo..."},
expectHasPositive: true,
expectHasGraphFilters: true,
expectGraphExprCount: 1,
},
{
name: "dependent graph filter",
filterStrings: []string{"..../foo"},
expectHasPositive: true,
expectHasGraphFilters: true,
expectGraphExprCount: 1,
},
{
name: "exclude target graph filter",
filterStrings: []string{"^{./foo}..."},
expectHasPositive: true,
expectHasGraphFilters: true,
expectGraphExprCount: 1,
},
{
name: "multiple graph filters",
filterStrings: []string{"./foo...", "..../bar"},
expectHasPositive: true,
expectHasGraphFilters: true,
expectGraphExprCount: 2,
},
{
name: "name attribute filter",
filterStrings: []string{"name=my-app"},
expectHasPositive: true,
},
{
name: "type attribute filter",
filterStrings: []string{"type=unit"},
expectHasPositive: true,
},
{
name: "external attribute filter",
filterStrings: []string{"external=true"},
expectHasPositive: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

filters, err := filter.ParseFilterQueries(l, tt.filterStrings)
require.NoError(t, err)

classifier := filter.NewClassifier(l)
err = classifier.Analyze(filters)
require.NoError(t, err)

assert.Equal(t, tt.expectHasPositive, classifier.HasPositiveFilters(), "HasPositiveFilters mismatch")
assert.Equal(t, tt.expectHasParseRequired, classifier.HasParseRequiredFilters(), "HasParseRequiredFilters mismatch")
assert.Equal(t, tt.expectHasGraphFilters, classifier.HasGraphFilters(), "HasGraphFilters mismatch")

if tt.expectGraphExprCount > 0 {
assert.Len(t, classifier.GraphExpressions(), tt.expectGraphExprCount, "GraphExpressions count mismatch")
}
})
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for 4-dot syntax patterns
echo "=== Searching for 4-dot patterns ==="
rg -n '\.\.\.\.\/' --type=go

echo -e "\n=== Searching for 3-dot patterns (dependency graph filters) ==="
rg -n '\.\.\.[^.]' --type=go | head -40

echo -e "\n=== Searching for dependent filter patterns (leading dots) ==="
rg -n '\.\.\/.+' --type=go | head -30

Repository: gruntwork-io/terragrunt

Length of output: 4919


🏁 Script executed:

#!/bin/bash
# Find and examine the filter parsing implementation
echo "=== Searching for filter parsing functions ==="
fd -t f -e go -e md -x grep -l "ParseFilterQueries\|dependent.*filter\|graph.*filter" {} \;

echo -e "\n=== Look for filter syntax documentation ==="
fd -t f \( -name "*.md" -o -name "*.go" \) -x grep -l "\.\.\/\|graph.*filter" {} \; | head -20

Repository: gruntwork-io/terragrunt

Length of output: 1715


🏁 Script executed:

#!/bin/bash
# Find the filter implementation files
find . -type f -name "*filter*.go" | grep -v node_modules | head -20

Repository: gruntwork-io/terragrunt

Length of output: 376


🏁 Script executed:

#!/bin/bash
# Look for filter examples in integration tests
echo "=== Integration filter graph test file ==="
grep -n '\.\.\.\|dependent\|graph.*filter' ./test/integration_filter_graph_test.go | head -50

Repository: gruntwork-io/terragrunt

Length of output: 3317


🏁 Script executed:

#!/bin/bash
# Check the filter parser implementation
echo "=== Checking filter parser implementation ==="
if [ -f ./internal/filter/parser.go ]; then
  grep -n 'dependent\|graph\|dots\|\.\.\..*/' ./internal/filter/parser.go | head -30
fi

# Also check filters.go
if [ -f ./internal/filter/filters.go ]; then
  grep -n 'dependent\|graph\|\.\.\..*/' ./internal/filter/filters.go | head -30
fi

Repository: gruntwork-io/terragrunt

Length of output: 884


🏁 Script executed:

#!/bin/bash
# Search for "..../foo" pattern specifically in test files (using different regex approach)
echo "=== Searching for dependent filter syntax in test files ==="
grep -n '\.\.\.\.' ./internal/discovery/phase_test.go
grep -n '\.\.\.\.' ./test/integration_filter_graph_test.go
grep -n '\.\.\.\.' ./test/integration_filter_test.go

Repository: gruntwork-io/terragrunt

Length of output: 242


🏁 Script executed:

#!/bin/bash
# Look at the actual test case around line 450 in phase_test.go
echo "=== Context around line 450 in phase_test.go ==="
sed -n '445,460p' ./internal/discovery/phase_test.go

# Also get line 429 to see the "dependency graph filter" test for comparison
echo -e "\n=== Dependency graph filter test (line ~429) ==="
sed -n '429,442p' ./internal/discovery/phase_test.go

Repository: gruntwork-io/terragrunt

Length of output: 1026


🏁 Script executed:

#!/bin/bash
# Search for documentation or comments about filter syntax
echo "=== Searching for filter syntax documentation ==="
grep -n 'dependent.*graph\|graph.*dependent' ./internal/filter/doc.go 2>/dev/null || echo "doc.go not found"

# Check if there's an examples file
if [ -f ./internal/filter/examples_test.go ]; then
  grep -n '\.\.' ./internal/filter/examples_test.go | head -40
fi

Repository: gruntwork-io/terragrunt

Length of output: 133


🏁 Script executed:

#!/bin/bash
# Check the examples file for filter syntax patterns
if [ -f ./internal/filter/examples_test.go ]; then
  echo "=== Filter examples ==="
  grep -n '\.\.\.' ./internal/filter/examples_test.go | head -60
fi

Repository: gruntwork-io/terragrunt

Length of output: 90


🏁 Script executed:

#!/bin/bash
# Look for the GraphExpression structure definition
echo "=== Searching for GraphExpression definition ==="
grep -n 'type GraphExpression' ./internal/filter/*.go
grep -n 'IncludeDependents\|IncludeDependencies' ./internal/filter/*.go | head -20

Repository: gruntwork-io/terragrunt

Length of output: 1833


🏁 Script executed:

#!/bin/bash
# Check all test cases in phase_test.go for graph filter patterns
echo "=== All graph filter test cases in phase_test.go ==="
grep -B 2 -A 1 'GraphExprCount' ./internal/discovery/phase_test.go

Repository: gruntwork-io/terragrunt

Length of output: 873


🏁 Script executed:

#!/bin/bash
# Look at the parser file to understand syntax parsing
echo "=== Parser implementation ==="
sed -n '90,120p' ./internal/filter/parser.go

echo -e "\n=== More parser context ==="
sed -n '1,50p' ./internal/filter/parser.go

Repository: gruntwork-io/terragrunt

Length of output: 1955


🏁 Script executed:

#!/bin/bash
# Check the AST file for GraphExpression construction
echo "=== GraphExpression construction ==="
sed -n '230,280p' ./internal/filter/ast.go

Repository: gruntwork-io/terragrunt

Length of output: 1290


🏁 Script executed:

#!/bin/bash
# Look at how the parser processes dots and slashes
echo "=== Parsing dots and operators ==="
grep -n '\.\.\|parseDepth\|ellipsis' ./internal/filter/parser.go | head -40

Repository: gruntwork-io/terragrunt

Length of output: 911


🏁 Script executed:

#!/bin/bash
# Check examples in integration_filter_graph_test.go for actual filter syntax
echo "=== Integration test filter patterns ==="
grep -B 2 -A 2 'filterQuery.*\.\.' ./test/integration_filter_graph_test.go | head -80

Repository: gruntwork-io/terragrunt

Length of output: 3042


🏁 Script executed:

#!/bin/bash
# Check how the lexer handles dots
echo "=== Lexer implementation ==="
grep -n 'ELLIPSIS\|\.\.\..*case' ./internal/filter/lexer.go | head -40

echo -e "\n=== Lexer token definitions ==="
grep -n 'const\|ELLIPSIS' ./internal/filter/lexer.go | head -50

Repository: gruntwork-io/terragrunt

Length of output: 236


🏁 Script executed:

#!/bin/bash
# Look for tokenization of dots
echo "=== Dot tokenization logic ==="
sed -n '1,200p' ./internal/filter/lexer.go | grep -A 5 -B 5 '\.'

Repository: gruntwork-io/terragrunt

Length of output: 5045


🏁 Script executed:

#!/bin/bash
# Check readPath implementation
echo "=== readPath implementation ==="
grep -A 30 'func.*readPath' ./internal/filter/lexer.go

Repository: gruntwork-io/terragrunt

Length of output: 851


🏁 Script executed:

#!/bin/bash
# Let's trace through what would tokenize for "../foo" and "..../foo"
echo "=== Testing lexer with specific inputs ==="
cd ./internal/filter && python3 << 'EOF'
# Let's manually trace what the lexer would do with "..../foo"
input = "..../foo"

# Lexer logic from what we've seen:
# Position 0: ch='.'
#   - peekChar returns '.' (position 1)
#   - Check if readPosition+1 < len and input[readPosition+1] == '.' → YES (position 2 is '.')
#   - Creates ELLIPSIS token "..." 
#   - readChar 3 times → now at position 3
#   - readChar once more before returning (from the tok assignment) → position 4

# After ELLIPSIS token, next call to NextToken:
# Position 4: ch='/'
# This would be handled by the '/' case in the switch

# So "..../foo" tokenizes as:
# 1. ELLIPSIS ("...")
# 2. Then "/" which calls readPath

# The readPath would include that leading "/" 
# So it would read "./foo" as the path

print("Input: " + input)
print("Expected tokens:")
print("1. ELLIPSIS: '...'")
print("2. PATH: '/foo' or similar")
print()
print("Compare to correct syntax '...foo':")
print("1. ELLIPSIS: '...'")
print("2. IDENT: 'foo'")
EOF

Repository: gruntwork-io/terragrunt

Length of output: 260


🏁 Script executed:

#!/bin/bash
# Verify the correct syntax by examining all dependent filter examples
echo "=== All dependent filter examples in tests ==="
grep -r '\.\.\.[^.]' ./test/ ./internal/discovery/ ./internal/filter/ --include='*.go' | grep -E 'filterQuery|filterStrings|"\.\.\.([a-zA-Z_])' | head -30

Repository: gruntwork-io/terragrunt

Length of output: 2454


🏁 Script executed:

#!/bin/bash
# Search for all instances of 4+ dots in filter contexts
echo "=== Searching for 4+ dot patterns in filter contexts ==="
grep -r '\.\.\.\.\|\.\.\.\.\..*foo' ./test/ ./internal/ --include='*.go' 2>/dev/null | grep -v node_modules

# Compare with correct 3-dot patterns
echo -e "\n=== Correct 3-dot dependent filter patterns ==="
grep -r '"\.\.\.[a-zA-Z_-]' ./test/ ./internal/discovery/ ./internal/filter/ --include='*.go' | grep -v node_modules | head -20

Repository: gruntwork-io/terragrunt

Length of output: 3982


🏁 Script executed:

#!/bin/bash
# Check the parser test and candidacy test for these patterns
echo "=== Parser test with 4+ dots ==="
grep -B 5 -A 5 '1......2' ./internal/filter/parser_test.go

echo -e "\n=== Complex test with 4 dots ==="
grep -B 5 -A 5 '1..../apps' ./internal/filter/complex_test.go

echo -e "\n=== Candidacy test with 4 dots ==="
grep -B 5 -A 5 '..../foo' ./internal/filter/candidacy_test.go

Repository: gruntwork-io/terragrunt

Length of output: 5896


🏁 Script executed:

#!/bin/bash
# Check what the expected behavior is for these patterns
echo "=== Checking expected output for ..../foo patterns ==="
grep -B 10 -A 5 '"..../foo"' ./internal/discovery/discovery_test.go | head -40

echo -e "\n=== Checking parser documentation ==="
grep -n 'relative.*path\|parent.*directory\|\.\.\/' ./internal/filter/parser.go

Repository: gruntwork-io/terragrunt

Length of output: 1138


🏁 Script executed:

#!/bin/bash
# Look for comments explaining the 4-dot syntax
echo "=== Searching for documentation of graph filter syntax ==="
grep -B 5 -A 5 'N\...\|depth' ./internal/filter/ast.go | head -50

Repository: gruntwork-io/terragrunt

Length of output: 125


🏁 Script executed:

#!/bin/bash
# Check the discovery_test.go to see if it has the same pattern
echo "=== discovery_test.go dependent filter test ==="
grep -B 5 -A 8 '"..../foo"' ./internal/discovery/discovery_test.go

# Check if these tests are copied or independently created
echo -e "\n=== Check if discovery_test and phase_test have matching test cases ==="
grep -n 'dependent graph filter' ./internal/discovery/discovery_test.go ./internal/discovery/phase_test.go

Repository: gruntwork-io/terragrunt

Length of output: 1603


🏁 Script executed:

#!/bin/bash
# See how the parser actually handles ..../foo vs ...foo
echo "=== Parser behavior with different syntaxes ==="
cat > /tmp/test_parse.go << 'EOF'
package main

import (
	"fmt"
	"regexp"
)

func main() {
	// Simulating what the lexer does with different inputs
	inputs := []string{"...foo", "..../foo", "...b-dependency", "..../bar"}
	
	for _, input := range inputs {
		fmt.Printf("Input: %s\n", input)
		// Check if it starts with .../ (3 dots + slash)
		if match, _ := regexp.MatchString(`^\.\.\./`, input); match {
			fmt.Println("  → Pattern: .../ (3 dots + slash)")
		} else if match, _ := regexp.MatchString(`^\.\.\.`, input); match {
			fmt.Println("  → Pattern: ... (3 dots)")
		}
		fmt.Println()
	}
}
EOF
cd /tmp && go run test_parse.go

Repository: gruntwork-io/terragrunt

Length of output: 291


🏁 Script executed:

#!/bin/bash
# Check all test case names and patterns across test files
echo "=== Comparing filter patterns in all test files ==="
echo "Phase test patterns:"
grep 'name.*"dependent\|name.*"graph' ./internal/discovery/phase_test.go -A 2 | grep filterStrings

echo -e "\nDiscovery test patterns:"
grep 'name.*"dependent\|name.*"graph' ./internal/discovery/discovery_test.go -A 2 | grep filterStrings

echo -e "\nCandidacy test patterns:"
grep 'name.*"dependent' ./internal/filter/candidacy_test.go -A 2 | grep filterStr

Repository: gruntwork-io/terragrunt

Length of output: 443


🏁 Script executed:

#!/bin/bash
# Look at the parser to see how it handles the target after ELLIPSIS
echo "=== Parser handling of target after ELLIPSIS ==="
sed -n '95,145p' ./internal/filter/parser.go

echo -e "\n=== Check what types of targets can follow ELLIPSIS ==="
sed -n '145,180p' ./internal/filter/parser.go

Repository: gruntwork-io/terragrunt

Length of output: 2576


🏁 Script executed:

#!/bin/bash
# Let's verify how "...foo" vs "..../foo" are actually parsed by checking if tests pass
echo "=== Check if phase_test.go currently passes ==="
cd ./internal/discovery && go test -run TestCandidacyClassifier_AnalyzesFiltersCorrectly -v 2>&1 | head -50

Repository: gruntwork-io/terragrunt

Length of output: 3120


🏁 Script executed:

#!/bin/bash
# Look for documentation on what the correct dependent filter syntax should be
echo "=== Search for comments on graph filter syntax ==="
grep -r 'dependent.*filter\|\.\.\..*target\|target.*dependent' ./internal/filter/ --include='*.go' -B 2 -A 2 | head -80

Repository: gruntwork-io/terragrunt

Length of output: 5429


🏁 Script executed:

#!/bin/bash
# Look at the full doc.go file for filter syntax documentation
echo "=== Full filter documentation ==="
cat ./internal/filter/doc.go

Repository: gruntwork-io/terragrunt

Length of output: 9590


Change "..../foo" to "...{./foo}" or "...foo" depending on the intended target.

At line 450, the filter uses ..../foo which parses as a dependent graph filter targeting the absolute path /foo. The documentation shows the correct syntax for path targets is braced syntax like ...{./foo} (for relative paths) or ...foo (for identifiers/names). All integration tests consistently use ...identifier without slashes.

🤖 Prompt for AI Agents
In `@internal/discovery/phase_test.go` around lines 401 - 506, The test case in
TestCandidacyClassifier_AnalyzesFiltersCorrectly uses an invalid filter string
"..../foo"; replace that entry in the test table's filterStrings for the
"dependent graph filter" case with the correct graph-target syntax (e.g. use
"...{./foo}" for a relative path target or "...foo" for an identifier) so
filter.ParseFilterQueries and classifier.Analyze receive a valid graph filter;
update only the filterStrings value in that test row (no other logic changes to
NewClassifier, Analyze, HasGraphFilters, or GraphExpressions).

Comment on lines +238 to +313
case *PrefixExpression:
// Right now, the only prefix operator is "!".
// If we encounter an unknown operator, just analyze the inner expression.
if node.Operator != "!" {
c.analyzeExpression(node.Right, filterIndex)
break
}

c.negatedExprs = append(c.negatedExprs, node.Right)
if _, requiresParse := node.Right.RequiresParse(); requiresParse {
c.parseExprs = append(c.parseExprs, node.Right)
}

c.extractNegatedGraphExpressions(node.Right, filterIndex)

case *InfixExpression:
c.analyzeExpression(node.Left, filterIndex)
c.analyzeExpression(node.Right, filterIndex)
}
}

// extractNegatedGraphExpressions walks through a negated expression and extracts
// any graph expressions found within it. This ensures that filters like "!...db"
// or "!db..." trigger the graph discovery phase.
func (c *Classifier) extractNegatedGraphExpressions(expr Expression, filterIndex int) {
WalkExpressions(expr, func(e Expression) bool {
if graphExpr, ok := e.(*GraphExpression); ok {
info := &GraphExpressionInfo{
Target: graphExpr.Target,
FullExpression: graphExpr,
Index: filterIndex,
IncludeDependencies: graphExpr.IncludeDependencies,
IncludeDependents: graphExpr.IncludeDependents,
ExcludeTarget: graphExpr.ExcludeTarget,
DependencyDepth: graphExpr.DependencyDepth,
DependentDepth: graphExpr.DependentDepth,
IsNegated: true,
}
c.graphExprs = append(c.graphExprs, info)
}

return true
})
}

// matchesAnyNegated checks if the component matches any negated expression.
func (c *Classifier) matchesAnyNegated(comp component.Component) bool {
return slices.ContainsFunc(c.negatedExprs, func(expr Expression) bool {
match, _ := MatchComponent(comp, expr)
return match
})
}

// matchesAnyPositive checks if the component matches any positive (non-negated) expression.
func (c *Classifier) matchesAnyPositive(comp component.Component, ctx ClassificationContext) bool {
if c.matchesFilesystemExpression(comp) {
return true
}

if c.matchesGraphExpressionTarget(comp) >= 0 {
return true
}

if c.matchesGitExpression(comp) {
return true
}

if !ctx.ParseDataAvailable || len(c.parseExprs) == 0 {
return false
}

return slices.ContainsFunc(c.parseExprs, func(expr Expression) bool {
match, _ := MatchComponent(comp, expr)
return match
})
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Negated parse filters are treated as positive matches.

parseExprs currently stores the inner expression for negated filters, and matchesAnyPositive treats all parse expressions as positive. This makes filters like !reading=shared.hcl classify matching components as discovered instead of excluded. Consider preserving the negation in parseExprs and skipping negated expressions in matchesAnyPositive.

🔧 Suggested fix
 case *PrefixExpression:
 	// Right now, the only prefix operator is "!".
 	// If we encounter an unknown operator, just analyze the inner expression.
 	if node.Operator != "!" {
 		c.analyzeExpression(node.Right, filterIndex)
 		break
 	}

 	c.negatedExprs = append(c.negatedExprs, node.Right)
 	if _, requiresParse := node.Right.RequiresParse(); requiresParse {
-		c.parseExprs = append(c.parseExprs, node.Right)
+		// Preserve the negation so later evaluation can respect it.
+		c.parseExprs = append(c.parseExprs, node)
 	}
 
 	c.extractNegatedGraphExpressions(node.Right, filterIndex)
 func (c *Classifier) matchesAnyPositive(comp component.Component, ctx ClassificationContext) bool {
 	if c.matchesFilesystemExpression(comp) {
 		return true
 	}

 	if c.matchesGraphExpressionTarget(comp) >= 0 {
 		return true
 	}

 	if c.matchesGitExpression(comp) {
 		return true
 	}

 	if !ctx.ParseDataAvailable || len(c.parseExprs) == 0 {
 		return false
 	}

 	return slices.ContainsFunc(c.parseExprs, func(expr Expression) bool {
+		if IsNegated(expr) {
+			return false
+		}
 		match, _ := MatchComponent(comp, expr)
 		return match
 	})
 }
🤖 Prompt for AI Agents
In `@internal/filter/classifier.go` around lines 238 - 313, The code is adding
inner expressions of negated filters into c.parseExprs, causing negated parse
filters to be treated as positive; fix by ensuring negated expressions are NOT
added to c.parseExprs (only to c.negatedExprs) in the PrefixExpression handling
(the block that appends to c.negatedExprs and c.parseExprs), and/or make
matchesAnyPositive skip any expressions that are present in c.negatedExprs when
iterating c.parseExprs (use MatchComponent to compare). Update the
PrefixExpression branch and matchesAnyPositive so parseExprs contains only
non-negated expressions and negated expressions are checked only via
matchesAnyNegated/negated logic.

fix: Addressing lints

fix: Fixing dependent discovery

fix: Adding some testing

fix: Adding more testing

fix: Addressing lints

fix: Addressing gaps from read files

fix: Addressing gaps from original terragrunt dir

fix: Addressing double-negations

fix: Adding exclude target testing

fix: Handling edge case for parse exclude

chore: Adding benchmarks

fix: Fixing #5467

fix: Addressing weird tests

fix: Addressing lints

chore: Adding more steps to the bench

fix: Fixing dependent discovery out of discovery working dir

fix: Cleaning up context

fix: Addressing lints

chore: Cleanup

chore: Deduplicating classifier logic into the `filter` package

chore: Getting rid of hidden memo

fix: Addressing lints

fix: Cleanup

fix: Cleanup

chore: Preallocating where possible

fix: Cleanup

fix: Fixing benchmark
@yhakbar yhakbar force-pushed the chore/refactor-discovery-for-more-nuanced-parse-handling branch from c7940c5 to 30cf30d Compare February 2, 2026 16:37
@yhakbar yhakbar marked this pull request as ready for review February 2, 2026 18:32
@yhakbar yhakbar requested a review from ThisGuyCodes as a code owner February 2, 2026 18:32
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@internal/discovery/phase_graph.go`:
- Around line 817-823: The parent.AddDependency call is adding a freshly
constructed depComponent before threadSafeComponents.EnsureComponent can return
the canonical/existing component; move the dependency addition until after
calling threadSafeComponents.EnsureComponent so the parent references the
canonical component. Specifically, create depComponent and set external via
isExternal(...), then call threadSafeComponents.EnsureComponent(...) and use the
returned component (not the local depComponent) when calling
parent.AddDependency(...); ensure you still propagate external state if
EnsureComponent returns an existing component.
🧹 Nitpick comments (12)
internal/discovery/phase_relationship.go (2)

107-136: Dead code: g.Wait() error will always be nil.

The goroutines passed to g.Go() always return nil (line 127), even when errors occur. Errors are collected separately via errMu/errs. This means g.Wait() at line 131 will never return a non-nil error, making lines 131-136 effectively dead code.

This pattern is intentional for continuing on partial failures, but consider removing the unreachable error handling or adding a comment explaining the design.

♻️ Optional: Remove dead code or add clarifying comment
-	if err := g.Wait(); err != nil {
-		select {
-		case errChan <- err:
-		default:
-		}
-	}
+	// Wait for all goroutines to complete. Errors are collected via errMu/errs
+	// rather than through errgroup to allow partial failures.
+	_ = g.Wait()

224-225: Nested errgroup may cause excessive concurrency.

discoverRelationships creates a new errgroup with g.SetLimit(p.numWorkers) (line 225) inside the recursive call. When called from runRelationshipDiscovery which also uses an errgroup with the same limit, this can result in up to numWorkers × numWorkers concurrent goroutines, potentially overwhelming system resources.

Consider either:

  1. Sharing a single worker pool across recursion levels.
  2. Reducing the limit for nested calls.
internal/filter/evaluator.go (1)

372-390: Consider removing commented-out code.

This block of commented-out code (lines 372-390) adds noise without providing value. If this logic might be needed in the future, consider documenting the scenario in a comment without the full implementation, or tracking it in an issue.

internal/discovery/helpers.go (1)

164-209: Channel draining logic is correct but fragile by design.

The mergeResults function correctly drains channels in two phases:

  1. Select loop until Done signal.
  2. Range over remaining items after Done.

This relies on the phase implementations closing data channels before or atomically with Done. The current implementation works because all defer statements in phase goroutines execute atomically when the goroutine exits. However, the previous review noted this pattern is fragile.

Consider adding a brief comment documenting the expected closure sequence for future maintainers.

internal/discovery/phase_filesystem.go (1)

127-164: Single consumer for file paths limits parallelism.

The filePaths channel is consumed by a single goroutine (lines 127-164), which processes files sequentially. With g.SetLimit(p.numWorkers), only two goroutines run: the walker and the processor. Consider spawning multiple consumer goroutines to parallelize file processing.

internal/discovery/phase_parse.go (3)

94-105: Discovered components parsed when readFiles/parseExclude/parseIncludes enabled may cause duplicate processing.

When readFiles, parseExclude, or parseIncludes is enabled, input.Components are added to componentsToParse with status StatusDiscovered. If a component appears in both input.Components and input.Candidates, it could be processed twice. Consider deduplicating by path before processing.


292-307: Inconsistent error suppression between diagnostics handler and PartialParseConfigFile.

The diagnostics handler at lines 282-288 suppresses all diagnostics when suppressParseErrors is true, but lines 294-307 still check for TerragruntConfigNotFoundError and other errors. This could lead to cases where diagnostics are suppressed but the overall parse still fails.


224-231: Parameter order: ctx should be first per Go convention.

The function signature has c before ctx, but Go convention places context.Context as the first parameter.

♻️ Suggested fix
 func parseComponent(
-	c component.Component,
 	ctx context.Context,
+	c component.Component,
 	l log.Logger,
 	opts *options.TerragruntOptions,
 	suppressParseErrors bool,
 	parserOptions []hclparse.Option,
 ) error {
internal/discovery/phase_worktree.go (3)

266-298: Redundant error collection pattern.

The error handling has redundancy: goroutines both return errors (which g.Wait() captures) and append to the errs slice. If a goroutine returns an error at line 281, g.Wait() at line 292 returns that same error, and it's also in errs. This results in the error being reported twice if the caller inspects both.

Consider simplifying to use only one approach - either return errors from goroutines (and rely on g.Wait()) or collect in errs and return nil from goroutines.

♻️ Proposed fix using errgroup's error handling only
-	var (
-		mu   sync.Mutex
-		errs = make([]error, 0, len(stackDiff.Changed))
-	)
-
 	for _, changed := range stackDiff.Changed {
 		g.Go(func() error {
 			components, err := p.walkChangedStack(ctx, input, changed.FromStack, changed.ToStack)
 			if err != nil {
-				mu.Lock()
-
-				errs = append(errs, err)
-
-				mu.Unlock()
-
 				return err
 			}
 
 			for _, c := range components {
 				discoveredComponents.EnsureComponent(c)
 			}
 
 			return nil
 		})
 	}
 
 	if err := g.Wait(); err != nil {
 		return nil, err
 	}
-
-	if len(errs) > 0 {
-		return nil, errors.Join(errs...)
-	}

424-448: Data race: concurrent writes to fromSHA and toSHA.

While the error variable race was fixed using localErr, the fromSHA and toSHA variables declared at line 425 are still written concurrently by the two goroutines (lines 433 and 441). Although Go's memory model typically makes this safe after Wait() returns (since the writes complete before Wait() returns), it's cleaner and more explicit to use the same local-variable pattern for the SHA values.

♻️ Proposed fix for clarity and consistency
 	for _, pair := range componentPairs {
-		var fromSHA, toSHA string
+		var (
+			fromSHA string
+			toSHA   string
+		)
 
 		shaGroup, _ := errgroup.WithContext(ctx)
 		shaGroup.SetLimit(min(runtime.NumCPU(), 2)) //nolint:mnd
 
 		shaGroup.Go(func() error {
-			var localErr error
-
-			fromSHA, localErr = GenerateDirSHA256(pair.FromComponent.Path())
-
-			return localErr
+			sha, err := GenerateDirSHA256(pair.FromComponent.Path())
+			if err != nil {
+				return err
+			}
+			fromSHA = sha
+			return nil
 		})
 
 		shaGroup.Go(func() error {
-			var localErr error
-
-			toSHA, localErr = GenerateDirSHA256(pair.ToComponent.Path())
-
-			return localErr
+			sha, err := GenerateDirSHA256(pair.ToComponent.Path())
+			if err != nil {
+				return err
+			}
+			toSHA = sha
+			return nil
 		})

335-402: Inconsistent error handling pattern between methods.

In walkChangedStack, goroutines append errors to errs but return nil (lines 349-356, 377-384), allowing both discoveries to run even if one fails. However, the check if err = discoveryGroup.Wait(); err != nil at line 396 will never be true since goroutines always return nil.

This is fine if the intent is to collect all errors, but it's inconsistent with discoverChangesInWorktreeStacks where goroutines return the error directly.

♻️ Proposed fix to remove dead code
-	if err = discoveryGroup.Wait(); err != nil {
-		return nil, err
-	}
+	_ = discoveryGroup.Wait()
 
 	if len(errs) > 0 {
 		return nil, errors.Join(errs...)
 	}
internal/discovery/discovery.go (1)

453-474: Potential dead code in error handling.

Since goroutines always return nil (line 464), g.Wait() at line 468 will never return a non-nil error. The error handling at lines 468-471 is effectively dead code.

♻️ Proposed fix to remove dead code
 		})
 	}
 
-	err := g.Wait()
-	if err != nil {
-		l.Debugf("Error building dependency graph: %v", err)
-	}
+	_ = g.Wait()
 
 	return errs
 }

Comment on lines +817 to +823
depComponent := component.NewUnit(depPath)

if isExternal(parentCtx.WorkingDir, depPath) {
depComponent.SetExternal()
}

parent.AddDependency(depComponent)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Dependency added to parent before ensuring component exists.

At line 823, parent.AddDependency(depComponent) is called before threadSafeComponents.EnsureComponent at line 825. If EnsureComponent returns a different (existing) component, the parent's dependency list will reference the newly created component rather than the canonical one.

🐛 Proposed fix
 	depComponent := component.NewUnit(depPath)
 
 	if isExternal(parentCtx.WorkingDir, depPath) {
 		depComponent.SetExternal()
 	}
 
-	parent.AddDependency(depComponent)
-
 	addedComponent, created := threadSafeComponents.EnsureComponent(depComponent)
+	parent.AddDependency(addedComponent)
+
 	if created {
 		copiedCtx := parentCtx.CopyWithNewOrigin(component.OriginGraphDiscovery)
🤖 Prompt for AI Agents
In `@internal/discovery/phase_graph.go` around lines 817 - 823, The
parent.AddDependency call is adding a freshly constructed depComponent before
threadSafeComponents.EnsureComponent can return the canonical/existing
component; move the dependency addition until after calling
threadSafeComponents.EnsureComponent so the parent references the canonical
component. Specifically, create depComponent and set external via
isExternal(...), then call threadSafeComponents.EnsureComponent(...) and use the
returned component (not the local depComponent) when calling
parent.AddDependency(...); ensure you still propagate external state if
EnsureComponent returns an existing component.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants