Repository: open-policy-agent/conftest

Issue:
Support for identifying specific lines that are the source of the violation
Some parsers include metadata about the originating source location and file for entries in the JSON output. Rego policies can include this in the violation object returned to conftest. conftest would consume this and include it in a new field in the `output.Result` struct. Each outputter could decide on the best way to surface this to users.

For example, the `github` outputter may emit an additional line that points to the origin file, and update the message in the tested file. 

Discussion:
@anderseknert: Do you know of any Go parser for JSON that records locations? It's been a while, but I found nothing last time I looked. Having Regal's language server (in particular, but there are more use cases) highlight the parts of a JSON document that violate policy would be amazing. @jalseth: I haven't tested it, but this one seems to have the location of each token https://pkg.go.dev/github.com/rafaelmgr12/jingo@v0.0.0-20250218200502-5230a95e962d/pkg/parser#Token.@anderseknert: Neat, I'll have to try it out. Thanks :) @jalseth: This was included in https://github.com/open-policy-agent/conftest/releases/tag/v0.64.0, along with support in the GitHub outputter.

Solution patch:
diff --git a/.gitignore b/.gitignore
index e22f49d..8ddb7cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,7 +13,5 @@ node_modules/
 package.json
 package-lock.json
 
-# ignore generated doc in tests
-/tests/document/*.md
 # ignore prospective golden files
 /document/testdata/doc/*.golden
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
index 6692d6b..c44d3c3 100644
--- a/.pre-commit-hooks.yaml
+++ b/.pre-commit-hooks.yaml
@@ -17,3 +17,23 @@
   require_serial: true
   minimum_pre_commit_version: "2.9.0"
   stages: [pre-commit, pre-merge-commit, pre-push, manual]
+
+- id: conftest-pull
+  name: Pull Conftest Policy Rego files
+  description: Download Conftest Policies
+  entry: conftest pull
+  language: golang
+  pass_filenames: false
+  require_serial: true
+  minimum_pre_commit_version: "2.9.0"
+  stages: [pre-commit, pre-merge-commit, pre-push, manual]
+
+- id: conftest-fmt
+  name: Conftest Format Rego files
+  description: Format Rego unit tests for Conftest policies
+  entry: conftest fmt
+  language: golang
+  pass_filenames: true
+  require_serial: false
+  minimum_pre_commit_version: "2.9.0"
+  stages: [pre-commit, pre-merge-commit, pre-push, manual]
diff --git a/Dockerfile b/Dockerfile
index 33f8aad..0fca80d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.25.1-alpine AS base
+FROM golang:1.25.3-alpine AS base
 ARG TARGETARCH
 ARG VERSION
 ARG COMMIT
@@ -51,7 +51,7 @@ RUN go install cuelang.org/go/cmd/cue@latest
 WORKDIR /examples
 
 ## RELEASE ##
-FROM alpine:3.22.1
+FROM alpine:3.22.2
 
 # Install git for protocols that depend on it when using conftest pull
 RUN apk add --no-cache git
diff --git a/docs/index.md b/docs/index.md
index 242e139..0fa5b4c 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -104,6 +104,10 @@ repos:
 
 The `conftest-test` hook validates your configuration files against policies, while the `conftest-verify` hook runs unit tests for your policies.
 
+Additional hooks are available including `conftest-pull` for downloading policies and `conftest-fmt` for formatting Rego files.
+See the [.pre-commit-hooks.yaml](https://github.com/open-policy-agent/conftest/blob/main/.pre-commit-hooks.yaml) file
+for the complete list of available hooks and their configuration options.
+
 For more information on pre-commit hooks, refer to the [pre-commit documentation](https://pre-commit.com/).
 
 ### Testing/Verifying Policies
diff --git a/output/result.go b/output/result.go
index 24ed15b..2297476 100644
--- a/output/result.go
+++ b/output/result.go
@@ -1,6 +1,7 @@
 package output
 
 import (
+	"encoding/json"
 	"fmt"
 	"slices"
 )
@@ -8,27 +9,56 @@ import (
 // Result describes the result of a single rule evaluation.
 type Result struct {
 	Message  string         `json:"msg"`
+	Location *Location      `json:"loc,omitempty"`
 	Metadata map[string]any `json:"metadata,omitempty"`
 	Outputs  []string       `json:"outputs,omitempty"`
 }
 
+// Location describes the origin location in the configuration file that
+// caused the result to be produced.
+type Location struct {
+	File string      `json:"file,omitempty"`
+	Line json.Number `json:"line,omitempty"`
+}
+
+func (l Location) String() string {
+	return fmt.Sprintf("%s L%s", l.File, l.Line.String())
+}
+
+const (
+	msgField = "msg"
+	locField = "_loc"
+)
+
+var reservedFields = []string{
+	msgField,
+	locField,
+}
+
 // NewResult creates a new result. An error is returned if the
 // metadata could not be successfully parsed.
 func NewResult(metadata map[string]any) (Result, error) {
-	if _, ok := metadata["msg"]; !ok {
-		return Result{}, fmt.Errorf("rule missing msg field: %v", metadata)
+	if metadata == nil {
+		return Result{}, fmt.Errorf("metadata must be supplied")
 	}
-	if _, ok := metadata["msg"].(string); !ok {
-		return Result{}, fmt.Errorf("msg field must be string: %v", metadata)
+	msg, ok := lookup[string](metadata, msgField)
+	if !ok {
+		return Result{}, fmt.Errorf("%q field must be present and a string", msgField)
 	}
 
 	result := Result{
-		Message:  metadata["msg"].(string),
+		Message:  msg,
 		Metadata: make(map[string]any),
 	}
 
+	if loc, ok := metadata[locField]; ok {
+		if l := parseLocation(loc); l != nil {
+			result.Location = l
+		}
+	}
+
 	for k, v := range metadata {
-		if k != "msg" {
+		if !slices.Contains(reservedFields, k) {
 			result.Metadata[k] = v
 		}
 	}
@@ -36,6 +66,36 @@ func NewResult(metadata map[string]any) (Result, error) {
 	return result, nil
 }
 
+func parseLocation(location any) *Location {
+	loc, ok := location.(map[string]any)
+	if !ok {
+		return nil
+	}
+
+	l := &Location{}
+	if file, ok := lookup[string](loc, "file"); ok {
+		l.File = file
+	}
+	if line, ok := lookup[json.Number](loc, "line"); ok {
+		l.Line = line
+	}
+
+	if l.File == "" && l.Line.String() == "" {
+		return nil
+	}
+
+	return l
+}
+
+func lookup[T any](m map[string]any, k string) (value T, ok bool) {
+	x, ok := m[k]
+	if !ok {
+		return
+	}
+	value, ok = x.(T)
+	return
+}
+
 // Passed returns true if the result did not fail a policy.
 func (r Result) Passed() bool {
 	return r.Message == ""
diff --git a/policy/engine.go b/policy/engine.go
index 5e809e9..62294a4 100644
--- a/policy/engine.go
+++ b/policy/engine.go
@@ -391,7 +391,6 @@ func (e *Engine) check(ctx context.Context, path string, config any, namespace s
 		checkResult.Failures = append(checkResult.Failures, failures...)
 		checkResult.Warnings = append(checkResult.Warnings, warnings...)
 		checkResult.Exceptions = append(checkResult.Exceptions, exceptions...)
-
 		checkResult.Queries = append(checkResult.Queries, exceptionQueryResult, ruleQueryResult)
 	}
 



Test patch:
diff --git a/output/result_test.go b/output/result_test.go
index aad17f5..585c633 100644
--- a/output/result_test.go
+++ b/output/result_test.go
@@ -1,10 +1,86 @@
 package output
 
 import (
+	"encoding/json"
+	"strconv"
 	"testing"
+
+	"github.com/google/go-cmp/cmp"
 )
 
+func TestNewResult(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		desc    string
+		input   map[string]any
+		want    Result
+		wantErr bool
+	}{
+		{
+			desc:    "no metadata is an error",
+			wantErr: true,
+		},
+		{
+			desc:    "missing msg is an error",
+			input:   map[string]any{},
+			wantErr: true,
+		},
+		{
+			desc:    "non-string msg is an error",
+			input:   map[string]any{"msg": 123},
+			wantErr: true,
+		},
+		{
+			desc:  "msg only",
+			input: map[string]any{"msg": "message"},
+			want: Result{
+				Message:  "message",
+				Metadata: make(map[string]any),
+			},
+		},
+		{
+			desc: "msg with location and metadata",
+			input: map[string]any{
+				"msg": "message",
+				"_loc": map[string]any{
+					"file": "some_file.json",
+					"line": json.Number("123"),
+				},
+				"other": "metadata",
+			},
+			want: Result{
+				Message: "message",
+				Location: &Location{
+					File: "some_file.json",
+					Line: json.Number("123"),
+				},
+				Metadata: map[string]any{"other": "metadata"},
+			},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.desc, func(t *testing.T) {
+			t.Parallel()
+
+			got, err := NewResult(tc.input)
+			if gotErr := err != nil; gotErr != tc.wantErr {
+				t.Fatalf("NewResult() error = %v, want %v", err, tc.wantErr)
+			}
+			if tc.wantErr {
+				return
+			}
+			if diff := cmp.Diff(got, tc.want); diff != "" {
+				t.Errorf("NewResult() produced an unexpected diff:\n%s", diff)
+			}
+		})
+	}
+}
+
 func TestCheckResultsHelpers(t *testing.T) {
+	t.Parallel()
+
 	tests := []struct {
 		desc             string
 		results          CheckResults
@@ -70,6 +146,8 @@ func TestCheckResultsHelpers(t *testing.T) {
 
 	for _, tc := range tests {
 		t.Run(tc.desc, func(t *testing.T) {
+			t.Parallel()
+
 			if gotFailure := tc.results.HasFailure(); gotFailure != tc.wantHasFailure {
 				t.Errorf("HasFailure() = %v, want %v", gotFailure, tc.wantHasFailure)
 			}
@@ -87,6 +165,8 @@ func TestCheckResultsHelpers(t *testing.T) {
 }
 
 func TestExitCode(t *testing.T) {
+	t.Parallel()
+
 	warning := CheckResult{
 		Warnings: []Result{{}},
 	}
@@ -110,15 +190,21 @@ func TestExitCode(t *testing.T) {
 		{results: CheckResults{warning, failure}, expected: 1},
 	}
 
-	for _, testCase := range testCases {
-		actual := testCase.results.ExitCode()
-		if actual != testCase.expected {
-			t.Errorf("Unexpected error code. expected %v, actual %v", testCase.expected, actual)
-		}
+	for i, testCase := range testCases {
+		t.Run(strconv.Itoa(i), func(t *testing.T) {
+			t.Parallel()
+
+			actual := testCase.results.ExitCode()
+			if actual != testCase.expected {
+				t.Errorf("Unexpected error code. expected %v, actual %v", testCase.expected, actual)
+			}
+		})
 	}
 }
 
 func TestExitCodeFailOnWarn(t *testing.T) {
+	t.Parallel()
+
 	warning := CheckResult{
 		Warnings: []Result{{}},
 	}
@@ -137,10 +223,14 @@ func TestExitCodeFailOnWarn(t *testing.T) {
 		{results: CheckResults{warning, failure}, expected: 2},
 	}
 
-	for _, testCase := range testCases {
-		actual := testCase.results.ExitCodeFailOnWarn()
-		if actual != testCase.expected {
-			t.Errorf("Unexpected error code. expected %v, actual %v", testCase.expected, actual)
-		}
+	for i, testCase := range testCases {
+		t.Run(strconv.Itoa(i), func(t *testing.T) {
+			t.Parallel()
+
+			actual := testCase.results.ExitCodeFailOnWarn()
+			if actual != testCase.expected {
+				t.Errorf("Unexpected error code. expected %v, actual %v", testCase.expected, actual)
+			}
+		})
 	}
 }
diff --git a/tests/document/.gitignore b/tests/document/.gitignore
new file mode 100644
index 0000000..ae5e27d
--- /dev/null
+++ b/tests/document/.gitignore
@@ -0,0 +1,2 @@
+custom
+*.md
diff --git a/tests/pre-commit/test.bats b/tests/pre-commit/test.bats
index b2d38d6..b5d8d14 100644
--- a/tests/pre-commit/test.bats
+++ b/tests/pre-commit/test.bats
@@ -39,6 +39,14 @@ repos:
       args:
         - --policy
         - examples/kubernetes/policy
+    # This hook is intended to change/fmt this file
+    - id: conftest-fmt
+      files: examples/kubernetes/deployment.yaml
+    - id: conftest-pull
+      args:
+        - --policy
+        - ./pulled-policies
+        - git::https://github.com/open-policy-agent/conftest//examples/kubernetes/policy
 EOF
 
     # Add and commit files
@@ -68,3 +76,17 @@ teardown_file() {
     run pre-commit run conftest-verify
     [ "$status" -eq 0 ]
 }
+
+@test "pre-commit: verify fmt hook changes a policy file" {
+    cd "$TEST_REPO"
+    run pre-commit run conftest-fmt --files examples/kubernetes/deployment.yaml
+    [ "$status" -ne 0 ]
+}
+
+@test "pre-commit: pull hook downloads policies successfully" {
+  cd "$TEST_REPO"
+  run pre-commit run conftest-pull
+  [ "$status" -eq 0 ]
+}
+
+
diff --git a/tests/pull-absolute-paths/.gitignore b/tests/pull-absolute-paths/.gitignore
new file mode 100644
index 0000000..186857b
--- /dev/null
+++ b/tests/pull-absolute-paths/.gitignore
@@ -0,0 +1 @@
+var
diff --git a/tests/source-location/data.yaml b/tests/source-location/data.yaml
new file mode 100644
index 0000000..354c068
--- /dev/null
+++ b/tests/source-location/data.yaml
@@ -0,0 +1,3 @@
+loc:
+  file: test.txt
+  line: 123
diff --git a/tests/source-location/policy/main.rego b/tests/source-location/policy/main.rego
new file mode 100644
index 0000000..def09e6
--- /dev/null
+++ b/tests/source-location/policy/main.rego
@@ -0,0 +1,7 @@
+package main
+
+deny contains {
+    "msg": "test",
+    "other": "metadata",
+    "_loc": input.loc
+}
diff --git a/tests/source-location/test.bats b/tests/source-location/test.bats
new file mode 100644
index 0000000..2beb602
--- /dev/null
+++ b/tests/source-location/test.bats
@@ -0,0 +1,9 @@
+#!/usr/bin/env bats
+
+@test "Location is included in results" {
+  run $CONFTEST test -o json data.yaml
+
+  echo $output
+  [[ "$output" =~ "\"file\": \"test.txt\"" ]]
+  [[ "$output" =~ "\"line\": 123" ]]
+}