Repository: open-policy-agent/conftest

Issue:
Trace is not outputted when `--output=table`
~As of v0.55.0, the CONFTEST_TRACE env var does not seem to set the `conftest test --trace` flag.~

I expected the trace to still be displayed even if the output is set to something else.

Discussion:
@thevilledev: Could not reproduce this on v0.55.0 or v0.56.0 (latest) on macOS 15.2. These commands produce identical output, sometimes in different order:

```
$ CONFTEST_TRACE=true conftest test examples/kubernetes/service.yaml -p examples/kubernetes/policy

$ conftest test --trace examples/kubernetes/service.yaml -p examples/kubernetes/policy
```

Looking at the code:
-  `internal/commands/default.go` sets the env var prefix correctly
- `internal/commands/test.go` binds flagname `trace` correctly
- `TestRunner` struct in `runner/test.go` has a Trace field, and Viper binds struct fields to variables case-insensitively. Even though the struct tag `mapstructure:"trace"` is missing I think this should work.

Which environment was this failing on?@tonglil: Thanks for diving into this @thevilledev.

Upon closer inspection, I was setting the output flag to `table`, which seems to override `trace`, which I did not expect and falsely attributed to the flag not working.@jalseth: I agree this is not intuitive (or documented) behavior. I haven't had time to dig into why it is this way, so there may be a reason. PRs are welcome.@thevilledev: I noticed the tracing flag is only available for standard output. It's not passed to other output formats even if defined, on the other hand why would it be. I have a proposal to fix this in #1060 so that if the flag is defined, output format always defaults to standard.@thevilledev: PR #1060 reduced to a documentation update due to concerns of making this a breaking change.

Here's my proposal to support tracing for any output format:

## Problem
Currently, users cannot use tracing together with custom output formats (JSON, table, etc.) because the output format takes priority. This limits debugging capabilities when working with tools that require specific output formats.

## Proposed Solution
Allow `--trace` to work with any output format by:
- Writing trace output to stderr
- Writing the formatted output (JSON, table, etc.) to stdout

This way:
- Tools can still parse their expected output format from stdout
- Users can see trace information for debugging in stderr
- No breaking changes to existing behavior

Example desired usage:
```console
# Trace output goes to stderr, JSON output to stdout
$ conftest test --trace --output json deployment.yaml 2>trace.log
```

## Implementation Notes
- The `Standard` outputter would need to be modified to support writing trace output to a different stream than the main output
- Need to ensure cross-platform compatibility (Windows, Unix-like systems)
- Consider adding a flag to control trace output destination (stdout/stderr/file)

## Benefits
- Better debugging experience when using tools that require specific output formats
- No need to switch between trace mode and output format mode
- Follows standard practice of using stderr for diagnostic output


Solution patch:
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index d08d440..f269989 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -58,25 +58,13 @@ jobs:
           cache: false
 
       - name: golangci-lint
-        uses: golangci/golangci-lint-action@v6
+        uses: golangci/golangci-lint-action@v7
         with:
           args: --timeout=5m --color=always --max-same-issues=0 --max-issues-per-linter=0
 
       - name: build
         run: make build
 
-      - name: setup docker buildx
-        run: docker buildx create --name conftestbuild --use
-
-      - name: Build Docker image
-        uses: docker/build-push-action@v6 
-        with:
-          context: .
-          push: false
-          tags: |
-            ${{ env.IMAGE }}:latest
-          platforms: ${{ env.PLATFORMS }}
-
       - name: unit test
         run: make test
 
@@ -89,7 +77,7 @@ jobs:
           cache: 'pip'
 
       - name: setup bats
-        uses: bats-core/bats-action@1.5.4
+        uses: bats-core/bats-action@3.0.1
         with:
           # Pin bats version to fix CI issue: https://github.com/bats-core/bats-action/pull/4
           bats-version: "1.10.0"
@@ -106,3 +94,23 @@ jobs:
 
       - name: test oci push/pull
         run: ./scripts/push-pull-e2e.sh
+
+  docker:
+    runs-on: ubuntu-latest
+    needs:
+      - validate
+    steps:
+      - name: checkout source
+        uses: actions/checkout@v4
+
+      - name: setup docker buildx
+        run: docker buildx create --name conftestbuild --use
+
+      - name: Build Docker image
+        uses: docker/build-push-action@v6 
+        with:
+          context: .
+          push: false
+          tags: |
+            ${{ env.IMAGE }}:latest
+          platforms: ${{ env.PLATFORMS }}
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index ccb20e3..2dd93b1 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -72,7 +72,7 @@ jobs:
           platforms: ${{ env.PLATFORMS }}
 
       - name: setup go
-        uses: actions/setup-go@v4
+        uses: actions/setup-go@v5
         with:
           go-version: "1.24.x"
 
diff --git a/.golangci.yaml b/.golangci.yaml
index de8ef52..f44ec62 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -1,23 +1,10 @@
-linters-settings:
-  misspell:
-    locale: US
-  govet:
-    enable:
-      - nilness
-  staticcheck:
-    checks:
-      - "all" # Include all checks except the ones below.
-      - "-SA1019" # Do not block the build if deprecated functions or packages are used.
-
+version: "2"
 linters:
-  disable-all: true
+  default: none
   enable:
     - errcheck
     - goconst
-    - gofmt
-    - goimports
     - gosec
-    - gosimple
     - govet
     - ineffassign
     - makezero
@@ -26,8 +13,40 @@ linters:
     - predeclared
     - revive
     - staticcheck
-    - typecheck
     - unconvert
     - unparam
     - unused
     - wastedassign
+  settings:
+    govet:
+      enable:
+        - nilness
+    misspell:
+      locale: US
+    staticcheck:
+      checks:
+        # These are processed in order. It is important that the inclusion
+        # comes before the exclusion.
+        - all
+        - -SA1019
+  exclusions:
+    generated: lax
+    presets:
+      - comments
+      - common-false-positives
+      - legacy
+      - std-error-handling
+    paths:
+      - third_party$
+      - builtin$
+      - examples$
+formatters:
+  enable:
+    - gofmt
+    - goimports
+  exclusions:
+    generated: lax
+    paths:
+      - third_party$
+      - builtin$
+      - examples$
diff --git a/acceptance.bats b/acceptance.bats
index 33443fc..3dcee36 100755
--- a/acceptance.bats
+++ b/acceptance.bats
@@ -521,3 +521,12 @@ EOF"
   [ "$status" -eq 1 ]
   [[ "$output" =~ "look up message type" ]]
 }
+
+@test "Can parse files from a symlinked directory" {
+  TMPDIR="$(mktemp -d -u)"
+  ln -s $(pwd)/examples/hcl2 ${TMPDIR}
+  run ./conftest test -p examples/hcl2/policy ${TMPDIR}
+  rm -rf ${TMPDIR}
+  [ "$status" -eq 1 ]
+  [[ "$output" =~ "10 tests, 3 passed, 0 warnings, 7 failures, 0 exceptions" ]]
+}
diff --git a/docs/debug.md b/docs/debug.md
index 99e0035..9e8259b 100644
--- a/docs/debug.md
+++ b/docs/debug.md
@@ -108,3 +108,17 @@ TRAC | Exit data.main.deny = _
 TRAC Redo data.main.deny = _
 TRAC | Redo data.main.deny = _
 ```
+
+## Using trace with other output formats
+
+You can use the `--trace` flag together with any output format. When using `--trace` with formats like `--output=table` or `--output=json`, the trace information will be written to stderr while the formatted output will be written to stdout. This allows you to capture trace information for debugging while still using your preferred output format.
+
+For example:
+
+```console
+# Output trace to stderr and table format to stdout
+$ conftest test --trace --output=table deployment.yaml
+
+# Capture trace output to a file while viewing table output
+$ conftest test --trace --output=table deployment.yaml 2>trace.log
+```
diff --git a/output/output.go b/output/output.go
index 8039787..fd9660c 100644
--- a/output/output.go
+++ b/output/output.go
@@ -43,9 +43,34 @@ func Get(format string, options Options) Outputter {
 		options.File = os.Stdout
 	}
 
+	// If tracing is enabled, output trace to stderr first,
+	// then return the requested outputter
+	if options.Tracing {
+		traceHandler := &Standard{
+			Writer:  os.Stderr,
+			NoColor: options.NoColor,
+			Tracing: true,
+		}
+
+		// Return a trace outputter that handles both trace and regular output
+		return newTraceOutputter(traceHandler, newOutputter(format, options))
+	}
+
+	// If no tracing, return the regular outputter
+	return newOutputter(format, options)
+}
+
+// newOutputter creates an outputter based on the format and options
+func newOutputter(format string, options Options) Outputter {
 	switch format {
 	case OutputStandard:
-		return &Standard{Writer: options.File, NoColor: options.NoColor, SuppressExceptions: options.SuppressExceptions, Tracing: options.Tracing, ShowSkipped: options.ShowSkipped}
+		return &Standard{
+			Writer:             options.File,
+			NoColor:            options.NoColor,
+			SuppressExceptions: options.SuppressExceptions,
+			Tracing:            options.Tracing,
+			ShowSkipped:        options.ShowSkipped,
+		}
 	case OutputJSON:
 		return NewJSON(options.File)
 	case OutputTAP:
@@ -65,6 +90,36 @@ func Get(format string, options Options) Outputter {
 	}
 }
 
+// traceOutputter handles outputting trace to stderr while sending regular output to stdout
+type traceOutputter struct {
+	traceHandler  *Standard
+	mainOutputter Outputter
+}
+
+// newTraceOutputter creates a new traceOutputter with the given trace handler and main outputter
+func newTraceOutputter(traceHandler *Standard, mainOutputter Outputter) *traceOutputter {
+	return &traceOutputter{
+		traceHandler:  traceHandler,
+		mainOutputter: mainOutputter,
+	}
+}
+
+// Output outputs the results, handling trace separately
+func (t *traceOutputter) Output(results CheckResults) error {
+	// First, output trace to stderr
+	if err := t.traceHandler.outputTraceOnly(results); err != nil {
+		return err
+	}
+
+	// Then, output regular format to stdout
+	return t.mainOutputter.Output(results)
+}
+
+// Report passes through to the main outputter
+func (t *traceOutputter) Report(results []*tester.Result, flag string) error {
+	return t.mainOutputter.Report(results, flag)
+}
+
 // Outputs returns the available output formats.
 func Outputs() []string {
 	return []string{
diff --git a/output/standard.go b/output/standard.go
index 7504934..b50009c 100644
--- a/output/standard.go
+++ b/output/standard.go
@@ -175,6 +175,18 @@ func (s *Standard) outputTrace(results CheckResults, colorizer aurora.Aurora) {
 	}
 }
 
+// outputTraceOnly outputs only the trace information to the writer
+// This is used by the TraceOutputter to output trace to stderr
+func (s *Standard) outputTraceOnly(results CheckResults) error {
+	colorizer := aurora.NewAurora(true)
+	if s.NoColor {
+		colorizer = aurora.NewAurora(false)
+	}
+
+	s.outputTrace(results, colorizer)
+	return nil
+}
+
 // Report outputs results similar to OPA test output
 func (s *Standard) Report(results []*tester.Result, flag string) error {
 	reporter := tester.PrettyReporter{
diff --git a/parser/properties/properties.go b/parser/properties/properties.go
index 6841176..318d901 100644
--- a/parser/properties/properties.go
+++ b/parser/properties/properties.go
@@ -13,7 +13,7 @@ type Parser struct{}
 func (pp *Parser) Unmarshal(p []byte, v any) error {
 	rawProps, err := prop.LoadString(string(p))
 	if err != nil {
-		return fmt.Errorf("Could not parse properties file: %w", err)
+		return fmt.Errorf("parse properties file: %w", err)
 	}
 
 	result := rawProps.Map()



Test patch:
diff --git a/internal/commands/test.go b/internal/commands/test.go
index f8e5729..89d286c 100644
--- a/internal/commands/test.go
+++ b/internal/commands/test.go
@@ -65,10 +65,18 @@ It expects one or more urls to fetch the latest policies from, e.g.:
 See the pull command for more details on supported protocols for fetching policies.
 
 When debugging policies it can be useful to use a more verbose policy evaluation output. By using the '--trace' flag
-the output will include a detailed trace of how the policy was evaluated. When both '--trace' and '--output' are specified,
-the output format takes priority and tracing will not be used.
+the output will include a detailed trace of how the policy was evaluated. The trace output will be written to stderr,
+while the regular output will be written to stdout. This allows you to use the '--trace' flag together with any output
+format, including table, JSON, etc.
 
+    # Trace output
 	$ conftest test --trace <input-file>
+
+	# Trace output with any non-standard output format
+	$ conftest test --trace --output=table <input-file>
+
+	# Redirect trace output to a file while viewing formatted output
+	$ conftest test --trace --output=json <input-file> 2>trace.log
 `
 
 // TestRun stores the compiler and store for a test run.
diff --git a/output/output_test.go b/output/output_test.go
index 97ad8b1..d487141 100644
--- a/output/output_test.go
+++ b/output/output_test.go
@@ -1,8 +1,11 @@
 package output
 
 import (
+	"bytes"
+	"io"
 	"os"
 	"reflect"
+	"strings"
 	"testing"
 )
 
@@ -10,51 +13,78 @@ func TestGetOutputter(t *testing.T) {
 	testCases := []struct {
 		input    string
 		expected Outputter
+		tracing  bool
 	}{
 		{
 			input:    OutputStandard,
 			expected: NewStandard(os.Stdout),
+			tracing:  false,
+		},
+		{
+			input:    OutputStandard,
+			expected: NewStandard(os.Stdout),
+			tracing:  true,
 		},
 		{
 			input:    OutputJSON,
 			expected: NewJSON(os.Stdout),
+			tracing:  false,
+		},
+		{
+			input:    OutputJSON,
+			expected: NewJSON(os.Stdout),
+			tracing:  true,
 		},
 		{
 			input:    OutputTAP,
 			expected: NewTAP(os.Stdout),
+			tracing:  false,
 		},
 		{
 			input:    OutputTable,
 			expected: NewTable(os.Stdout),
+			tracing:  false,
 		},
 		{
 			input:    OutputJUnit,
 			expected: NewJUnit(os.Stdout, false),
+			tracing:  false,
 		},
 		{
 			input:    OutputGitHub,
 			expected: NewGitHub(os.Stdout),
+			tracing:  false,
 		},
 		{
 			input:    OutputAzureDevOps,
 			expected: NewAzureDevOps(os.Stdout),
+			tracing:  false,
 		},
 		{
 			input:    OutputSARIF,
 			expected: NewSARIF(os.Stdout),
+			tracing:  false,
 		},
 		{
 			input:    "unknown_format",
 			expected: NewStandard(os.Stdout),
+			tracing:  false,
 		},
 	}
 
 	for _, testCase := range testCases {
 		t.Run(testCase.input, func(t *testing.T) {
-			actual := Get(testCase.input, Options{NoColor: true})
+			actual := Get(testCase.input, Options{NoColor: true, Tracing: testCase.tracing})
 
-			actualType := reflect.TypeOf(actual)
+			// If tracing is enabled, we expect a traceOutputter
+			if testCase.tracing {
+				if _, ok := actual.(*traceOutputter); !ok {
+					t.Errorf("Expected traceOutputter but got %T", actual)
+				}
+				return
+			}
 
+			actualType := reflect.TypeOf(actual)
 			expectedType := reflect.TypeOf(testCase.expected)
 			if expectedType != actualType {
 				t.Errorf("Unexpected outputter. expected %v actual %v", expectedType, actualType)
@@ -62,3 +92,87 @@ func TestGetOutputter(t *testing.T) {
 		})
 	}
 }
+
+func TestTraceOutputter(t *testing.T) {
+	// Create a test result with trace information
+	results := CheckResults{
+		{
+			FileName:  "test.yaml",
+			Namespace: "test",
+			Failures:  []Result{{Message: "test failure"}},
+			Queries: []QueryResult{
+				{
+					Query: "data.main.deny",
+					Traces: []string{
+						"TRACE line 1",
+						"TRACE line 2",
+					},
+				},
+			},
+		},
+	}
+
+	tests := []struct {
+		name            string
+		createOutputter func(io.Writer) Outputter
+		expectedStdout  string
+		expectedTrace   []string
+	}{
+		{
+			name: "table format with trace",
+			createOutputter: func(w io.Writer) Outputter {
+				return NewTable(w)
+			},
+			expectedStdout: "test.yaml",
+			expectedTrace:  []string{"TRACE line 1", "TRACE line 2"},
+		},
+		{
+			name: "json format with trace",
+			createOutputter: func(w io.Writer) Outputter {
+				return NewJSON(w)
+			},
+			expectedStdout: "test.yaml",
+			expectedTrace:  []string{"TRACE line 1", "TRACE line 2"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Create buffers for stdout and stderr
+			stdoutBuf := new(bytes.Buffer)
+			stderrBuf := new(bytes.Buffer)
+
+			// Create the main outputter
+			mainOutputter := tt.createOutputter(stdoutBuf)
+
+			// Create the trace handler
+			traceHandler := &Standard{
+				Writer:  stderrBuf,
+				NoColor: true,
+				Tracing: true,
+			}
+
+			// Create the trace outputter
+			traceOut := newTraceOutputter(traceHandler, mainOutputter)
+
+			// Output the results
+			if err := traceOut.Output(results); err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+
+			// Check that trace output went to stderr
+			stderrOutput := stderrBuf.String()
+			for _, trace := range tt.expectedTrace {
+				if !strings.Contains(stderrOutput, trace) {
+					t.Errorf("stderr missing expected trace: %q", trace)
+				}
+			}
+
+			// Check that formatted output went to stdout
+			stdoutOutput := stdoutBuf.String()
+			if !strings.Contains(stdoutOutput, tt.expectedStdout) {
+				t.Errorf("stdout missing expected content: %q", tt.expectedStdout)
+			}
+		})
+	}
+}
diff --git a/runner/test.go b/runner/test.go
index c36dddb..48788c9 100644
--- a/runner/test.go
+++ b/runner/test.go
@@ -149,19 +149,17 @@ func parseFileList(fileList []string, ignoreRegex string) ([]string, error) {
 	return files, nil
 }
 
-func getFilesFromDirectory(directory string, ignoreRegex string) ([]string, error) {
-	regexp, err := regexp.Compile(ignoreRegex)
-	if err != nil {
-		return nil, fmt.Errorf("given regexp couldn't be parsed :%w", err)
-	}
-
-	var files []string
-	walk := func(currentPath string, info os.FileInfo, err error) error {
+func getWalkFn(visitedDirs map[string]bool, files *[]string, ignoreRegex string, regexp *regexp.Regexp) filepath.WalkFunc {
+	return func(currentPath string, info os.FileInfo, err error) error {
 		if err != nil {
 			return fmt.Errorf("walk path: %w", err)
 		}
 
 		if info.IsDir() {
+			if visitedDirs[currentPath] {
+				return filepath.SkipDir
+			}
+			visitedDirs[currentPath] = true
 			return nil
 		}
 
@@ -169,14 +167,44 @@ func getFilesFromDirectory(directory string, ignoreRegex string) ([]string, erro
 			return nil
 		}
 
-		if parser.FileSupported(currentPath) {
-			files = append(files, currentPath)
+		if info.Mode()&os.ModeSymlink == 0 {
+			if parser.FileSupported(currentPath) {
+				*files = append(*files, currentPath)
+			}
+			return nil
+		}
+
+		realPath, err := filepath.EvalSymlinks(currentPath)
+		if err != nil {
+			return err
+		}
+
+		ri, err := os.Stat(realPath)
+		if err != nil {
+			return err
+		}
+
+		if ri.IsDir() {
+			return filepath.Walk(realPath, getWalkFn(visitedDirs, files, ignoreRegex, regexp))
+		}
+
+		if parser.FileSupported(realPath) {
+			*files = append(*files, realPath)
 		}
 
 		return nil
 	}
+}
+
+func getFilesFromDirectory(directory string, ignoreRegex string) ([]string, error) {
+	regexp, err := regexp.Compile(ignoreRegex)
+	if err != nil {
+		return nil, fmt.Errorf("given regexp couldn't be parsed :%w", err)
+	}
 
-	err = filepath.Walk(directory, walk)
+	var files []string
+	visitedDirs := make(map[string]bool)
+	err = filepath.Walk(directory, getWalkFn(visitedDirs, &files, ignoreRegex, regexp))
 	if err != nil {
 		return nil, err
 	}