Skip to content

Commit b0d142d

Browse files
authored
fix: Fixing negation logic (#5368)
* chore: Adding repro red test * fix: Fixing logic for naked negation * fix: Fixing stack discovery negation * fix: Fixing typo in bug report template * fix: Avoiding usage of `slices.DeleteFunc` to prevent zeroing
1 parent 913178c commit b0d142d

9 files changed

Lines changed: 115 additions & 24 deletions

File tree

.github/ISSUE_TEMPLATE/01-bug_report.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Without this information, it is much less likely that maintainers will invest ti
2020
The exceptions to requiring steps to reproduce are:
2121

2222
1. You are reporting a bug that you don't know how to reproduce, but you are reporting it so that others in the community are aware of it.
23-
2. You are willing to fix the bug yourself, and you accept the reponsibility of ensuring that the bug is valid, and that the fix is well tested.
23+
2. You are willing to fix the bug yourself, and you accept the responsibility of ensuring that the bug is valid, and that the fix is well tested.
2424

2525
How to provide steps for reproduction:
2626

internal/filter/ast.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ type Expression interface {
2121
RequiresParse() (Expression, bool)
2222
// IsRestrictedToStacks returns true if the expression is restricted to stacks.
2323
IsRestrictedToStacks() bool
24+
// Negated returns the equivalent expression with negation flipped.
25+
Negated() Expression
2426
}
2527

2628
// Expressions is a slice of expressions.
@@ -57,6 +59,7 @@ func (p *PathExpression) String() string { return p.Value
5759
func (p *PathExpression) RequiresDiscovery() (Expression, bool) { return p, false }
5860
func (p *PathExpression) RequiresParse() (Expression, bool) { return p, false }
5961
func (p *PathExpression) IsRestrictedToStacks() bool { return false }
62+
func (p *PathExpression) Negated() Expression { return NewPrefixExpression("!", p) }
6063

6164
// AttributeExpression represents a key-value attribute filter (e.g., "name=my-app").
6265
type AttributeExpression struct {
@@ -119,6 +122,9 @@ func (a *AttributeExpression) RequiresParse() (Expression, bool) {
119122
func (a *AttributeExpression) IsRestrictedToStacks() bool {
120123
return a.Key == "type" && a.Value == "stack"
121124
}
125+
func (a *AttributeExpression) Negated() Expression {
126+
return NewPrefixExpression("!", a)
127+
}
122128

123129
// PrefixExpression represents a prefix operator expression (e.g., "!name=foo").
124130
type PrefixExpression struct {
@@ -157,6 +163,14 @@ func (p *PrefixExpression) IsRestrictedToStacks() bool {
157163
return false
158164
}
159165
}
166+
func (p *PrefixExpression) Negated() Expression {
167+
switch p.Operator {
168+
case "!":
169+
return p.Right
170+
default:
171+
return NewPrefixExpression("!", p.Right)
172+
}
173+
}
160174

161175
// InfixExpression represents an infix operator expression (e.g., "./apps/* | name=bar").
162176
type InfixExpression struct {
@@ -204,6 +218,14 @@ func (i *InfixExpression) IsRestrictedToStacks() bool {
204218
return false
205219
}
206220
}
221+
func (i *InfixExpression) Negated() Expression {
222+
switch i.Operator {
223+
case "|":
224+
return NewInfixExpression(i.Left.Negated(), i.Operator, i.Right)
225+
default:
226+
return NewInfixExpression(i.Left.Negated(), i.Operator, i.Right)
227+
}
228+
}
207229

208230
// GraphExpression represents a graph traversal expression (e.g., "...foo", "foo...", "...foo...", "^foo").
209231
type GraphExpression struct {
@@ -255,6 +277,9 @@ func (g *GraphExpression) RequiresParse() (Expression, bool) {
255277
return g, true
256278
}
257279
func (g *GraphExpression) IsRestrictedToStacks() bool { return false }
280+
func (g *GraphExpression) Negated() Expression {
281+
return NewPrefixExpression("!", g)
282+
}
258283

259284
// GitExpression represents a Git-based filter expression (e.g., "[main...HEAD]" or "[main]").
260285
// It filters components based on changes between Git references.
@@ -280,6 +305,9 @@ func (g *GitExpression) RequiresParse() (Expression, bool) {
280305
return nil, false
281306
}
282307
func (g *GitExpression) IsRestrictedToStacks() bool { return false }
308+
func (g *GitExpression) Negated() Expression {
309+
return NewPrefixExpression("!", g)
310+
}
283311

284312
// GitExpressions is a slice of Git expressions.
285313
type GitExpressions []*GitExpression

internal/filter/evaluator.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,20 +195,23 @@ func evaluatePrefixExpression(l log.Logger, expr *PrefixExpression, components c
195195
return nil, err
196196
}
197197

198-
excludeSet := make(map[string]struct{}, len(toExclude))
199-
for _, c := range toExclude {
200-
excludeSet[c.Path()] = struct{}{}
198+
if len(toExclude) == 0 {
199+
return components, nil
201200
}
202201

203-
var result component.Components
202+
// We don't use slices.DeleteFunc here because we don't want the members of the original components slice to be
203+
// zeroed.
204+
results := make(component.Components, 0, len(components)-len(toExclude))
204205

205206
for _, c := range components {
206-
if _, ok := excludeSet[c.Path()]; !ok {
207-
result = append(result, c)
207+
if slices.Contains(toExclude, c) {
208+
continue
208209
}
210+
211+
results = append(results, c)
209212
}
210213

211-
return result, nil
214+
return results, nil
212215
}
213216

214217
// evaluateInfixExpression evaluates an infix expression (intersection).

internal/filter/filter.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,27 @@ func (f *Filter) RequiresParse() (Expression, bool) {
5555
return f.expr.RequiresParse()
5656
}
5757

58+
// Negated returns the equivalent filter with negation flipped.
59+
//
60+
// If the filter is already negated, it will return the non-negated filter.
61+
func (f *Filter) Negated() *Filter {
62+
switch node := f.expr.(type) {
63+
case *PrefixExpression:
64+
return NewFilter(node.Right, f.originalQuery)
65+
case *InfixExpression:
66+
return NewFilter(
67+
NewInfixExpression(
68+
node.Left.Negated(),
69+
node.Operator,
70+
node.Right,
71+
),
72+
f.originalQuery,
73+
)
74+
default:
75+
return f
76+
}
77+
}
78+
5879
// Apply is a convenience function that parses and evaluates a filter in one step.
5980
// It's equivalent to calling Parse followed by Evaluate.
6081
func Apply(l log.Logger, filterString string, components component.Components) (component.Components, error) {

internal/filter/filters.go

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -286,19 +286,44 @@ func (f Filters) Evaluate(l log.Logger, components component.Components) (compon
286286
return nil, err
287287
}
288288

289-
// Phase 2: Apply negative filters to remove components
290-
for _, filter := range negativeFilters {
291-
var result component.Components
289+
if len(negativeFilters) == 0 {
290+
return combined, nil
291+
}
292+
293+
// Phase 2: Apply negative filters to find components to remove
294+
toRemove := make(component.Components, 0, len(combined))
292295

293-
result, err = filter.Evaluate(l, combined)
296+
for _, filter := range negativeFilters {
297+
removed, err := filter.Negated().Evaluate(l, combined)
294298
if err != nil {
295299
return nil, err
296300
}
297301

298-
combined = result
302+
for _, c := range removed {
303+
if !slices.Contains(toRemove, c) {
304+
toRemove = append(toRemove, c)
305+
}
306+
}
307+
}
308+
309+
if len(toRemove) == 0 {
310+
return combined, nil
311+
}
312+
313+
// Phase 3: Remove components from the initial set
314+
315+
// We don't use slices.DeleteFunc here because we don't want the members of the original components slice to be
316+
// zeroed.
317+
results := make(component.Components, 0, len(combined)-len(toRemove))
318+
for _, c := range combined {
319+
if slices.Contains(toRemove, c) {
320+
continue
321+
}
322+
323+
results = append(results, c)
299324
}
300325

301-
return combined, nil
326+
return results, nil
302327
}
303328

304329
// EvaluateOnFiles evaluates the filters on a list of files and returns the filtered result.
@@ -336,12 +361,7 @@ func initialComponents(l log.Logger, positiveFilters []*Filter, components compo
336361
seen := make(map[string]component.Component, len(components))
337362

338363
for _, filter := range positiveFilters {
339-
var (
340-
result component.Components
341-
err error
342-
)
343-
344-
result, err = filter.Evaluate(l, components)
364+
result, err := filter.Evaluate(l, components)
345365
if err != nil {
346366
return nil, err
347367
}
@@ -376,9 +396,12 @@ func (f Filters) String() string {
376396

377397
// startsWithNegation checks if an expression starts with a negation operator.
378398
func startsWithNegation(expr Expression) bool {
379-
if prefixExpr, ok := expr.(*PrefixExpression); ok {
380-
return prefixExpr.Operator == "!"
399+
switch node := expr.(type) {
400+
case *PrefixExpression:
401+
return node.Operator == "!"
402+
case *InfixExpression:
403+
return startsWithNegation(node.Left)
404+
default:
405+
return false
381406
}
382-
383-
return false
384407
}

test/fixtures/exclude-by-default/_stacks/terragrunt.stack.hcl

Whitespace-only changes.

test/fixtures/exclude-by-default/unit1/main.tf

Whitespace-only changes.

test/fixtures/exclude-by-default/unit1/terragrunt.hcl

Whitespace-only changes.

test/integration_filter_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const (
2929
testFixtureFilterSource = "fixtures/filter-source"
3030
testFixtureMinimizeParsing = "fixtures/filter/minimize-parsing"
3131
testFixtureMinimizeParsingDestroy = "fixtures/filter/minimize-parsing-destroy"
32+
testFixtureExcludeByDefault = "fixtures/exclude-by-default"
3233
)
3334

3435
// createTestUnit creates a unit directory with terragrunt.hcl and main.tf files.
@@ -2387,3 +2388,18 @@ resource "null_resource" "test" {}
23872388
require.NotContains(t, output, "Too many command line arguments")
23882389
require.NotContains(t, output, "Expected at most one positional argument")
23892390
}
2391+
2392+
func TestFilterExcludeByDefault(t *testing.T) {
2393+
t.Parallel()
2394+
2395+
tmpEnvPath := helpers.CopyEnvironment(t, testFixtureExcludeByDefault)
2396+
rootPath := filepath.Join(tmpEnvPath, testFixtureExcludeByDefault)
2397+
2398+
helpers.CleanupTerraformFolder(t, rootPath)
2399+
2400+
cmd := "terragrunt run --all --no-color --working-dir " + rootPath + " --filter '!_stacks | type=stack' -- plan"
2401+
_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, cmd)
2402+
require.NoError(t, err)
2403+
2404+
assert.NotContains(t, stderr, "No units discovered", "Filter should discover units, not result in empty discovery")
2405+
}

0 commit comments

Comments
 (0)