Full Code of jakebark/tag-nag for AI

main fc7e0da543e8 cached
79 files
133.5 KB
41.3k tokens
118 symbols
1 requests
Download .txt
Repository: jakebark/tag-nag
Branch: main
Commit: fc7e0da543e8
Files: 79
Total size: 133.5 KB

Directory structure:
gitextract_b5nvfihg/

├── .github/
│   ├── dependabot.yml
│   └── workflows/
│       ├── docker-hub.yml
│       ├── go_tests.yml
│       └── semgrep.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── examples/
│   ├── .tag-nag.yml
│   ├── codebuild.yml
│   ├── github.yml
│   └── gitlab.yml
├── go.mod
├── go.sum
├── internal/
│   ├── cloudformation/
│   │   ├── helpers.go
│   │   ├── helpers_test.go
│   │   ├── process.go
│   │   ├── resources.go
│   │   ├── resources_test.go
│   │   ├── scan.go
│   │   ├── spec_loader.go
│   │   └── spec_loader_test.go
│   ├── config/
│   │   └── config.go
│   ├── inputs/
│   │   ├── inputs.go
│   │   ├── inputs_test.go
│   │   ├── loader.go
│   │   └── loader_test.go
│   ├── output/
│   │   ├── formatter.go
│   │   ├── formatter_test.go
│   │   ├── json.go
│   │   ├── json_test.go
│   │   ├── junit.go
│   │   ├── junit_test.go
│   │   ├── process.go
│   │   ├── sarif.go
│   │   ├── sarif_test.go
│   │   ├── text.go
│   │   └── text_test.go
│   ├── shared/
│   │   ├── helpers.go
│   │   ├── helpers_test.go
│   │   └── types.go
│   └── terraform/
│       ├── default_tags.go
│       ├── default_tags_test.go
│       ├── helpers.go
│       ├── helpers_test.go
│       ├── process.go
│       ├── references.go
│       ├── resources.go
│       ├── resources_test.go
│       ├── scan.go
│       ├── scan_test.go
│       └── types.go
├── main.go
├── main_test.go
└── testdata/
    ├── cloudformation/
    │   ├── tags.json
    │   ├── tags.yaml
    │   └── tags.yml
    ├── config/
    │   ├── blank_config.yml
    │   ├── empty_settings.yml
    │   ├── full_config.yml
    │   ├── invalid_structure.yml
    │   ├── invalid_syntax.yml
    │   ├── missing_tags.yml
    │   ├── tag_array.yml
    │   ├── tag_keys.yml
    │   ├── tag_values.yml
    │   └── yaml_extension.yaml
    └── terraform/
        ├── example_repo/
        │   ├── locals.tf
        │   ├── main.tf
        │   ├── provider.tf
        │   └── variables.tf
        ├── functions.tf
        ├── ignore.tf
        ├── ignore_all.tf
        ├── no_tags.tf
        ├── provider.tf
        ├── referenced_tags.tf
        ├── referenced_values.tf
        └── tags.tf

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
  - package-ecosystem: "gomod"
    directory: "/"
    schedule:
      interval: "monthly"
    open-pull-requests-limit: 5
    labels:
      - "dependencies"
    groups:
      golang-x:
        patterns: ["golang.org/x/*"]
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "monthly"
    open-pull-requests-limit: 5
    labels:
      - "dependencies"


================================================
FILE: .github/workflows/docker-hub.yml
================================================
name: publish to docker hub

on:
  release:
    types: [published]

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Check out the repo
        uses: actions/checkout@v6

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: jakebark/tag-nag

      - name: Build and push Docker image
        id: push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            TERRAFORM_VERSION=${{ vars.TERRAFORM_VERSION }}



================================================
FILE: .github/workflows/go_tests.yml
================================================
name: go test

on:
  pull_request:
    branches: [ main ] 

jobs:
  test: 
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
        go-version: ['1.21', '1.22', '1.23', '1.24']

    steps:
      - name: checkout code
        uses: actions/checkout@v6 

      - name: setup go
        uses: actions/setup-go@v5 
        with:
          go-version: ${{ matrix.go-version }} 
          cache: true 

      - name: run go tests
        run: go test -v ./... 


================================================
FILE: .github/workflows/semgrep.yml
================================================
name: semgrep

on:
  pull_request:
    branches: [ main ] 

jobs:
  semgrep:
    runs-on: ubuntu-latest
    
    container:
      image: semgrep/semgrep

    steps:
      - name: checkout code
        uses: actions/checkout@v6

      - name: run semgrep
        run: semgrep ci


================================================
FILE: .gitignore
================================================
*.exe
*.exe~
*.dll
*.so
*.dylib

*.test
**/test/*
todo.md

# Output of the go coverage tool, specifically when used with LiteIDE
*.out

# Dependency directories (remove the comment below to include it)
# vendor/

# Go workspace file
go.work
go.work.sum

# env file
.env

.DS_Store


================================================
FILE: Dockerfile
================================================
FROM golang:1.22 AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download 
RUN go mod tidy

COPY . .

RUN go build -o tag-nag

FROM debian:stable-slim

ARG TERRAFORM_VERSION

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    wget \
    unzip \
    ca-certificates \
    git && \
    rm -rf /var/lib/apt/lists/*

RUN wget "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" && \
    unzip "terraform_${TERRAFORM_VERSION}_linux_amd64.zip" -d /usr/local/bin && \
    rm "terraform_${TERRAFORM_VERSION}_linux_amd64.zip" && \
    chmod +x /usr/local/bin/terraform

RUN groupadd -r appgroup && \
    useradd --no-log-init -r -g appgroup appuser

COPY --from=builder /app/tag-nag /usr/local/bin/tag-nag

USER appuser

WORKDIR /workspace

ENTRYPOINT ["tag-nag"]

CMD ["--help"]


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2025 jakebark

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# tag-nag

<img src="./img/demo.gif" width="650">

Validate AWS tags in Terraform and CloudFormation.  

## Installation
```bash
go install github.com/jakebark/tag-nag@latest
```
You may need to set [GOPATH](https://go.dev/wiki/SettingGOPATH).

## Commands

Tag-nag will search a file or directory for tag keys. Directory search is recursive.

```bash
tag-nag <file/directory> --tags "Key1,Key2"

tag-nag main.tf --tags "Owner" # run against a file
tag-nag ./my_project --tags "Owner,Environment" # run against a directory
tag-nag . --tags "Owner", "Environment" # will take string or list

```

Search for tag keys *and* tag values

```bash
tag-nag <file/directory> --tags "Key[Value]"

tag-nag main.tf --tags "Owner[Jake]" 
tag-nag main.tf --tags "Owner[Jake],Environment" # mixed search possible
tag-nag main.tf --tags "Owner[Jake],Environment[Dev,Prod]" # multiple options for tag values

```

Flags
```bash
-c --case-insensitive  
-d --dry-run # will always exit successfully
--cfn-spec ~/path/to/CloudFormationResourceSpecification.json # path to Cfn spec file, filters taggable resources
-s --skip "file.tf, path/to/directory" # skip files and directories
-o --output json # output to json (default is text)
```

## Config file

The above commands can be issued with a `.tag-nag.yml` file in the same directory where tag-nag is run. 

See the [example .tag-nag.yml file](./examples/.tag-nag.yml).  

## Skip Checks

Skip file
```hcl
#tag-nag ignore-all
```

Terraform
```hcl
resource "aws_s3_bucket" "this" {
  #tag-nag ignore
  bucket   = "that"
}
```

CloudFormation
```yaml
EC2Instance:  #tag-nag ignore
    Type: "AWS::EC2::Instance"
    Properties: 
      ImageId: ami-12a34b
      InstanceType: c1.xlarge   
```

## Filtering taggable resources

Some AWS resources cannot be tagged. 

To filter out these resources with Terraform, run tag-nag against an initialised directory (`terraform init`).

To filter out these resources with CloudFormation, specify a path to the [CloudFormation JSON spec file](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-resource-specification.html) with the `--cfn-spec` input. 

## Docker
Run
```bash
docker pull jakebark/tag-nag:latest
docker run --rm -v $(pwd):/workspace -w /workspace jakebark/tag-nag \
  . --tags "Owner,Environment" 

```

Interactive shell
```bash
docker pull jakebark/tag-nag:latest
docker run -it --rm \
  -v "$(pwd)":/workspace \
  -w /workspace \
  --entrypoint /bin/sh jakebark/tag-nag:latest
```

The image contains terraform, allowing `terraform init` to be run, if required.  
```bash
docker pull jakebark/tag-nag:latest
docker run --rm -v $(pwd):/workspace -w /workspace \
  --entrypoint /bin/sh jakebark/tag-nag:latest \
  -c "terraform init -input=false -no-color && tag-nag\
     . --tags 'Owner,Environment'"
```

## CI/CD

Example CI files:
- [GitHub](./examples/github.yml)
- [GitLab](./examples/gitlab.yml)
- [AWS CodeBuild](./examples/codebuild.yml)

## Related Resources

- [pkg.go.dev/github.com/jakebark/tag-nag](https://pkg.go.dev/github.com/jakebark/tag-nag)

<div align="center">
<img alt="tag:nag" height="150" src="./img/tag.png" />
</div>


================================================
FILE: examples/.tag-nag.yml
================================================
tags:
  - key: Owner
  - key: Environment
    values: [Dev, Test, Prod]
  - key: Project

settings:
  case_insensitive: false
  dry_run: false
  cfn_spec: "~/path/to/CloudFormationResourceSpecification.json" # remove if not using 

skip:
  - file.tf
  - "test-data/**"    # keep quotes for wildcard/glob pattern
  - "*.tmp"
  - .terraform
  - .git




================================================
FILE: examples/codebuild.yml
================================================
## codebuild image 'jakebar/tag-nag:$latest'
version: 0.2

phases:
  build:
    commands:
      - cd "$CODEBUILD_SRC_DIR"
      - terraform init -backend=false # remove for CloudFormation
      - tag-nag . --tags "tags"


================================================
FILE: examples/github.yml
================================================
name: tag-nag

on:
  pull_request:
    branches: [main]

jobs:
  tag-nag:
    runs-on: ubuntu-latest
    
    container:
      image: jakebark/tag-nag:latest

    steps:
      - name: checkout code
        uses: actions/checkout@v4

      - name: run terraform init # remove for CloudFormation
        run: terraform init -backend=false
         
      - name: run tag-nag
        run: tag-nag . --tags "tags"


================================================
FILE: examples/gitlab.yml
================================================
stages:
  - validate

tag-nag:
  stage: validate
  image: jakebark/tag-nag:latest
  script:
    - terraform init -backend=false # remove for CloudFormation
    - tag-nag . --tags "tags"


================================================
FILE: go.mod
================================================
module github.com/jakebark/tag-nag

go 1.22

require (
	github.com/hashicorp/hcl/v2 v2.23.0
	github.com/spf13/pflag v1.0.6
	github.com/zclconf/go-cty v1.13.0
	gopkg.in/yaml.v3 v3.0.1
)

require (
	github.com/agext/levenshtein v1.2.1 // indirect
	github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
	github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
	github.com/google/go-cmp v0.6.0 // indirect
	github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
	golang.org/x/mod v0.8.0 // indirect
	golang.org/x/sys v0.5.0 // indirect
	golang.org/x/text v0.11.0 // indirect
	golang.org/x/tools v0.6.0 // indirect
)


================================================
FILE: go.sum
================================================
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos=
github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0=
github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0=
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=


================================================
FILE: internal/cloudformation/helpers.go
================================================
package cloudformation

import (
	"os"
	"strings"

	"github.com/jakebark/tag-nag/internal/config"
	"gopkg.in/yaml.v3"
)

// mapNodes converts a yaml mapping node into a go map
func mapNodes(node *yaml.Node) map[string]*yaml.Node {
	m := make(map[string]*yaml.Node)
	if node == nil || node.Kind != yaml.MappingNode {
		return m
	}
	for i := 0; i < len(node.Content); i += 2 {
		keyNode := node.Content[i]
		valueNode := node.Content[i+1]
		m[keyNode.Value] = valueNode
	}
	return m
}

// findMapNode parses a yaml block and returns the value, when given the key
func findMapNode(node *yaml.Node, key string) *yaml.Node {
	if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
		node = node.Content[0]
	}
	if node.Kind != yaml.MappingNode {
		return nil
	}
	for i := 0; i < len(node.Content); i += 2 {
		k := node.Content[i]
		v := node.Content[i+1]
		if k.Value == key {
			return v
		}
	}
	return nil
}

// parseYAML unmarshal yaml and return a pointer to the root of the node
func parseYAML(filePath string) (*yaml.Node, error) {
	data, err := os.ReadFile(filePath)
	if err != nil {
		return nil, err
	}

	var root yaml.Node
	if err := yaml.Unmarshal(data, &root); err != nil {
		return nil, err
	}

	if root.Kind == yaml.DocumentNode && len(root.Content) > 0 {
		root = *root.Content[0]
	}
	return &root, nil
}

func skipResource(node *yaml.Node, lines []string) bool {
	index := node.Line - 2
	if index < len(lines) {
		if strings.Contains(lines[index], config.TagNagIgnore) {
			return true
		}
	}
	return false
}


================================================
FILE: internal/cloudformation/helpers_test.go
================================================
package cloudformation

import (
	"testing"

	"gopkg.in/yaml.v3"
)

// helper to create a yaml.Node for testing
func createYamlNode(t *testing.T, yamlStr string) *yaml.Node {
	t.Helper()
	var node yaml.Node
	err := yaml.Unmarshal([]byte(yamlStr), &node)
	if err != nil {
		t.Fatalf("Failed to unmarshal test YAML: %v\nYAML:\n%s", err, yamlStr)
	}
	if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
		return node.Content[0]
	}
	return &node
}

func testMapNodes(t *testing.T) {
	tests := []struct {
		name         string
		inputYAML    string
		expectedKeys []string
	}{
		{
			name:         "simple map",
			inputYAML:    `Key1: Value1\nKey2: 123`,
			expectedKeys: []string{"Key1", "Key2"},
		},
		{
			name:         "nested map",
			inputYAML:    `Key1: Value1\nKey2:\n  Nested1: NestedValue`,
			expectedKeys: []string{"Key1", "Key2"},
		},
		{
			name:         "empty map",
			inputYAML:    `{}`,
			expectedKeys: []string{},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			node := createYamlNode(t, tc.inputYAML)
			mapped := mapNodes(node)
			gotKeys := make([]string, 0, len(mapped))
			for k := range mapped {
				gotKeys = append(gotKeys, k)
			}
			if len(gotKeys) != len(tc.expectedKeys) {
				t.Errorf("mapNodes() returned map with %d keys, expected %d. Got keys: %v", len(gotKeys), len(tc.expectedKeys), gotKeys)
				return
			}
		})
	}
}

func testFindMapNode(t *testing.T) {
	yamlContent := `
        RootKey: RootValue
        Map1:
          NestedKey1: NestedValue1
          NestedKey2: 123
        Map2: {}
        List1:
          - itemA
          - itemB
`
	rootNode := createYamlNode(t, yamlContent)

	tests := []struct {
		name          string
		node          *yaml.Node
		key           string
		expectedNil   bool
		expectedKind  yaml.Kind
		expectedValue string
	}{
		{
			name:          "root key to root value",
			node:          rootNode,
			key:           "RootKey",
			expectedNil:   false,
			expectedKind:  yaml.ScalarNode,
			expectedValue: "RootValue",
		},
		{
			name:         "map key to map values",
			node:         rootNode,
			key:          "Map1",
			expectedNil:  false,
			expectedKind: yaml.MappingNode,
		},
		{
			name:         "map key to empty map value",
			node:         rootNode,
			key:          "Map2",
			expectedNil:  false,
			expectedKind: yaml.MappingNode,
		},
		{
			name:         "list key to list values",
			node:         rootNode,
			key:          "List1",
			expectedNil:  false,
			expectedKind: yaml.SequenceNode,
		},
		{
			name:        "missing key",
			node:        rootNode,
			key:         "MissingKey",
			expectedNil: true,
		},
		{
			name:        "search other node",
			node:        findMapNode(rootNode, "List1"),
			key:         "itemA",
			expectedNil: true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got := findMapNode(tc.node, tc.key)
			isNil := got == nil
			if isNil != tc.expectedNil {
				t.Fatalf("findMapNode(key=%q) returned nil? %t, expectedNil %t", tc.key, isNil, tc.expectedNil)
			}
			if !tc.expectedNil {
				if got.Kind != tc.expectedKind {
					t.Errorf("findMapNode(key=%q) returned node kind %v, expected %v", tc.key, got.Kind, tc.expectedKind)
				}
				if tc.expectedKind == yaml.ScalarNode && got.Value != tc.expectedValue {
					t.Errorf("findMapNode(key=%q) returned scalar value %q, expected %q", tc.key, got.Value, tc.expectedValue)
				}
			}
		})
	}
}



================================================
FILE: internal/cloudformation/process.go
================================================
package cloudformation

import (
	"fmt"
	"log"
	"os"
	"path/filepath"
	"slices"
	"strings"

	"github.com/jakebark/tag-nag/internal/config"
	"github.com/jakebark/tag-nag/internal/shared"
)

// ProcessDirectory walks all cfn files in a directory, then returns violations
func ProcessDirectory(directoryPath string, requiredTags map[string][]string, caseInsensitive bool, specFilePath string, skip []string) []shared.Violation {
	hasFiles, err := scan(directoryPath)
	if err != nil {
		return nil
	}
	if !hasFiles {
		return nil
	}

	// log.Println("\nCloudFormation files found")
	var allViolations []shared.Violation

	var taggable map[string]bool
	if specFilePath != "" {
		var loadSpecErr error
		taggable, loadSpecErr = loadTaggableResourcesFromSpec(specFilePath)
		if loadSpecErr != nil {
			log.Printf("Warning: Could not load or parse --cfn-spec file '%s': %v.", specFilePath, loadSpecErr)
			taggable = nil
		} else {
			// log.Println("Parsing CloudFormation spec file.")
		}
	}

	walkErr := filepath.Walk(directoryPath, func(path string, info os.FileInfo, walkErr error) error {
		if walkErr != nil {
			log.Printf("Error accessing %q: %v\n", path, walkErr)
			return walkErr
		}

		for _, skipped := range skip {
			if strings.HasPrefix(path, skipped) {
				if info.IsDir() {
					return filepath.SkipDir
				}
				return nil
			}
		}
		if info.IsDir() {
			dirName := info.Name()
			if slices.Contains(config.SkippedDirs, dirName) {
				return filepath.SkipDir
			}
		}

		if !info.IsDir() && (filepath.Ext(path) == ".yaml" || filepath.Ext(path) == ".yml" || filepath.Ext(path) == ".json") {
			violations, processErr := processFile(path, requiredTags, caseInsensitive, taggable)
			if processErr != nil {
				log.Printf("Error processing file %s: %v\n", path, processErr)
				return nil // Example: Continue walking
			}
			allViolations = append(allViolations, violations...)
		}
		return nil
	})
	if walkErr != nil {
		log.Printf("Error scanning directory %s: %v\n", directoryPath, walkErr)
	}
	return allViolations
}

// processFile parses files and maps the cfn nodes
func processFile(filePath string, requiredTags shared.TagMap, caseInsensitive bool, taggable map[string]bool) ([]shared.Violation, error) {
	data, err := os.ReadFile(filePath)
	if err != nil {
		log.Printf("Error reading %s: %v\n", filePath, err)
		return nil, fmt.Errorf("reading file %s: %w", filePath, err)
	}
	content := string(data)
	lines := strings.Split(content, "\n")

	skipAll := strings.Contains(content, config.TagNagIgnoreAll)

	root, err := parseYAML(filePath)
	if err != nil {
		return nil, fmt.Errorf("parsing file %s: %w", filePath, err)
	}

	// search root node for resources node
	resourcesMapping := mapNodes(findMapNode(root, "Resources"))
	if resourcesMapping == nil {
		log.Printf("No 'Resources' section found in %s\n", filePath)
		return []shared.Violation{}, nil
	}

	violations := checkResourcesForTags(resourcesMapping, requiredTags, caseInsensitive, lines, skipAll, taggable, filePath)
	return violations, nil
}


================================================
FILE: internal/cloudformation/resources.go
================================================
package cloudformation

import (
	"fmt"
	"log"
	"strings"

	"github.com/jakebark/tag-nag/internal/shared"
	"gopkg.in/yaml.v3"
)

// getResourceViolations inspects resource blocks and returns violations
func checkResourcesForTags(resourcesMapping map[string]*yaml.Node, requiredTags shared.TagMap, caseInsensitive bool, fileLines []string, skipAll bool, taggable map[string]bool, filePath string) []shared.Violation {
	var violations []shared.Violation

	for resourceName, resourceNode := range resourcesMapping { // resourceNode == yaml node for resource
		resourceMapping := mapNodes(resourceNode)

		typeNode, ok := resourceMapping["Type"]
		if !ok || !strings.HasPrefix(typeNode.Value, "AWS::") {
			continue
		}
		resourceType := typeNode.Value

		if taggable != nil {
			isTaggable, found := taggable[resourceType]
			if found && !isTaggable {
				continue
			}
		}

		properties := make(map[string]any) // tags are part of the properties  node
		if propsNode, ok := resourceMapping["Properties"]; ok {
			_ = propsNode.Decode(&properties)
		}

		tags, err := extractTagMap(properties, caseInsensitive)
		if err != nil {
			log.Printf("Error extracting tags from resource %s: %v\n", resourceName, err)
			continue
		}

		missing := shared.FilterMissingTags(requiredTags, tags, caseInsensitive)
		if len(missing) > 0 {
			violation := shared.Violation{
				ResourceName: resourceName,
				ResourceType: resourceType,
				Line:         resourceNode.Line,
				MissingTags:  missing,
				FilePath:     filePath,
			}
			// if file-level or resource-level ignore is found
			if skipAll || skipResource(resourceNode, fileLines) {
				violation.Skip = true
			}
			violations = append(violations, violation)
		}
	}
	return violations
}

// extractTagMap extracts a yaml/json map to a go map
func extractTagMap(properties map[string]any, caseInsensitive bool) (shared.TagMap, error) {
	tagsMap := make(shared.TagMap)
	literalTags, exists := properties["Tags"]
	if !exists {
		return tagsMap, nil
	}

	tagsList, ok := literalTags.([]any)
	if !ok {
		return tagsMap, fmt.Errorf("Tags format is invalid") // tags are not in a list
	}

	for _, tagInterface := range tagsList {
		tagEntry, ok := tagInterface.(map[string]any)
		if !ok {
			continue
		}
		key, ok := tagEntry["Key"].(string)
		if !ok {
			continue
		}
		var tagValue string
		if valStr, ok := tagEntry["Value"].(string); ok {
			tagValue = valStr
		} else if refMap, ok := tagEntry["Value"].(map[string]any); ok {
			if ref, exists := refMap["Ref"]; exists {
				if refStr, ok := ref.(string); ok {
					tagValue = fmt.Sprintf("!Ref %s", refStr)
				}
			}
		}
		key = shared.NormalizeCase(key, caseInsensitive)
		tagsMap[key] = []string{tagValue}
	}
	return tagsMap, nil
}


================================================
FILE: internal/cloudformation/resources_test.go
================================================
package cloudformation

import (
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/jakebark/tag-nag/internal/shared"
)

func TestExtractTagMap(t *testing.T) {
	tests := []struct {
		name            string
		properties      map[string]any
		caseInsensitive bool
		expected        shared.TagMap
		expectedErr     bool
	}{
		{
			name: "no tag key",
			properties: map[string]any{
				"OtherKey": "Value"},
			expected: shared.TagMap{},
		},
		{
			name: "empty list",
			properties: map[string]any{
				"Tags": []any{},
			},
			expected: shared.TagMap{},
		},
		{
			name: "not a list",
			properties: map[string]any{
				"Tags": map[string]string{"Key": "Value"},
			},
			expectedErr: true,
		},
		{
			name: "literal tags",
			properties: map[string]any{
				"Tags": []any{
					map[string]any{"Key": "Owner", "Value": "Jake"},
					map[string]any{"Key": "Env", "Value": "Dev"},
				},
			},
			expected: shared.TagMap{
				"Owner": []string{"Jake"},
				"Env":   []string{"Dev"},
			},
		},
		// {
		// 	name: "referenced tags",
		// 	properties: map[string]any{
		// 		"Tags": []any{
		// 			map[string]any{"Key": "StackName", "Value": map[string]any{"Ref": "AWS::StackName"}},
		// 		},
		// 	},
		// 	expected: shared.TagMap{
		// 		"StackName": []string{"!Ref StackName"},
		// 	},
		// },
		// {
		// 	name: "mixed tags, literal and referenced",
		// 	properties: map[string]any{
		// 		"Tags": []any{
		// 			map[string]any{"Key": "Owner", "Value": "Jake"},
		// 			map[string]any{"Key": "StackName", "Value": map[string]any{"Ref": "AWS::StackName"}},
		// 		},
		// 	},
		// 	expected: shared.TagMap{
		// 		"Owner":     []string{"Jake"},
		// 		"StackName": []string{"!Ref StackName"},
		// 	},
		// },
		{
			name: "literal tags, case insensitive",
			properties: map[string]any{
				"Tags": []any{
					map[string]any{"Key": "Owner", "Value": "Jake"},
					map[string]any{"Key": "env", "Value": "Dev"},
				},
			},
			caseInsensitive: true,
			expected: shared.TagMap{
				"owner": []string{"Jake"},
				"env":   []string{"Dev"},
			},
		},
		{
			name: "missing key",
			properties: map[string]any{
				"Tags": []any{
					map[string]any{"Value": "Jake"},
				},
			},
			expected: shared.TagMap{},
		},
		{
			name: "missing value",
			properties: map[string]any{
				"Tags": []any{
					map[string]any{"Key": "OptionalTag"},
				},
			},
			expected: shared.TagMap{
				"OptionalTag": []string{""},
			},
		},
		{
			name: "non-string",
			properties: map[string]any{
				"Tags": []any{
					map[string]any{"Key": 123, "Value": "Jake"},
				},
			},
			expected: shared.TagMap{},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := extractTagMap(tc.properties, tc.caseInsensitive)
			if (err != nil) != tc.expectedErr {
				t.Fatalf("extractTagMap() error = %v, expectedErr %v", err, tc.expectedErr)
			}
			if !tc.expectedErr {
				if diff := cmp.Diff(tc.expected, got); diff != "" {
					t.Errorf("extractTagMap() mismatch (-expected +got):\n%s", diff)
				}
			}
		})
	}
}


================================================
FILE: internal/cloudformation/scan.go
================================================
package cloudformation

import (
	"errors"
	"io/fs"
	"path/filepath"
)

// scan looks for cfn files
func scan(directoryPath string) (bool, error) {
	found := false
	targetExts := map[string]bool{
		".yaml": true,
		".yml":  true,
		".json": true,
	}
	walkErr := filepath.WalkDir(directoryPath, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return nil
		}

		if !d.IsDir() {
			if targetExts[filepath.Ext(path)] {
				found = true
				return fs.ErrNotExist // stop scan immediately
			}
		}
		return nil
	})

	if walkErr != nil && !errors.Is(walkErr, fs.ErrNotExist) {
		return false, walkErr
	}
	return found, nil
}


================================================
FILE: internal/cloudformation/spec_loader.go
================================================
package cloudformation

import (
	"encoding/json"
	"fmt"
	"log"
	"os"
	"strings"
)

type cfnSpec struct {
	ResourceTypes map[string]cfnResourceType `json:"ResourceTypes"`
	PropertyTypes map[string]cfnPropertyType `json:"PropertyTypes"`
}

type cfnResourceType struct {
	Properties map[string]cfnProperty `json:"Properties"`
}

type cfnProperty struct {
	Required          bool   `json:"Required"`
	Type              string `json:"Type"`     // list
	ItemType          string `json:"ItemType"` // tag
	PrimitiveType     string `json:"PrimitiveType"`
	PrimitiveItemType string `json:"PrimitiveItemType"`
	// Other fields ignored
}

type cfnPropertyType struct {
	Properties map[string]cfnProperty `json:"Properties"`
}

// isTaggable checks the cfn spec file to see if a resource can be tagged
func (rt cfnResourceType) isTaggable(specData *cfnSpec) bool {
	tagsProp, ok := rt.Properties["Tags"]
	if !ok {
		return false
	}
	if tagsProp.Type == "List" && tagsProp.ItemType == "Tag" {
		tagTypeDef, tagTypeExists := specData.PropertyTypes["Tag"]
		if tagTypeExists {
			_, keyExists := tagTypeDef.Properties["Key"]
			_, valueExists := tagTypeDef.Properties["Value"]
			return keyExists && valueExists
		}
	}
	return false
}

// LoadTaggableResourcesFromSpec parses the provided spec file path
func loadTaggableResourcesFromSpec(specFilePath string) (map[string]bool, error) {
	log.Printf("Attempting to load CloudFormation specification from: %s", specFilePath)
	data, err := os.ReadFile(specFilePath)
	if err != nil {
		return nil, fmt.Errorf("failed to read CloudFormation spec file '%s': %w", specFilePath, err)
	}

	var specData cfnSpec
	err = json.Unmarshal(data, &specData)
	if err != nil {
		return nil, fmt.Errorf("failed to parse CloudFormation spec JSON from '%s': %w", specFilePath, err)
	}

	if specData.ResourceTypes == nil {
		return nil, fmt.Errorf("invalid CloudFormation spec format: missing 'ResourceTypes' in '%s'", specFilePath)
	}
	if specData.PropertyTypes == nil {
		return nil, fmt.Errorf("invalid CloudFormation spec format: missing 'PropertyTypes' in '%s'", specFilePath)
	}

	taggableMap := make(map[string]bool)
	for resourceName, resourceDef := range specData.ResourceTypes {
		if strings.HasPrefix(resourceName, "AWS::") {
			taggableMap[resourceName] = resourceDef.isTaggable(&specData)
		}
	}

	log.Printf("Loaded %d AWS resource types.", len(taggableMap))
	return taggableMap, nil
}


================================================
FILE: internal/cloudformation/spec_loader_test.go
================================================
package cloudformation

import (
	"encoding/json"
	"testing"
)

// helper to create a test spec file
func createTestSpec() *cfnSpec {
	specJSON := `{
		"PropertyTypes": {
			"Tag": {
				"Properties": {
					"Key": { "PrimitiveType": "String", "Required": true },
					"Value": { "PrimitiveType": "String", "Required": true }
				}
			},
			"OtherType": {
				"Properties": { "Name": { "PrimitiveType": "String" } }
			}
		},
		"ResourceTypes": {}
	}`
	var spec cfnSpec
	if err := json.Unmarshal([]byte(specJSON), &spec); err != nil {
		panic("Failed to unmarshal base test spec data: " + err.Error())
	}
	return &spec
}

func TestIsTaggable(t *testing.T) {
	baseSpec := createTestSpec()

	tests := []struct {
		name         string
		resourceJSON string
		want         bool
	}{
		{
			name: "taggable",
			resourceJSON: `{
				"Properties": {
					"Name": { "PrimitiveType": "String" },
					"Tags": { "Type": "List", "ItemType": "Tag", "Required": false }
				}
			}`,
			want: true,
		},
		{
			name: "not taggable",
			resourceJSON: `{
				"Properties": {
					"Name": { "PrimitiveType": "String" }
				}
			}`,
			want: false,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			var resourceDef cfnResourceType
			if err := json.Unmarshal([]byte(tc.resourceJSON), &resourceDef); err != nil {
				t.Fatalf("Failed to unmarshal test resource JSON: %v", err)
			}

			if got := resourceDef.isTaggable(baseSpec); got != tc.want {
				t.Errorf("isTaggable() = %v, want %v", got, tc.want)
			}
		})
	}
}


================================================
FILE: internal/config/config.go
================================================
package config

import (
	"github.com/zclconf/go-cty/cty/function"
	"github.com/zclconf/go-cty/cty/function/stdlib"
)

const (
	TagNagIgnore       = "#tag-nag ignore"
	TagNagIgnoreAll    = "#tag-nag ignore-all"
	DefaultConfigFile  = ".tag-nag.yml"
	AltConfigFile      = ".tag-nag.yaml"
)

var SkippedDirs = []string{
	".terraform",
	".git",
}

// terraform functions, used when evaluating context of locals and vars
// added manually, no reasonable workaround to auto-import all
// https://developer.hashicorp.com/terraform/language/functions
// https://pkg.go.dev/github.com/zclconf/go-cty@v1.16.2/cty/function/stdlib
var StdlibFuncs = map[string]function.Function{
	"abs":          stdlib.AbsoluteFunc,
	"ceil":         stdlib.CeilFunc,
	"chomp":        stdlib.ChompFunc,
	"chunklist":    stdlib.ChunklistFunc,
	"coalesce":     stdlib.CoalesceFunc,
	"coalescelist": stdlib.CoalesceListFunc,
	"compact":      stdlib.CompactFunc,
	"concat":       stdlib.ConcatFunc,
	"contains":     stdlib.ContainsFunc,
	"csvdecode":    stdlib.CSVDecodeFunc,
	"distinct":     stdlib.DistinctFunc,
	"element":      stdlib.ElementFunc,
	"flatten":      stdlib.FlattenFunc,
	"floor":        stdlib.FloorFunc,
	"format":       stdlib.FormatFunc,
	"formatdate":   stdlib.FormatDateFunc,
	"formatlist":   stdlib.FormatListFunc,
	"indent":       stdlib.IndentFunc,
	"index":        stdlib.IndexFunc,
	"int":          stdlib.IntFunc,
	"join":         stdlib.JoinFunc,
	"jsondecode":   stdlib.JSONDecodeFunc,
	"jsonencode":   stdlib.JSONEncodeFunc,
	"keys":         stdlib.KeysFunc,
	"length":       stdlib.LengthFunc,
	"log":          stdlib.LogFunc,
	"lookup":       stdlib.LookupFunc,
	"lower":        stdlib.LowerFunc,
	"max":          stdlib.MaxFunc,
	"merge":        stdlib.MergeFunc,
	"min":          stdlib.MinFunc,
	"parseint":     stdlib.ParseIntFunc,
	"pow":          stdlib.PowFunc,
	"range":        stdlib.RangeFunc,
	"regex":        stdlib.RegexFunc,
	"regexall":     stdlib.RegexAllFunc,
	"regexreplace": stdlib.RegexReplaceFunc,
	"replace":      stdlib.ReplaceFunc,
	"reverse":      stdlib.ReverseFunc,
	"reverselist":  stdlib.ReverseListFunc,
	"setunion":     stdlib.SetUnionFunc,
	"slice":        stdlib.SliceFunc,
	"sort":         stdlib.SortFunc,
	"split":        stdlib.SplitFunc,
	"trim":         stdlib.TrimFunc,
	"trimprefix":   stdlib.TrimPrefixFunc,
	"trimspace":    stdlib.TrimSpaceFunc,
	"trimsuffix":   stdlib.TrimSuffixFunc,
	"upper":        stdlib.UpperFunc,
	"values":       stdlib.ValuesFunc,
}


================================================
FILE: internal/inputs/inputs.go
================================================
package inputs

import (
	"fmt"
	"log"
	"os"
	"strings"

	"github.com/jakebark/tag-nag/internal/shared"
	"github.com/spf13/pflag"
)

type UserInput struct {
	Directory       string
	RequiredTags    shared.TagMap
	CaseInsensitive bool
	DryRun          bool
	CfnSpecPath     string
	Skip            []string
	OutputFormat    shared.OutputFormat
}

// ParseFlags returns pased CLI flags and arguments
func ParseFlags() UserInput {
	var caseInsensitive bool
	var dryRun bool
	var tags string
	var cfnSpecPath string
	var skip string
	var outputFormat string

	pflag.BoolVarP(&caseInsensitive, "case-insensitive", "c", false, "Make tag checks non-case-sensitive")
	pflag.BoolVarP(&dryRun, "dry-run", "d", false, "Dry run tag:nag without triggering exit(1) code")
	pflag.StringVar(&tags, "tags", "", "Comma-separated list of required tag keys (e.g., 'Owner,Environment[Dev,Prod]')")
	pflag.StringVar(&cfnSpecPath, "cfn-spec", "", "Optional path to CloudFormationResourceSpecification.json)")
	pflag.StringVarP(&skip, "skip", "s", "", "Comma-separated list of files or directories to skip")
	pflag.StringVarP(&outputFormat, "output", "o", "text", "Output format: text, json, junit-xml, or sarif")
	pflag.Parse()

	if pflag.NArg() < 1 {
		log.Fatal("Error: specify a directory or file to scan")
	}

	// try config file if no tags provided
	if tags == "" {
		configFile, err := FindAndLoadConfigFile()
		if err != nil {
			log.Fatalf("Error loading config: %v", err)
		}
		if configFile != nil {
			// Use config output format if CLI wasn't specified
			configOutputFormat := shared.OutputFormat(outputFormat)
			outputFlag := pflag.Lookup("output")
			if !outputFlag.Changed && configFile.Settings.Output != "" {
				configOutputFormat = configFile.Settings.Output
			}
			return UserInput{
				Directory:       pflag.Arg(0),
				RequiredTags:    configFile.convertToTagMap(),
				CaseInsensitive: configFile.Settings.CaseInsensitive,
				DryRun:          configFile.Settings.DryRun,
				CfnSpecPath:     configFile.Settings.CfnSpec,
				Skip:            configFile.Skip,
				OutputFormat:    configOutputFormat,
			}
		}
		log.Fatal("Error: specify required tags using --tags or create a .tag-nag.yml config file")
	}

	parsedTags, err := parseTags(tags)
	if err != nil {
		log.Fatalf("Error parsing tags: %v", err)
	}

	var skipPaths []string
	if skip != "" {
		skipPaths = strings.Split(skip, ",")
		for i := range skipPaths {
			skipPaths[i] = strings.TrimSpace(skipPaths[i])
		}
	}

	// Try to load config file for output format default
	configFile, err := FindAndLoadConfigFile()
	if err != nil && !os.IsNotExist(err) {
		log.Fatalf("Error loading config: %v", err)
	}

	format := shared.OutputFormat(outputFormat)
	// Use config output format if CLI wasn't explicitly provided and config exists
	outputFlag := pflag.Lookup("output")
	if !outputFlag.Changed && configFile != nil && configFile.Settings.Output != "" {
		format = configFile.Settings.Output
	}

	if format != shared.OutputFormatText && format != shared.OutputFormatJSON && format != shared.OutputFormatJUnitXML && format != shared.OutputFormatSARIF {
		log.Fatalf("Invalid output format '%s'. Supported formats: text, json, junit-xml, sarif", outputFormat)
	}

	return UserInput{
		Directory:       pflag.Arg(0),
		RequiredTags:    parsedTags,
		CaseInsensitive: caseInsensitive,
		DryRun:          dryRun,
		CfnSpecPath:     cfnSpecPath,
		Skip:            skipPaths,
		OutputFormat:    format,
	}
}

// parses tag input components
func parseTags(input string) (shared.TagMap, error) {
	tagMap := make(shared.TagMap)
	pairs := splitTags(input)
	for _, pair := range pairs {
		trimmed := strings.TrimSpace(pair)
		if trimmed == "" {
			continue
		}

		key, values, err := parseTag(trimmed)
		if err != nil {
			return nil, fmt.Errorf("failed to parse tag component '%s': %w", trimmed, err)
		}
		tagMap[key] = values
	}
	return tagMap, nil
}

// parses tag keys and values
func parseTag(tagComponent string) (key string, values []string, err error) {
	trimmed := strings.TrimSpace(tagComponent)
	if trimmed == "" {
		return "", nil, fmt.Errorf("empty tag component")
	}

	// key and value
	openBracketIdx := strings.Index(trimmed, "[")
	if openBracketIdx != -1 {
		if !strings.HasSuffix(trimmed, "]") {
			return "", nil, fmt.Errorf("invalid tag format: %s. Expected closing ']'", trimmed)
		}

		key = strings.TrimSpace(trimmed[:openBracketIdx])
		if key == "" {
			return "", nil, fmt.Errorf("empty key in bracket format: %s", trimmed)
		}

		valuesStr := trimmed[openBracketIdx+1 : len(trimmed)-1]
		if valuesStr == "" {
			return key, []string{}, nil
		}

		valParts := strings.Split(valuesStr, ",")
		for _, v := range valParts {
			trimmedVal := strings.TrimSpace(v)
			if trimmedVal != "" {
				values = append(values, trimmedVal)
			}
		}
		return key, values, nil
	}

	// key only
	if strings.Contains(trimmed, "[") || strings.Contains(trimmed, "]") {
		return "", nil, fmt.Errorf("invalid tag format: %s. Contains '[' or ']' without matching pair or value definition", trimmed)
	}
	return trimmed, []string{}, nil
}

// splitTags splits the input string on commas outside of brackets
// to fix the [a,b,c] issue
func splitTags(input string) []string {
	var parts []string
	start := 0
	depth := 0
	for i, r := range input {
		switch r {
		case '[':
			depth++
		case ']':
			if depth > 0 {
				depth--
			}
		case ',':
			if depth == 0 {
				parts = append(parts, strings.TrimSpace(input[start:i]))
				start = i + 1
			}
		}
	}
	parts = append(parts, strings.TrimSpace(input[start:]))
	return parts
}


================================================
FILE: internal/inputs/inputs_test.go
================================================
package inputs

import (
	"reflect"
	"testing"

	"github.com/jakebark/tag-nag/internal/shared"
)

func TestParseTags(t *testing.T) {
	testCases := []struct {
		name          string
		input         string
		expected      shared.TagMap
		expectedError bool
	}{
		{
			name:  "key",
			input: "Owner",
			expected: shared.TagMap{
				"Owner": {},
			},
			expectedError: false,
		},
		{
			name:  "multiple keys",
			input: "Owner, Environment , Project",
			expected: shared.TagMap{
				"Owner":       {},
				"Environment": {},
				"Project":     {},
			},
			expectedError: false,
		},
		{
			name:  "mixed keys and values",
			input: "Owner[jake], Environment[Dev,Prod], CostCenter",
			expected: shared.TagMap{
				"Owner":       {"jake"},
				"Environment": {"Dev", "Prod"},
				"CostCenter":  {},
			},
			expectedError: false,
		},
		{
			name:          "empty",
			input:         "",
			expected:      shared.TagMap{},
			expectedError: false,
		},
		{
			name:          "whitespace",
			input:         "  ,   ",
			expected:      shared.TagMap{},
			expectedError: false,
		},
		{
			name:  "mixed keys and values, with whitespace",
			input: " Owner ,  Environment[Dev, Prod] ",
			expected: shared.TagMap{
				"Owner":       {},
				"Environment": {"Dev", "Prod"},
			},
			expectedError: false,
		},
		{
			name:  "leading comma",
			input: ",Owner",
			expected: shared.TagMap{
				"Owner": {},
			},
			expectedError: false,
		},
		{
			name:  "missing value",
			input: "Env[]",
			expected: shared.TagMap{
				"Env": {}, // No values extracted
			},
			expectedError: false,
		},
		{
			name:  "missing value, other values present",
			input: "Env[Dev,,Prod]",
			expected: shared.TagMap{
				"Env": {"Dev", "Prod"},
			},
			expectedError: false,
		},
		{
			name:  "whitespace preserved",
			input: "Owner[it belongs to me]",
			expected: shared.TagMap{
				"Owner": {"it belongs to me"},
			},
			expectedError: false,
		},
		{
			name:          "unclosed bracket",
			input:         "invalid[value",
			expected:      nil,
			expectedError: true,
		},
		{
			name:          "no key",
			input:         "[value]",
			expected:      nil,
			expectedError: true,
		},
		{
			name:          "stray bracket",
			input:         "stray]",
			expected:      nil,
			expectedError: true,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			actual, err := parseTags(tc.input)
			if tc.expectedError {
				if err == nil {
					t.Errorf("parseTags(%q) expected an error, but got nil", tc.input)
				}
				return
			}
			if err != nil {
				t.Errorf("parseTags(%q) expected no error, but got: %v", tc.input, err)
			}
			if !reflect.DeepEqual(actual, tc.expected) {
				t.Errorf("parseTags(%q) = %v; want %v", tc.input, actual, tc.expected)
			}
		})
	}
}

func TestSplitTags(t *testing.T) {
	testCases := []struct {
		name     string
		input    string
		expected []string
	}{
		{"empty", "", []string{""}},
		{"key", "Owner", []string{"Owner"}},
		{"multiple keys", "Owner, Env , Project", []string{"Owner", "Env", "Project"}},
		{"value", "Owner[Jake]", []string{"Owner[Jake]"}},
		{"multiple values", "Env[Dev,Prod]", []string{"Env[Dev,Prod]"}},
		{"mixed keys and values", "Owner[Jake], Env[Dev,Prod], CostCenter", []string{"Owner[Jake]", "Env[Dev,Prod]", "CostCenter"}},
		{"trailing comma", "Owner,Env,", []string{"Owner", "Env", ""}},
		{"leading comma", ",Owner,Env", []string{"", "Owner", "Env"}},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			actual := splitTags(tc.input)
			if !reflect.DeepEqual(actual, tc.expected) {
				t.Errorf("splitTags(%q) = %v; want %v", tc.input, actual, tc.expected)
			}
		})
	}
}


================================================
FILE: internal/inputs/loader.go
================================================
package inputs

import (
	"fmt"
	"os"

	"github.com/jakebark/tag-nag/internal/config"
	"github.com/jakebark/tag-nag/internal/shared"
	"gopkg.in/yaml.v3"
)

type Config struct {
	Tags     []TagDefinition `yaml:"tags"`
	Settings Settings        `yaml:"settings"`
	Skip     []string        `yaml:"skip"`
}

type TagDefinition struct {
	Key    string   `yaml:"key"`
	Values []string `yaml:"values,omitempty"`
}

type Settings struct {
	CaseInsensitive bool                `yaml:"case_insensitive"`
	DryRun          bool                `yaml:"dry_run"`
	CfnSpec         string              `yaml:"cfn_spec"`
	Output          shared.OutputFormat `yaml:"output"`
}

// FindAndLoadConfigFile attempts to find and load configuration file
func FindAndLoadConfigFile() (*Config, error) {
	if _, err := os.Stat(config.DefaultConfigFile); err == nil {
		return processConfigFile(config.DefaultConfigFile)
	}

	if _, err := os.Stat(config.AltConfigFile); err == nil {
		return processConfigFile(config.AltConfigFile)
	}

	return nil, nil
}

// processConfigFile reads the config file
func processConfigFile(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("reading config file %s: %w", path, err)
	}

	var config Config
	if err := yaml.Unmarshal(data, &config); err != nil {
		return nil, fmt.Errorf("parsing config file %s: %w", path, err)
	}

	return &config, nil
}

// ConvertToTagMap converts config tags to internal TagMap format
func (c *Config) convertToTagMap() shared.TagMap {
	tagMap := make(shared.TagMap)

	for _, tag := range c.Tags {
		tagMap[tag.Key] = tag.Values
	}

	return tagMap
}


================================================
FILE: internal/inputs/loader_test.go
================================================
package inputs

import (
	"os"
	"reflect"
	"testing"

	"github.com/jakebark/tag-nag/internal/shared"
)

func TestProcessConfigFile(t *testing.T) {
	testCases := []struct {
		name              string
		configFile        string
		expectedError     bool
		expectedTags      int
		expectedOwner     bool
		expectedEnvValues []string
		expectedSettings  Settings
		expectedSkips     []string
	}{
		{
			name:          "tag keys",
			configFile:    "../../testdata/config/tag_keys.yml",
			expectedError: false,
			expectedTags:  2,
			expectedOwner: true,
			expectedSettings: Settings{
				CaseInsensitive: false,
				DryRun:          false,
				CfnSpec:         "",
			},
			expectedSkips: []string{},
		},
		{
			name:              "tag values",
			configFile:        "../../testdata/config/tag_values.yml",
			expectedError:     false,
			expectedTags:      3,
			expectedOwner:     true,
			expectedEnvValues: []string{"Dev", "Test", "Prod"},
			expectedSettings: Settings{
				CaseInsensitive: false,
				DryRun:          false,
				CfnSpec:         "",
			},
			expectedSkips: []string{},
		},
		{
			name:          "full config",
			configFile:    "../../testdata/config/full_config.yml",
			expectedError: false,
			expectedTags:  3,
			expectedOwner: true,
			expectedSettings: Settings{
				CaseInsensitive: true,
				DryRun:          false,
				CfnSpec:         "/path/to/spec.json",
			},
			expectedSkips: []string{"*.tmp", ".terraform", "test-data/**"},
		},
		{
			name:          "empty settings",
			configFile:    "../../testdata/config/empty_settings.yml",
			expectedError: false,
			expectedTags:  1,
			expectedOwner: true,
			expectedSettings: Settings{
				CaseInsensitive: false,
				DryRun:          false,
				CfnSpec:         "",
			},
			expectedSkips: []string{},
		},
		{
			name:          "yaml extension",
			configFile:    "../../testdata/config/yaml_extension.yaml",
			expectedError: false,
			expectedTags:  1,
			expectedOwner: true,
			expectedSettings: Settings{
				CaseInsensitive: false,
				DryRun:          false,
				CfnSpec:         "",
			},
			expectedSkips: []string{},
		},
		{
			name:          "blank config",
			configFile:    "../../testdata/config/blank_config.yml",
			expectedError: false,
			expectedTags:  0,
			expectedOwner: false,
			expectedSettings: Settings{
				CaseInsensitive: false,
				DryRun:          false,
				CfnSpec:         "",
			},
			expectedSkips: []string{},
		},
		{
			name:          "invalid syntax",
			configFile:    "../../testdata/config/invalid_syntax.yml",
			expectedError: true,
		},
		{
			name:          "missing tags",
			configFile:    "../../testdata/config/missing_tags.yml",
			expectedError: false,
			expectedTags:  0,
			expectedOwner: false,
			expectedSettings: Settings{
				CaseInsensitive: false,
				DryRun:          true,
				CfnSpec:         "",
			},
			expectedSkips: []string{"*.tmp"},
		},
		{
			name:          "invalid structure",
			configFile:    "../../testdata/config/invalid_structure.yml",
			expectedError: true,
		},
		{
			name:          "invalid tag value array",
			configFile:    "../../testdata/config/tag_array.yml",
			expectedError: true,
		},
		{
			name:          "no file",
			configFile:    "../../testdata/config/does-not-exist.yml",
			expectedError: true,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			config, err := processConfigFile(tc.configFile)

			// Check error expectation
			if tc.expectedError {
				if err == nil {
					t.Errorf("Expected error but got none")
				}
				return
			}

			if err != nil {
				t.Errorf("Unexpected error: %v", err)
				return
			}

			if config == nil {
				t.Errorf("Expected config but got nil")
				return
			}

			// Check number of tags
			if len(config.Tags) != tc.expectedTags {
				t.Errorf("Expected %d tags, got %d", tc.expectedTags, len(config.Tags))
			}

			// Check if Owner tag exists
			hasOwner := false
			var envValues []string
			for _, tag := range config.Tags {
				if tag.Key == "Owner" {
					hasOwner = true
				}
				if tag.Key == "Environment" {
					envValues = tag.Values
				}
			}

			if hasOwner != tc.expectedOwner {
				t.Errorf("Expected Owner tag: %v, got: %v", tc.expectedOwner, hasOwner)
			}

			// Check Environment values if specified
			if tc.expectedEnvValues != nil {
				if !reflect.DeepEqual(envValues, tc.expectedEnvValues) {
					t.Errorf("Expected Environment values %v, got %v", tc.expectedEnvValues, envValues)
				}
			}

			// Check settings
			if config.Settings != tc.expectedSettings {
				t.Errorf("Expected settings %+v, got %+v", tc.expectedSettings, config.Settings)
			}

			// Check skip patterns (handle nil vs empty slice)
			configSkips := config.Skip
			if configSkips == nil {
				configSkips = []string{}
			}
			if !reflect.DeepEqual(configSkips, tc.expectedSkips) {
				t.Errorf("Expected skip patterns %v, got %v", tc.expectedSkips, configSkips)
			}
		})
	}
}

func TestFindAndLoadConfigFile(t *testing.T) {
	// Save current directory
	originalDir, err := os.Getwd()
	if err != nil {
		t.Fatalf("Failed to get current directory: %v", err)
	}
	defer os.Chdir(originalDir)

	testCases := []struct {
		name           string
		setupFunc      func(t *testing.T, tempDir string)
		expectedError  bool
		expectedConfig bool
		expectedTags   int
	}{
		{
			name: "no config files present",
			setupFunc: func(t *testing.T, tempDir string) {
				// Empty directory
			},
			expectedError:  false,
			expectedConfig: false,
		},
		{
			name: ".tag-nag.yml present",
			setupFunc: func(t *testing.T, tempDir string) {
				content := "tags:\n  - key: Owner\n"
				err := os.WriteFile(".tag-nag.yml", []byte(content), 0644)
				if err != nil {
					t.Fatalf("Failed to create test config: %v", err)
				}
			},
			expectedError:  false,
			expectedConfig: true,
			expectedTags:   1,
		},
		{
			name: ".tag-nag.yaml present",
			setupFunc: func(t *testing.T, tempDir string) {
				content := "tags:\n  - key: Project\n"
				err := os.WriteFile(".tag-nag.yaml", []byte(content), 0644)
				if err != nil {
					t.Fatalf("Failed to create test config: %v", err)
				}
			},
			expectedError:  false,
			expectedConfig: true,
			expectedTags:   1,
		},
		{
			name: "both files present (should prefer .yml)",
			setupFunc: func(t *testing.T, tempDir string) {
				ymlContent := "tags:\n  - key: Owner\n  - key: Project\n"
				yamlContent := "tags:\n  - key: Environment\n"

				err := os.WriteFile(".tag-nag.yml", []byte(ymlContent), 0644)
				if err != nil {
					t.Fatalf("Failed to create .yml config: %v", err)
				}

				err = os.WriteFile(".tag-nag.yaml", []byte(yamlContent), 0644)
				if err != nil {
					t.Fatalf("Failed to create .yaml config: %v", err)
				}
			},
			expectedError:  false,
			expectedConfig: true,
			expectedTags:   2, // Should use .yml file (2 tags), not .yaml file (1 tag)
		},
		{
			name: "invalid config file",
			setupFunc: func(t *testing.T, tempDir string) {
				content := "tags:\n  - key: Owner\n    values: [invalid"
				err := os.WriteFile(".tag-nag.yml", []byte(content), 0644)
				if err != nil {
					t.Fatalf("Failed to create invalid config: %v", err)
				}
			},
			expectedError: true,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			// Create temporary directory for each test
			tempDir, err := os.MkdirTemp("", "tag-nag-test")
			if err != nil {
				t.Fatalf("Failed to create temp dir: %v", err)
			}
			defer os.RemoveAll(tempDir)

			// Change to temp directory
			err = os.Chdir(tempDir)
			if err != nil {
				t.Fatalf("Failed to change to temp dir: %v", err)
			}

			// Setup test scenario
			tc.setupFunc(t, tempDir)

			// Test the function
			config, err := FindAndLoadConfigFile()

			// Check error expectation
			if tc.expectedError {
				if err == nil {
					t.Errorf("Expected error but got none")
				}
				return
			}

			if err != nil {
				t.Errorf("Unexpected error: %v", err)
				return
			}

			// Check config presence
			if tc.expectedConfig {
				if config == nil {
					t.Errorf("Expected config but got nil")
					return
				}
				if len(config.Tags) != tc.expectedTags {
					t.Errorf("Expected %d tags, got %d", tc.expectedTags, len(config.Tags))
				}
			} else {
				if config != nil {
					t.Errorf("Expected no config but got: %+v", config)
				}
			}
		})
	}
}

func TestConvertToTagMap(t *testing.T) {
	testCases := []struct {
		name     string
		config   Config
		expected shared.TagMap
	}{
		{
			name: "empty config",
			config: Config{
				Tags: []TagDefinition{},
			},
			expected: shared.TagMap{},
		},
		{
			name: "tags without values",
			config: Config{
				Tags: []TagDefinition{
					{Key: "Owner"},
					{Key: "Project"},
				},
			},
			expected: shared.TagMap{
				"Owner":   []string{},
				"Project": []string{},
			},
		},
		{
			name: "tags with values",
			config: Config{
				Tags: []TagDefinition{
					{Key: "Owner"},
					{Key: "Environment", Values: []string{"Dev", "Test", "Prod"}},
					{Key: "Project"},
				},
			},
			expected: shared.TagMap{
				"Owner":       []string{},
				"Environment": []string{"Dev", "Test", "Prod"},
				"Project":     []string{},
			},
		},
		{
			name: "mixed values",
			config: Config{
				Tags: []TagDefinition{
					{Key: "Owner", Values: []string{"Alice", "Bob"}},
					{Key: "Environment", Values: []string{"Prod"}},
					{Key: "Project"},
				},
			},
			expected: shared.TagMap{
				"Owner":       []string{"Alice", "Bob"},
				"Environment": []string{"Prod"},
				"Project":     []string{},
			},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			result := tc.config.convertToTagMap()

			// Check each key individually to handle nil vs empty slice differences
			if len(result) != len(tc.expected) {
				t.Errorf("Expected %d keys, got %d", len(tc.expected), len(result))
				return
			}

			for key, expectedValues := range tc.expected {
				actualValues, exists := result[key]
				if !exists {
					t.Errorf("Expected key %s not found in result", key)
					continue
				}

				// Handle nil vs empty slice
				if actualValues == nil {
					actualValues = []string{}
				}
				if expectedValues == nil {
					expectedValues = []string{}
				}

				if !reflect.DeepEqual(actualValues, expectedValues) {
					t.Errorf("Key %s: expected %v, got %v", key, expectedValues, actualValues)
				}
			}
		})
	}
}


================================================
FILE: internal/output/formatter.go
================================================
package output

import "github.com/jakebark/tag-nag/internal/shared"

type Formatter interface {
	Format(violations []shared.Violation) ([]byte, error)
}

// GetFormatter returns the appropriate formatter for the given format
func GetFormatter(format shared.OutputFormat) Formatter {
	switch format {
	case shared.OutputFormatJSON:
		return &JSONFormatter{}
	case shared.OutputFormatJUnitXML:
		return &JUnitXMLFormatter{}
	case shared.OutputFormatSARIF:
		return &SARIFFormatter{}
	case shared.OutputFormatText:
		fallthrough
	default:
		return &TextFormatter{}
	}
}



================================================
FILE: internal/output/formatter_test.go
================================================
package output

import (
	"reflect"
	"testing"

	"github.com/jakebark/tag-nag/internal/shared"
)

func TestGetFormatter(t *testing.T) {
	testCases := []struct {
		name         string
		format       shared.OutputFormat
		expectedType string
	}{
		{
			name:         "json format",
			format:       shared.OutputFormatJSON,
			expectedType: "*output.JSONFormatter",
		},
		{
			name:         "junit-xml format",
			format:       shared.OutputFormatJUnitXML,
			expectedType: "*output.JUnitXMLFormatter",
		},
		{
			name:         "sarif format",
			format:       shared.OutputFormatSARIF,
			expectedType: "*output.SARIFFormatter",
		},
		{
			name:         "text format",
			format:       shared.OutputFormatText,
			expectedType: "*output.TextFormatter",
		},
		{
			name:         "unknown format defaults to text",
			format:       shared.OutputFormat("unknown"),
			expectedType: "*output.TextFormatter",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			formatter := GetFormatter(tc.format)
			actualType := reflect.TypeOf(formatter).String()
			if actualType != tc.expectedType {
				t.Errorf("GetFormatter(%q) = %s; want %s", tc.format, actualType, tc.expectedType)
			}
		})
	}
}



================================================
FILE: internal/output/json.go
================================================
package output

import (
	"encoding/json"

	"github.com/jakebark/tag-nag/internal/shared"
)

// JSONFormatter implements JSON output format
type JSONFormatter struct{}

// JSONOutput represents the structured JSON output format
type JSONOutput struct {
	Violations []shared.Violation `json:"violations"`
	Summary    Summary            `json:"summary"`
}

// Summary provides aggregate information about violations
type Summary struct {
	Total         int `json:"total"`
	Skipped       int `json:"skipped"`
	FilesAffected int `json:"files_affected"`
}

// Format formats violations as JSON
func (f *JSONFormatter) Format(violations []shared.Violation) ([]byte, error) {
	var skipped int
	files := make(map[string]bool)

	for _, v := range violations {
		if v.Skip {
			skipped++
		}
		files[v.FilePath] = true
	}

	output := JSONOutput{
		Violations: violations,
		Summary: Summary{
			Total:         len(violations),
			Skipped:       skipped,
			FilesAffected: len(files),
		},
	}

	return json.MarshalIndent(output, "", "  ")
}



================================================
FILE: internal/output/json_test.go
================================================
package output

import (
	"encoding/json"
	"testing"

	"github.com/jakebark/tag-nag/internal/shared"
)

func TestJSONFormatter_Format(t *testing.T) {
	testCases := []struct {
		name       string
		violations []shared.Violation
		wantJSON   bool
		wantFields []string
	}{
		{
			name:       "empty violations",
			violations: []shared.Violation{},
			wantJSON:   true,
			wantFields: []string{"violations", "summary"},
		},
		{
			name: "single violation",
			violations: []shared.Violation{
				{
					ResourceType: "aws_s3_bucket",
					ResourceName: "test",
					MissingTags:  []string{"Owner"},
					FilePath:     "main.tf",
					Line:         10,
				},
			},
			wantJSON:   true,
			wantFields: []string{"violations", "summary"},
		},
		{
			name: "multiple violations",
			violations: []shared.Violation{
				{ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}},
				{ResourceType: "aws_instance", ResourceName: "test2", MissingTags: []string{"Env"}},
			},
			wantJSON:   true,
			wantFields: []string{"violations", "summary"},
		},
		{
			name: "skipped violation",
			violations: []shared.Violation{
				{ResourceType: "aws_s3_bucket", ResourceName: "test", Skip: true},
			},
			wantJSON:   true,
			wantFields: []string{"violations", "summary"},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			formatter := &JSONFormatter{}
			output, err := formatter.Format(tc.violations)

			if err != nil {
				t.Errorf("Format() error = %v", err)
				return
			}

			if tc.wantJSON {
				var parsed map[string]interface{}
				if err := json.Unmarshal(output, &parsed); err != nil {
					t.Errorf("Output is not valid JSON: %v", err)
				}

				for _, field := range tc.wantFields {
					if _, exists := parsed[field]; !exists {
						t.Errorf("Missing field %q in JSON output", field)
					}
				}
			}
		})
	}
}

================================================
FILE: internal/output/junit.go
================================================
package output

import (
	"encoding/xml"
	"fmt"
	"strings"

	"github.com/jakebark/tag-nag/internal/shared"
)

type JUnitXMLFormatter struct{}

type TestSuite struct {
	XMLName   xml.Name   `xml:"testsuite"`
	Name      string     `xml:"name,attr"`
	Tests     int        `xml:"tests,attr"`
	Failures  int        `xml:"failures,attr"`
	TestCases []TestCase `xml:"testcase"`
}

type TestCase struct {
	XMLName   xml.Name `xml:"testcase"`
	Name      string   `xml:"name,attr"`
	ClassName string   `xml:"classname,attr"`
	Failure   *Failure `xml:"failure,omitempty"`
}

type Failure struct {
	XMLName xml.Name `xml:"failure"`
	Message string   `xml:"message,attr"`
}

// Format formats violations as JUnit XML
func (f *JUnitXMLFormatter) Format(violations []shared.Violation) ([]byte, error) {
	var testCases []TestCase
	failures := 0

	for _, v := range violations {
		testCase := TestCase{
			Name:      fmt.Sprintf("%s.%s", v.ResourceType, v.ResourceName),
			ClassName: v.FilePath,
		}

		if !v.Skip {
			failures++
			testCase.Failure = &Failure{
				Message: fmt.Sprintf("Missing tags: %s", strings.Join(v.MissingTags, ", ")),
			}
		}

		testCases = append(testCases, testCase)
	}

	testSuite := TestSuite{
		Name:      "tag-nag",
		Tests:     len(violations),
		Failures:  failures,
		TestCases: testCases,
	}

	output, err := xml.MarshalIndent(testSuite, "", "  ")
	if err != nil {
		return nil, err
	}

	return []byte(xml.Header + string(output)), nil
}



================================================
FILE: internal/output/junit_test.go
================================================
package output

import (
	"encoding/xml"
	"strings"
	"testing"

	"github.com/jakebark/tag-nag/internal/shared"
)

func TestJUnitXMLFormatter_Format(t *testing.T) {
	testCases := []struct {
		name         string
		violations   []shared.Violation
		wantXML      bool
		wantTests    int
		wantFailures int
	}{
		{
			name:         "empty violations",
			violations:   []shared.Violation{},
			wantXML:      true,
			wantTests:    0,
			wantFailures: 0,
		},
		{
			name: "single violation",
			violations: []shared.Violation{
				{
					ResourceType: "aws_s3_bucket",
					ResourceName: "test",
					MissingTags:  []string{"Owner"},
					FilePath:     "main.tf",
					Line:         10,
				},
			},
			wantXML:      true,
			wantTests:    1,
			wantFailures: 1,
		},
		{
			name: "multiple violations",
			violations: []shared.Violation{
				{ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}},
				{ResourceType: "aws_instance", ResourceName: "test2", MissingTags: []string{"Env"}},
			},
			wantXML:      true,
			wantTests:    2,
			wantFailures: 2,
		},
		{
			name: "skipped violation",
			violations: []shared.Violation{
				{ResourceType: "aws_s3_bucket", ResourceName: "test", Skip: true},
			},
			wantXML:      true,
			wantTests:    1,
			wantFailures: 0,
		},
		{
			name: "mixed violations",
			violations: []shared.Violation{
				{ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}},
				{ResourceType: "aws_instance", ResourceName: "test2", Skip: true},
			},
			wantXML:      true,
			wantTests:    2,
			wantFailures: 1,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			formatter := &JUnitXMLFormatter{}
			output, err := formatter.Format(tc.violations)

			if err != nil {
				t.Errorf("Format() error = %v", err)
				return
			}

			if tc.wantXML {
				var testSuite TestSuite
				if err := xml.Unmarshal(output, &testSuite); err != nil {
					t.Errorf("Output is not valid XML: %v", err)
				}

				if testSuite.Tests != tc.wantTests {
					t.Errorf("Tests count = %d; want %d", testSuite.Tests, tc.wantTests)
				}

				if testSuite.Failures != tc.wantFailures {
					t.Errorf("Failures count = %d; want %d", testSuite.Failures, tc.wantFailures)
				}

				if !strings.Contains(string(output), "<?xml version") {
					t.Errorf("Output missing XML declaration")
				}
			}
		})
	}
}

================================================
FILE: internal/output/process.go
================================================
package output

import (
	"fmt"
	"log"
	"os"

	"github.com/jakebark/tag-nag/internal/shared"
)

// ProcessOutput handles the output formatting and exit logic
func ProcessOutput(violations []shared.Violation, format shared.OutputFormat, dryRun bool) {
	formatter := GetFormatter(format)
	formattedOutput, err := formatter.Format(violations)
	if err != nil {
		log.Fatalf("Error formatting output: %v", err)
	}

	if len(formattedOutput) > 0 {
		fmt.Print(string(formattedOutput))
	}

	nonSkippedCount := 0
	for _, v := range violations {
		if !v.Skip {
			nonSkippedCount++
		}
	}

	if nonSkippedCount > 0 && dryRun {
		log.Printf("\033[32mFound %d tag violation(s)\033[0m\n", nonSkippedCount)
		os.Exit(0)
	} else if nonSkippedCount > 0 {
		log.Printf("\033[31mFound %d tag violation(s)\033[0m\n", nonSkippedCount)
		os.Exit(1)
	} else {
		log.Println("No tag violations found")
		os.Exit(0)
	}
}



================================================
FILE: internal/output/sarif.go
================================================
package output

import (
	"encoding/json"
	"fmt"
	"strings"

	"github.com/jakebark/tag-nag/internal/shared"
)

type SARIFFormatter struct{}

type sarifOutput struct {
	Schema  string     `json:"$schema"`
	Version string     `json:"version"`
	Runs    []sarifRun `json:"runs"`
}

type sarifRun struct {
	Tool struct {
		Driver struct {
			Name           string `json:"name"`
			InformationURI string `json:"informationUri"`
		} `json:"driver"`
	} `json:"tool"`
	Results []sarifResult `json:"results"`
}

type sarifResult struct {
	RuleID  string `json:"ruleId"`
	Kind    string `json:"kind"`
	Level   string `json:"level,omitempty"`
	Message struct {
		Text string `json:"text"`
	} `json:"message"`
	Locations []sarifLocation `json:"locations"`
}

type sarifLocation struct {
	PhysicalLocation struct {
		ArtifactLocation struct {
			URI string `json:"uri"`
		} `json:"artifactLocation"`
		Region struct {
			StartLine int `json:"startLine"`
		} `json:"region"`
	} `json:"physicalLocation"`
}

// Format formats violations as SARIF v2.1.0
func (f *SARIFFormatter) Format(violations []shared.Violation) ([]byte, error) {
	var results []sarifResult

	for _, v := range violations {
		r := sarifResult{RuleID: "missing-tags"}
		r.Message.Text = fmt.Sprintf("%s %q is missing tags: %s",
			v.ResourceType, v.ResourceName, strings.Join(v.MissingTags, ", "))

		if v.Skip {
			r.Kind = "notApplicable"
		} else {
			r.Kind = "fail"
			r.Level = "error"
		}

		loc := sarifLocation{}
		loc.PhysicalLocation.ArtifactLocation.URI = v.FilePath
		loc.PhysicalLocation.Region.StartLine = v.Line
		r.Locations = []sarifLocation{loc}

		results = append(results, r)
	}

	run := sarifRun{Results: results}
	run.Tool.Driver.Name = "tag-nag"
	run.Tool.Driver.InformationURI = "https://github.com/jakebark/tag-nag"

	output := sarifOutput{
		Schema:  "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
		Version: "2.1.0",
		Runs:    []sarifRun{run},
	}

	return json.MarshalIndent(output, "", "  ")
}


================================================
FILE: internal/output/sarif_test.go
================================================
package output

import (
	"encoding/json"
	"testing"

	"github.com/jakebark/tag-nag/internal/shared"
)

func TestSARIFFormatter_Format(t *testing.T) {
	testCases := []struct {
		name         string
		violations   []shared.Violation
		wantResults  int
		wantFailures int
	}{
		{
			name:         "empty violations",
			violations:   []shared.Violation{},
			wantResults:  0,
			wantFailures: 0,
		},
		{
			name: "single violation",
			violations: []shared.Violation{
				{
					ResourceType: "aws_s3_bucket",
					ResourceName: "test",
					MissingTags:  []string{"Owner"},
					FilePath:     "main.tf",
					Line:         10,
				},
			},
			wantResults:  1,
			wantFailures: 1,
		},
		{
			name: "multiple violations",
			violations: []shared.Violation{
				{ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 1},
				{ResourceType: "aws_instance", ResourceName: "test2", MissingTags: []string{"Env"}, FilePath: "main.tf", Line: 20},
			},
			wantResults:  2,
			wantFailures: 2,
		},
		{
			name: "skipped violation",
			violations: []shared.Violation{
				{ResourceType: "aws_s3_bucket", ResourceName: "test", Skip: true, FilePath: "main.tf", Line: 1},
			},
			wantResults:  1,
			wantFailures: 0,
		},
		{
			name: "mixed violations",
			violations: []shared.Violation{
				{ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 1},
				{ResourceType: "aws_instance", ResourceName: "test2", Skip: true, FilePath: "main.tf", Line: 10},
			},
			wantResults:  2,
			wantFailures: 1,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			formatter := &SARIFFormatter{}
			output, err := formatter.Format(tc.violations)

			if err != nil {
				t.Errorf("Format() error = %v", err)
				return
			}

			var parsed sarifOutput
			if err := json.Unmarshal(output, &parsed); err != nil {
				t.Errorf("Output is not valid JSON: %v", err)
				return
			}

			if parsed.Version != "2.1.0" {
				t.Errorf("Version = %q; want %q", parsed.Version, "2.1.0")
			}

			if len(parsed.Runs) != 1 {
				t.Fatalf("Runs count = %d; want 1", len(parsed.Runs))
			}

			results := parsed.Runs[0].Results
			if len(results) != tc.wantResults {
				t.Errorf("Results count = %d; want %d", len(results), tc.wantResults)
			}

			failures := 0
			for _, r := range results {
				if r.Kind == "fail" {
					failures++
				}
			}
			if failures != tc.wantFailures {
				t.Errorf("Failure count = %d; want %d", failures, tc.wantFailures)
			}
		})
	}
}


================================================
FILE: internal/output/text.go
================================================
package output

import (
	"fmt"
	"strings"

	"github.com/jakebark/tag-nag/internal/shared"
)

type TextFormatter struct{}

// Format formats violations as human-readable text
func (f *TextFormatter) Format(violations []shared.Violation) ([]byte, error) {
	var output strings.Builder

	fileGroups := make(map[string][]shared.Violation)
	for _, v := range violations {
		fileGroups[v.FilePath] = append(fileGroups[v.FilePath], v)
	}

	for filePath, fileViolations := range fileGroups {
		output.WriteString(fmt.Sprintf("\nViolation(s) in %s\n", filePath))

		for _, v := range fileViolations {
			if v.Skip {
				output.WriteString(fmt.Sprintf("  %d: %s \"%s\" skipped\n",
					v.Line, v.ResourceType, v.ResourceName))
			} else {
				output.WriteString(fmt.Sprintf("  %d: %s \"%s\" 🏷️  Missing tags: %s\n",
					v.Line, v.ResourceType, v.ResourceName, strings.Join(v.MissingTags, ", ")))
			}
		}
	}

	return []byte(output.String()), nil
}


================================================
FILE: internal/output/text_test.go
================================================
package output

import (
	"strings"
	"testing"

	"github.com/jakebark/tag-nag/internal/shared"
)

func TestTextFormatter_Format(t *testing.T) {
	testCases := []struct {
		name           string
		violations     []shared.Violation
		wantContains   []string
		wantNotContains []string
	}{
		{
			name:       "empty violations",
			violations: []shared.Violation{},
			wantContains: []string{},
		},
		{
			name: "single violation",
			violations: []shared.Violation{
				{
					ResourceType: "aws_s3_bucket",
					ResourceName: "test",
					MissingTags:  []string{"Owner"},
					FilePath:     "main.tf",
					Line:         10,
				},
			},
			wantContains: []string{
				"Violation(s) in main.tf",
				"10: aws_s3_bucket \"test\"",
				"Missing tags: Owner",
			},
		},
		{
			name: "multiple violations same file",
			violations: []shared.Violation{
				{ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 5},
				{ResourceType: "aws_instance", ResourceName: "test2", MissingTags: []string{"Env"}, FilePath: "main.tf", Line: 15},
			},
			wantContains: []string{
				"Violation(s) in main.tf",
				"5: aws_s3_bucket \"test1\"",
				"15: aws_instance \"test2\"",
				"Missing tags: Owner",
				"Missing tags: Env",
			},
		},
		{
			name: "skipped violation",
			violations: []shared.Violation{
				{ResourceType: "aws_s3_bucket", ResourceName: "test", FilePath: "main.tf", Line: 10, Skip: true},
			},
			wantContains: []string{
				"Violation(s) in main.tf",
				"10: aws_s3_bucket \"test\" skipped",
			},
			wantNotContains: []string{
				"Missing tags:",
			},
		},
		{
			name: "mixed violations",
			violations: []shared.Violation{
				{ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 5},
				{ResourceType: "aws_instance", ResourceName: "test2", FilePath: "main.tf", Line: 15, Skip: true},
			},
			wantContains: []string{
				"Violation(s) in main.tf",
				"5: aws_s3_bucket \"test1\"",
				"Missing tags: Owner",
				"15: aws_instance \"test2\" skipped",
			},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			formatter := &TextFormatter{}
			output, err := formatter.Format(tc.violations)

			if err != nil {
				t.Errorf("Format() error = %v", err)
				return
			}

			outputStr := string(output)

			for _, want := range tc.wantContains {
				if !strings.Contains(outputStr, want) {
					t.Errorf("Output missing %q", want)
				}
			}

			for _, notWant := range tc.wantNotContains {
				if strings.Contains(outputStr, notWant) {
					t.Errorf("Output should not contain %q", notWant)
				}
			}
		})
	}
}

================================================
FILE: internal/shared/helpers.go
================================================
package shared

import (
	"fmt"
	"sort"
	"strings"
)

// FilterMissingTags checks effectiveTags against requiredTags
func FilterMissingTags(requiredTags TagMap, effectiveTags TagMap, caseInsensitive bool) []string {
	var missingTags []string

	for requiredKey, allowedValues := range requiredTags {
		effectiveValues, keyFound := matchTagKey(requiredKey, effectiveTags, caseInsensitive)

		// construct violation message
		violationMessage := requiredKey
		if len(allowedValues) > 0 {
			violationMessage = fmt.Sprintf("%s[%s]", requiredKey, strings.Join(allowedValues, ","))
		}
		if !keyFound {
			missingTags = append(missingTags, violationMessage)
			continue
		}

		// if there are tag values required, check them
		if len(allowedValues) > 0 {
			if !matchTagValue(allowedValues, effectiveValues, caseInsensitive) {
				missingTags = append(missingTags, violationMessage)
			}
		}
	}

	sort.Strings(missingTags) //
	return missingTags
}

// matchTagKey checks required tag key against effective tags
func matchTagKey(requiredKey string, effectiveTags TagMap, caseInsensitive bool) (values []string, found bool) {
	for effectiveKey, effectiveValues := range effectiveTags {
		if CompareCase(effectiveKey, requiredKey, caseInsensitive) {
			return effectiveValues, true
		}
	}
	return nil, false
}

// matchTagValue checks required tag values (if present) against effective tags
func matchTagValue(allowedValues []string, effectiveValues []string, caseInsensitive bool) bool {
	if len(allowedValues) == 0 { // if no tag alues are required, return match
		return true
	}
	if len(effectiveValues) == 0 && len(allowedValues) > 0 {
		return false
	}

	for _, allowed := range allowedValues {
		for _, effectiveValue := range effectiveValues {
			if CompareCase(effectiveValue, allowed, caseInsensitive) {
				return true
			}
		}
	}
	return false
}

// NormalizeCase lowers the case if caseInsensitive is true
func NormalizeCase(input string, caseInsensitive bool) string {
	if caseInsensitive {
		return strings.ToLower(input)
	}
	return input
}

// CompareCase compares case sensitivity where appropriate
func CompareCase(first, second string, caseInsensitive bool) bool {
	if caseInsensitive {
		return strings.EqualFold(first, second)
	}
	return first == second
}


================================================
FILE: internal/shared/helpers_test.go
================================================
package shared

import (
	"reflect"
	"sort"
	"testing"
)

func TestFilterMissingTags(t *testing.T) {
	testCases := []struct {
		name            string
		requiredTags    TagMap
		effectiveTags   TagMap
		caseInsensitive bool
		expectedMissing []string
	}{
		{
			name:            "tags present",
			requiredTags:    TagMap{"Owner": {}, "Env": {"Prod", "Dev"}},
			effectiveTags:   TagMap{"Owner": {"a"}, "Env": {"Prod"}},
			caseInsensitive: false,
			expectedMissing: nil,
		},
		{
			name:            "missing key",
			requiredTags:    TagMap{"Owner": {}, "Env": {"Prod"}},
			effectiveTags:   TagMap{"Env": {"Prod"}},
			caseInsensitive: false,
			expectedMissing: []string{"Owner"},
		},
		{
			name:            "wrong value",
			requiredTags:    TagMap{"Owner": {}, "Env": {"Prod"}},
			effectiveTags:   TagMap{"Owner": {"a"}, "Env": {"Dev"}},
			caseInsensitive: false,
			expectedMissing: []string{"Env[Prod]"},
		},
		{
			name:            "missing key and value",
			requiredTags:    TagMap{"Env": {"Prod"}},
			effectiveTags:   TagMap{"Owner": {"a"}},
			caseInsensitive: false,
			expectedMissing: []string{"Env[Prod]"},
		},
		{
			name:            "tags present, case insensitive",
			requiredTags:    TagMap{"Owner": {}, "Env": {"Prod", "Dev"}},
			effectiveTags:   TagMap{"owner": {"a"}, "env": {"prod"}},
			caseInsensitive: true,
			expectedMissing: nil,
		},
		{
			name:            "missing key, case insensitive",
			requiredTags:    TagMap{"Owner": {}, "Env": {"Prod"}},
			effectiveTags:   TagMap{"env": {"Prod"}},
			caseInsensitive: true,
			expectedMissing: []string{"Owner"},
		},
		{
			name:            "wrong value, case insensitive",
			requiredTags:    TagMap{"Owner": {}, "Env": {"Prod"}},
			effectiveTags:   TagMap{"owner": {"a"}, "env": {"Dev"}},
			caseInsensitive: true,
			expectedMissing: []string{"Env[Prod]"},
		},
		{
			name:            "missing key and value, case insensitive",
			requiredTags:    TagMap{"Env": {"Prod"}},
			effectiveTags:   TagMap{"owner": {"a"}},
			caseInsensitive: true,
			expectedMissing: []string{"Env[Prod]"},
		},
		{
			name:            "no required tags",
			requiredTags:    TagMap{},
			effectiveTags:   TagMap{"Owner": {"a"}, "Env": {"Dev"}},
			caseInsensitive: false,
			expectedMissing: nil,
		},
		{
			name:            "no tags",
			requiredTags:    TagMap{"Owner": {}, "Env": {"Prod"}},
			effectiveTags:   TagMap{},
			caseInsensitive: false,
			expectedMissing: []string{"Owner", "Env[Prod]"},
		},
		{
			name:            "multiple values required, one present",
			requiredTags:    TagMap{"Region": {"us-east-1", "us-west-2"}},
			effectiveTags:   TagMap{"Region": {"us-west-2"}},
			caseInsensitive: false,
			expectedMissing: nil,
		},
		{
			name:            "multiple values required, none present",
			requiredTags:    TagMap{"Region": {"us-east-1", "us-west-2"}},
			effectiveTags:   TagMap{"Region": {"eu-central-1"}},
			caseInsensitive: false,
			expectedMissing: []string{"Region[us-east-1,us-west-2]"},
		},
		{
			name:            "multiple values required, one present, case insensitive",
			requiredTags:    TagMap{"Env": {"Prod", "Dev"}},
			effectiveTags:   TagMap{"Env": {"prod"}},
			caseInsensitive: true,
			expectedMissing: nil,
		},
		{
			name:            "multiple values required, none present, case insensitive",
			requiredTags:    TagMap{"Region": {"us-east-1", "us-west-2"}},
			effectiveTags:   TagMap{"region": {"eu-central-1"}},
			caseInsensitive: true,
			expectedMissing: []string{"Region[us-east-1,us-west-2]"},
		},
		{
			name:            "multiple values required, multiple values present",
			requiredTags:    TagMap{"Env": {"Prod", "Dev"}},
			effectiveTags:   TagMap{"Env": {"Prod", "Stage"}},
			caseInsensitive: false,
			expectedMissing: nil,
		},
		{
			name:            "multiple values present, none match",
			requiredTags:    TagMap{"Env": {"Prod"}},
			effectiveTags:   TagMap{"Env": {"Dev", "Stage"}},
			caseInsensitive: false,
			expectedMissing: []string{"Env[Prod]"},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			actual := FilterMissingTags(tc.requiredTags, tc.effectiveTags, tc.caseInsensitive)

			if actual != nil && tc.expectedMissing != nil {
				sort.Strings(actual)
				sort.Strings(tc.expectedMissing)
			}

			if !reflect.DeepEqual(actual, tc.expectedMissing) {
				t.Errorf("FilterMissingTags() = %#v; want %#v", actual, tc.expectedMissing)
			}
		})
	}
}

func TestNormalizeCase(t *testing.T) {
	tests := []struct {
		name            string
		input           string
		caseInsensitive bool
		expected        string
	}{
		{
			name:            "preserve case when false",
			input:           "Owner",
			caseInsensitive: false,
			expected:        "Owner",
		},
		{
			name:            "lowercase when true",
			input:           "Owner",
			caseInsensitive: true,
			expected:        "owner",
		},
		{
			name:            "already lowercase",
			input:           "owner",
			caseInsensitive: true,
			expected:        "owner",
		},
		{
			name:            "mixed case",
			input:           "EnViRoNmEnT",
			caseInsensitive: true,
			expected:        "environment",
		},
		{
			name:            "all uppercase",
			input:           "ENVIRONMENT",
			caseInsensitive: true,
			expected:        "environment",
		},
		{
			name:            "empty string case sensitive",
			input:           "",
			caseInsensitive: false,
			expected:        "",
		},
		{
			name:            "empty string case insensitive",
			input:           "",
			caseInsensitive: true,
			expected:        "",
		},
		{
			name:            "special characters preserved",
			input:           "Tag-Name_123",
			caseInsensitive: true,
			expected:        "tag-name_123",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			actual := NormalizeCase(tc.input, tc.caseInsensitive)
			if actual != tc.expected {
				t.Errorf("NormalizeCase(%q, %v) = %q; want %q", tc.input, tc.caseInsensitive, actual, tc.expected)
			}
		})
	}
}

func TestCompareCase(t *testing.T) {
	tests := []struct {
		name            string
		first           string
		second          string
		caseInsensitive bool
		expected        bool
	}{
		{
			name:            "exact match case sensitive",
			first:           "Owner",
			second:          "Owner",
			caseInsensitive: false,
			expected:        true,
		},
		{
			name:            "case mismatch case sensitive",
			first:           "Owner",
			second:          "owner",
			caseInsensitive: false,
			expected:        false,
		},
		{
			name:            "case mismatch case insensitive",
			first:           "Owner",
			second:          "owner",
			caseInsensitive: true,
			expected:        true,
		},
		{
			name:            "different strings case sensitive",
			first:           "Owner",
			second:          "Environment",
			caseInsensitive: false,
			expected:        false,
		},
		{
			name:            "different strings case insensitive",
			first:           "Owner",
			second:          "Environment",
			caseInsensitive: true,
			expected:        false,
		},
		{
			name:            "mixed case match case insensitive",
			first:           "EnViRoNmEnT",
			second:          "environment",
			caseInsensitive: true,
			expected:        true,
		},
		{
			name:            "both uppercase case insensitive",
			first:           "ENVIRONMENT",
			second:          "ENVIRONMENT",
			caseInsensitive: true,
			expected:        true,
		},
		{
			name:            "empty strings",
			first:           "",
			second:          "",
			caseInsensitive: false,
			expected:        true,
		},
		{
			name:            "empty vs non-empty",
			first:           "",
			second:          "Owner",
			caseInsensitive: true,
			expected:        false,
		},
		{
			name:            "special characters case insensitive",
			first:           "Tag-Name_123",
			second:          "tag-name_123",
			caseInsensitive: true,
			expected:        true,
		},
		{
			name:            "unicode case insensitive",
			first:           "Café",
			second:          "café",
			caseInsensitive: true,
			expected:        true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			actual := CompareCase(tc.first, tc.second, tc.caseInsensitive)
			if actual != tc.expected {
				t.Errorf("CompareCase(%q, %q, %v) = %v; want %v", tc.first, tc.second, tc.caseInsensitive, actual, tc.expected)
			}
		})
	}
}


================================================
FILE: internal/shared/types.go
================================================
package shared

type TagMap map[string][]string

type Violation struct {
	ResourceType string   `json:"resource_type"`
	ResourceName string   `json:"resource_name"`
	Line         int      `json:"line"`
	MissingTags  []string `json:"missing_tags"`
	Skip         bool     `json:"skip"`
	FilePath     string   `json:"file_path"`
}

type OutputFormat string

const (
	OutputFormatText     OutputFormat = "text"
	OutputFormatJSON     OutputFormat = "json"
	OutputFormatJUnitXML OutputFormat = "junit-xml"
	OutputFormatSARIF    OutputFormat = "sarif"
)


================================================
FILE: internal/terraform/default_tags.go
================================================
package terraform

import (
	"fmt"
	"log"
	"sort"
	"strings"

	"github.com/hashicorp/hcl/v2/hclparse"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/jakebark/tag-nag/internal/shared"
	"github.com/zclconf/go-cty/cty"
)

// processDefaultTags identifies the default tags
func processDefaultTags(tfFiles []tfFile, tfContext *TerraformContext, caseInsensitive bool) DefaultTags {
	defaultTags := DefaultTags{
		LiteralTags: make(map[string]shared.TagMap),
	}

	parser := hclparse.NewParser()

	for _, tf := range tfFiles {
		file, diags := parser.ParseHCLFile(tf.path)
		if diags.HasErrors() || file == nil {
			log.Printf("Error parsing %s during default tag scan: %v\n", tf.path, diags)
			continue
		}

		syntaxBody, ok := file.Body.(*hclsyntax.Body)
		if !ok {
			log.Printf("Failed to get syntax body for %s\n", tf.path)
			continue
		}

		processProviders(syntaxBody, &defaultTags, tfContext, caseInsensitive)
	}

	return defaultTags
}

// processProviders extracts any default_tags from providers
func processProviders(body *hclsyntax.Body, defaultTags *DefaultTags, tfContext *TerraformContext, caseInsensitive bool) {
	for _, block := range body.Blocks {
		if block.Type == "provider" && len(block.Labels) > 0 {
			providerID := getProviderID(block, caseInsensitive)   // handle ID
			tags := getDefaultTags(block, tfContext, caseInsensitive) // handle tags

			if len(tags) > 0 {
				var keys []string
				for key := range tags {
					keys = append(keys, key) // remove bool element of tag map
				}
				sort.Strings(keys)
				fmt.Printf("Found Terraform default tags for provider %s: [%v]\n", providerID, strings.Join(keys, ", "))
				defaultTags.LiteralTags[providerID] = tags

			}
		}
	}
}

// getProviderID  returns  the provider identifier (aws or alias)
func getProviderID(block *hclsyntax.Block, caseInsensitive bool) string {
	providerName := block.Labels[0]
	var alias string

	// check for alias presence
	if attr, ok := block.Body.Attributes["alias"]; ok {
		val, diags := attr.Expr.Value(nil)
		if !diags.HasErrors() {
			alias = val.AsString()
		}
	}
	return normalizeProviderID(providerName, alias, caseInsensitive)
}

// normalize ProviderID combines the provider name and alias ("aws.west"), aligning with resource provider naming
func normalizeProviderID(providerName, alias string, caseInsensitive bool) string {
	providerID := providerName
	if alias != "" {
		providerID += "." + alias
	}

	providerID = shared.NormalizeCase(providerID, caseInsensitive)

	return providerID
}

// getDefaultTags returns the default_tags on a provider block.
func getDefaultTags(block *hclsyntax.Block, tfContext *TerraformContext, caseInsensitive bool) shared.TagMap { // Add tfContext param
	for _, subBlock := range block.Body.Blocks {
		if subBlock.Type == "default_tags" {
			if tagsAttr, exists := subBlock.Body.Attributes["tags"]; exists {
				tagsVal, diags := tagsAttr.Expr.Value(tfContext.EvalContext)
				if diags.HasErrors() {
					log.Printf("Error evaluating default_tags expression for provider %v: %v", block.Labels, diags)
					return nil
				}

				if !tagsVal.Type().IsObjectType() && !tagsVal.Type().IsMapType() {
					log.Printf("Warning: Evaluated default_tags for provider %v is not an object/map type, but %s. Skipping.", block.Labels, tagsVal.Type().FriendlyName())
					return nil
				}
				if tagsVal.IsNull() {
					log.Printf("Warning: Evaluated default_tags for provider %v is null. Skipping.", block.Labels)
					return nil
				}

				evalTags := make(shared.TagMap)
				for key, val := range tagsVal.AsValueMap() {
					var valStr string
					if val.IsNull() {
						valStr = ""
					} else if val.Type() == cty.String {
						valStr = val.AsString()
					} else {
						strResult, err := convertCtyValueToString(val)
						if err != nil {
							log.Printf("Warning: Could not convert default tag value for key %q to string: %v. Using empty string.", key, err)
							valStr = ""
						} else {
							valStr = strResult
						}
					}

					effectiveKey := shared.NormalizeCase(key, caseInsensitive)
					evalTags[effectiveKey] = []string{valStr}
				}
				return evalTags
			}
		}
	}
	return nil // No default_tags block found
}


================================================
FILE: internal/terraform/default_tags_test.go
================================================
package terraform

import (
	"testing"
)

// Test for normalizeProviderID
func TestNormalizeProviderID(t *testing.T) {
	tests := []struct {
		name            string
		providerName    string
		alias           string
		caseInsensitive bool
		expected        string
	}{
		{
			name:         "default",
			providerName: "aws",
			alias:        "",
			expected:     "aws",
		},
		{
			name:         "alias",
			providerName: "aws",
			alias:        "west",
			expected:     "aws.west",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			if got := normalizeProviderID(tc.providerName, tc.alias, tc.caseInsensitive); got != tc.expected {
				t.Errorf("normalizeProviderID() = %v, expected %v", got, tc.expected)
			}
		})
	}
}


================================================
FILE: internal/terraform/helpers.go
================================================
package terraform

import (
	"encoding/json"
	"fmt"
	"log"
	"os/exec"
	"strings"

	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/jakebark/tag-nag/internal/config"
	"github.com/jakebark/tag-nag/internal/shared"
	"github.com/zclconf/go-cty/cty"
	ctyjson "github.com/zclconf/go-cty/cty/json"
)

// traversalToString converts a hcl hierachical/traversal string to a literal string
func traversalToString(expr hcl.Expression, caseInsensitive bool) string {
	if ste, ok := expr.(*hclsyntax.ScopeTraversalExpr); ok {
		tokens := []string{}
		for _, step := range ste.Traversal {
			switch t := step.(type) {
			case hcl.TraverseRoot:
				tokens = append(tokens, t.Name)
			case hcl.TraverseAttr:
				tokens = append(tokens, t.Name)
			}
		}
		result := shared.NormalizeCase(strings.Join(tokens, "."), caseInsensitive)
		return result
	}
	// fallback - attempt to evaluate the expression as a literal value
	if v, diags := expr.Value(nil); !diags.HasErrors() {
		if v.Type().Equals(cty.String) {
			s := shared.NormalizeCase(v.AsString(), caseInsensitive)
			return s
		} else {
			return fmt.Sprintf("%v", v)
		}
	}
	return ""
}

// mergeTags combines multiple tag maps
func mergeTags(tagMaps ...shared.TagMap) shared.TagMap {
	merged := make(shared.TagMap)
	for _, m := range tagMaps {
		for k, v := range m {
			merged[k] = v
		}
	}
	return merged
}

// SkipResource determines if a resource block should be skipped
func SkipResource(block *hclsyntax.Block, lines []string) bool {
	index := block.DefRange().Start.Line
	if index < len(lines) {
		if strings.Contains(lines[index], config.TagNagIgnore) {
			return true
		}
	}
	return false
}

func convertCtyValueToString(val cty.Value) (string, error) {
	if !val.IsKnown() {
		return "", fmt.Errorf("value is unknown")
	}
	if val.IsNull() {
		return "", nil
	}

	ty := val.Type()
	switch {
	case ty == cty.String:
		return val.AsString(), nil
	case ty == cty.Number:
		bf := val.AsBigFloat()
		return bf.Text('f', -1), nil
	case ty == cty.Bool:
		return fmt.Sprintf("%t", val.True()), nil
	case ty.IsListType() || ty.IsTupleType() || ty.IsSetType() || ty.IsMapType() || ty.IsObjectType():

		simpleJSON, err := ctyjson.SimpleJSONValue{Value: val}.MarshalJSON()
		if err != nil {
			return "", fmt.Errorf("failed to marshal complex type to json: %w", err)
		}
		strJSON := string(simpleJSON)
		if len(strJSON) >= 2 && strJSON[0] == '"' && strJSON[len(strJSON)-1] == '"' {
			var unquotedStr string
			if err := json.Unmarshal(simpleJSON, &unquotedStr); err == nil {
				return unquotedStr, nil
			}
		}
		return strJSON, nil
	default:
		return fmt.Sprintf("%v", val), nil // Best effort
	}
}

// loadTaggableResources calls the Terraform JSON schema and returns a set of all resources that are taggable
func loadTaggableResources(providerAddr string) map[string]bool {
	out, err := exec.Command(
		"terraform", "providers", "schema", "-json",
	).Output()
	if err != nil {
		// log.Printf("Failed to load AWS terraform provider schema: %v", err)
		return nil
	}

	// unmarshall what we need
	var s struct {
		ProviderSchemas map[string]struct {
			ResourceSchemas map[string]struct {
				Block struct {
					Attributes map[string]json.RawMessage `json:"attributes"`
				} `json:"block"`
			} `json:"resource_schemas"`
		} `json:"provider_schemas"`
	}
	if err := json.Unmarshal(out, &s); err != nil {
		log.Fatalf("failed to parse schema JSON: %v", err)
		return nil
	}

	taggable := make(map[string]bool)
	if ps, ok := s.ProviderSchemas[providerAddr]; ok {
		for resType, schema := range ps.ResourceSchemas {
			if _, has := schema.Block.Attributes["tags"]; has {
				taggable[resType] = true
			} else { //
				taggable[resType] = false
			}
		}
	} else {
		return nil
	}

	return taggable
}


================================================
FILE: internal/terraform/helpers_test.go
================================================
package terraform

import (
	"reflect"
	"testing"

	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/jakebark/tag-nag/internal/shared"
	"github.com/zclconf/go-cty/cty"
)

func TestTraversalToString(t *testing.T) {
	testCases := []struct {
		name            string
		hclInput        string
		caseInsensitive bool
		expected        string
	}{
		{
			name:            "literal",
			hclInput:        "Owner",
			caseInsensitive: false,
			expected:        "Owner",
		},
		{
			name:            "literal, case insensitive",
			hclInput:        "Owner",
			caseInsensitive: true,
			expected:        "owner",
		},
		{
			name:            "traversal",
			hclInput:        "local.network.subnets[0].id",
			caseInsensitive: false,
			expected:        "local.network.subnets.id",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			// Parse the HCL expression string
			expr, diags := hclsyntax.ParseExpression([]byte(tc.hclInput), tc.name+".tf", hcl.Pos{Line: 1, Column: 1})
			if diags.HasErrors() {
				t.Fatalf("Failed to parse expression %q: %v", tc.hclInput, diags)
			}

			actual := traversalToString(expr, tc.caseInsensitive)
			if actual != tc.expected {
				t.Errorf("traversalToString(%q, %v) = %q; want %q", tc.hclInput, tc.caseInsensitive, actual, tc.expected)
			}
		})
	}
}

func TestMergeTags(t *testing.T) {
	testCases := []struct {
		name     string
		inputs   []shared.TagMap
		expected shared.TagMap
	}{
		{
			name:     "empty",
			inputs:   []shared.TagMap{},
			expected: shared.TagMap{},
		},
		{
			name: "key",
			inputs: []shared.TagMap{
				{"Environment": {}},
			},
			expected: shared.TagMap{"Environment": {}},
		},
		{
			name:     "key and value",
			inputs:   []shared.TagMap{{"Environment": {"Dev"}}},
			expected: shared.TagMap{"Environment": {"Dev"}},
		},
		{
			name: "multiple keys and values",
			inputs: []shared.TagMap{
				{"Environment": {"Dev"}},
				{"Owner": {"Prod"}},
			},
			expected: shared.TagMap{"Environment": {"Dev"}, "Owner": {"Prod"}},
		},
		{
			name: "overlapping values, last wins",
			inputs: []shared.TagMap{
				{"Environment": {"Dev"}, "Owner": {"jakebark"}},
				{"Owner": {"Jake"}, "CostCenter": {"C-01"}},
			},
			expected: shared.TagMap{"Environment": {"Dev"}, "Owner": {"Jake"}, "CostCenter": {"C-01"}},
		},
		{
			name: "overlapping empty value, last wins",
			inputs: []shared.TagMap{
				{"Environment": {"Dev"}},
				{"Environment": {}},
			},
			expected: shared.TagMap{"Environment": {}},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			actual := mergeTags(tc.inputs...)
			if !reflect.DeepEqual(actual, tc.expected) {
				t.Errorf("mergeTags() = %v; want %v", actual, tc.expected)
			}
		})
	}
}

func TestConvertCtyValueToString(t *testing.T) {
	tests := []struct {
		name    string
		input   cty.Value
		want    string
		wantErr bool
	}{
		{
			name:  "string",
			input: cty.StringVal("hello world"),
			want:  "hello world",
		},
		{
			name:  "empty string",
			input: cty.StringVal(""),
			want:  "",
		},
		{
			name:  "number",
			input: cty.NumberIntVal(123),
			want:  "123",
		},
		{
			name:  "float",
			input: cty.NumberFloatVal(123.45),
			want:  "123.45",
		},
		{
			name:  "bool, true",
			input: cty.True,
			want:  "true",
		},
		{
			name:  "bool, false",
			input: cty.False,
			want:  "false",
		},
		{
			name:  "null",
			input: cty.NullVal(cty.String),
			want:  "",
		},
		{
			name:    "unknown value",
			input:   cty.UnknownVal(cty.String),
			wantErr: true,
		},
		{
			name:  "list",
			input: cty.ListVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}),
			want:  `["a","b"]`,
		},
		{
			name:  "map",
			input: cty.MapVal(map[string]cty.Value{"key": cty.StringVal("value")}),
			want:  `{"key":"value"}`,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := convertCtyValueToString(tc.input)
			if (err != nil) != tc.wantErr {
				t.Fatalf("convertCtyValueToString() error = %v, wantErr %v", err, tc.wantErr)
			}
			if !tc.wantErr && got != tc.want {
				t.Errorf("convertCtyValueToString() = %v, want %v", got, tc.want)
			}
		})
	}
}


================================================
FILE: internal/terraform/process.go
================================================
package terraform

import (
	"log"
	"os"
	"path/filepath"
	"slices"
	"strings"

	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclparse"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/jakebark/tag-nag/internal/config"
	"github.com/jakebark/tag-nag/internal/shared"
	"github.com/zclconf/go-cty/cty"
	"github.com/zclconf/go-cty/cty/function"
)

type tfFile struct {
	path string
	info os.FileInfo
}

// ProcessDirectory walks all terraform files in directory
func ProcessDirectory(directoryPath string, requiredTags map[string][]string, caseInsensitive bool, skip []string) []shared.Violation {
	hasFiles, err := scan(directoryPath)
	if err != nil {
		return nil
	}
	if !hasFiles {
		return nil
	}

	// log.Println("Terraform files found\n")
	var allViolations []shared.Violation

	taggable := loadTaggableResources("registry.terraform.io/hashicorp/aws")
	if taggable == nil {
		// log.Printf("Warning: Failed to load Terraform AWS Provider\nRun 'terraform init' to fix\n")
		// log.Printf("Continuing with limited features ... \n ")
	}

	tfContext, err := buildTagContext(directoryPath)
	if err != nil {
		tfContext = &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value), Functions: make(map[string]function.Function)}}
	}

	// single directory walk
	tfFiles, err := collectFiles(directoryPath, skip)
	if err != nil {
		log.Printf("Error scanning directory %q: %v\n", directoryPath, err)
		return nil
	}

	if len(tfFiles) == 0 {
		return nil
	}

	// extract default tags from all files
	defaultTags := processDefaultTags(tfFiles, tfContext, caseInsensitive)

	// process resources for tag violations
	for _, tf := range tfFiles {
		violations := processFile(tf.path, requiredTags, &defaultTags, tfContext, caseInsensitive, taggable)
		allViolations = append(allViolations, violations...)
	}

	return allViolations
}

// collectFiles identifies all elligible terraform files
func collectFiles(directoryPath string, skip []string) ([]tfFile, error) {
	var tfFiles []tfFile

	err := filepath.Walk(directoryPath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		if skipDirectories(path, info, skip) {
			if info.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}

		if !info.IsDir() && filepath.Ext(path) == ".tf" {
			tfFiles = append(tfFiles, tfFile{path: path, info: info})
		}
		return nil
	})

	return tfFiles, err
}

// skipDir identifies directories to ignore
func skipDirectories(path string, info os.FileInfo, skip []string) bool {
	// user-defined skip paths
	for _, skipped := range skip {
		if strings.HasPrefix(path, skipped) {
			return true
		}
	}

	// default skipped directories eg .git
	if info.IsDir() {
		dirName := info.Name()
		if slices.Contains(config.SkippedDirs, dirName) {
			return true
		}
	}

	return false
}

// processFile parses files looking for resources
func processFile(filePath string, requiredTags shared.TagMap, defaultTags *DefaultTags, tfContext *TerraformContext, caseInsensitive bool, taggable map[string]bool) []shared.Violation {
	data, err := os.ReadFile(filePath)
	if err != nil {
		log.Printf("Error reading %s: %v\n", filePath, err)
		return nil
	}
	content := string(data)
	lines := strings.Split(content, "\n")

	skipAll := strings.Contains(content, config.TagNagIgnoreAll)

	parser := hclparse.NewParser()
	file, diagnostics := parser.ParseHCLFile(filePath)

	if diagnostics.HasErrors() {
		log.Printf("Error parsing %s: %v\n", filePath, diagnostics)
		return nil
	}

	syntaxBody, ok := file.Body.(*hclsyntax.Body)
	if !ok {
		log.Printf("Parsing failed for %s\n", filePath)
		return nil
	}

	violations := checkResourcesForTags(syntaxBody, requiredTags, defaultTags, tfContext, caseInsensitive, lines, skipAll, taggable, filePath)
	return violations
}


================================================
FILE: internal/terraform/references.go
================================================
package terraform

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclparse"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/jakebark/tag-nag/internal/config"
	"github.com/zclconf/go-cty/cty"
	"github.com/zclconf/go-cty/cty/function"
)

func buildTagContext(directoryPath string) (*TerraformContext, error) {
	parsedFiles := make(map[string]*hcl.File)
	parser := hclparse.NewParser()

	// first pass, parse files
	err := filepath.Walk(directoryPath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			dirName := info.Name()
			for _, skipped := range config.SkippedDirs {
				if dirName == skipped {
					return filepath.SkipDir
				}
			}
		}
		if !info.IsDir() && filepath.Ext(path) == ".tf" {
			file, diags := parser.ParseHCLFile(path)
			if diags.HasErrors() {
				log.Printf("Error parsing HCL file %s: %v\n", path, diags)
			}
			if file != nil {
				parsedFiles[path] = file
			}
		}
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("error walking directory %s: %w", directoryPath, err)
	}

	if len(parsedFiles) == 0 {
		log.Println("No Terraform files (.tf) found to build context.")
		return &TerraformContext{
			EvalContext: &hcl.EvalContext{
				Variables: make(map[string]cty.Value),
				Functions: make(map[string]function.Function),
			},
		}, nil
	}

	// second pass, evaluate vars
	tfVars := make(map[string]cty.Value)
	for _, file := range parsedFiles {
		body, ok := file.Body.(*hclsyntax.Body)
		if !ok {
			continue
		}

		for _, block := range body.Blocks {
			if block.Type == "variable" && len(block.Labels) > 0 {
				varName := block.Labels[0]
				if defaultAttr, exists := block.Body.Attributes["default"]; exists {
					val, diags := defaultAttr.Expr.Value(nil)
					if diags.HasErrors() {
						log.Printf("Error evaluating default for variable %q: %v", varName, diags)
						val = cty.NullVal(cty.DynamicPseudoType)
					}
					tfVars[varName] = val
				} else {
					tfVars[varName] = cty.NullVal(cty.DynamicPseudoType)
				}
			}
		}
	}

	// 3rd pass, evaluate locals
	tfLocals := make(map[string]cty.Value)
	localsDefs := make(map[string]hcl.Expression)

	for _, file := range parsedFiles {
		body, ok := file.Body.(*hclsyntax.Body)
		if !ok {
			continue
		}
		for _, block := range body.Blocks {
			if block.Type == "locals" {
				for name, attr := range block.Body.Attributes {
					localsDefs[name] = attr.Expr
				}
			}
		}
	}

	evalCtxForLocals := &hcl.EvalContext{
		Variables: map[string]cty.Value{"var": cty.ObjectVal(tfVars)},
		Functions: config.StdlibFuncs,
	}
	evalCtxForLocals.Variables["local"] = cty.NullVal(cty.DynamicPseudoType) // Placeholder for local

	const maxLocalPasses = 10
	evaluatedCount := 0
	for pass := 0; pass < maxLocalPasses && evaluatedCount < len(localsDefs); pass++ {
		madeProgress := false

		evalCtxForLocals.Variables["local"] = cty.ObjectVal(tfLocals)

		for name, expr := range localsDefs {
			if _, exists := tfLocals[name]; exists {
				continue
			}

			val, diags := expr.Value(evalCtxForLocals)
			if !diags.HasErrors() {
				tfLocals[name] = val
				evaluatedCount++
				madeProgress = true
			}

		}
		if !madeProgress && evaluatedCount < len(localsDefs) {
			log.Printf("Warning: Could not resolve all locals dependencies after %d passes.", pass+1)

			break
		}
	}

	finalCtx := &hcl.EvalContext{
		Variables: map[string]cty.Value{
			"var":   cty.ObjectVal(tfVars),
			"local": cty.ObjectVal(tfLocals),
		},
		Functions: config.StdlibFuncs,
	}

	return &TerraformContext{EvalContext: finalCtx}, nil
}


================================================
FILE: internal/terraform/resources.go
================================================
package terraform

import (
	"log"
	"strings"

	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/jakebark/tag-nag/internal/shared"
	"github.com/zclconf/go-cty/cty"
)

// checkResourcesForTags inspects resource blocks and returns violations
func checkResourcesForTags(body *hclsyntax.Body, requiredTags shared.TagMap, defaultTags *DefaultTags, tfContext *TerraformContext, caseInsensitive bool, fileLines []string, skipAll bool, taggable map[string]bool, filePath string) []shared.Violation {
	var violations []shared.Violation

	for _, block := range body.Blocks {
		if block.Type != "resource" || len(block.Labels) < 2 { // skip anything without 2 labels eg "aws_s3_bucket" and "this"
			continue
		}

		resourceType := block.Labels[0] // aws_s3_bucket
		resourceName := block.Labels[1] // this

		if !strings.HasPrefix(resourceType, "aws_") {
			continue
		}

		isTaggable := true // assume resource is taggable, by default
		if taggable != nil {
			var found bool
			isTaggable, found = taggable[resourceType]
			if !found {
				isTaggable = true // if not found, assume resource is taggable
				// isTaggable = false
				// log.Printf("Warning: Resource type %s not found in provider schema. Assuming not taggable.", resourceType) //todo
			}
		} else {
		}

		if !isTaggable {
			// log.Printf("Skipping non-taggable resource type: %s", resourceType)
			continue
		}

		providerID := getResourceProvider(block, caseInsensitive)
		providerEvalTags := defaultTags.LiteralTags[providerID]
		if providerEvalTags == nil {
			providerEvalTags = make(shared.TagMap)
		}

		resourceEvalTags := findTags(block, tfContext, caseInsensitive)
		effectiveTags := mergeTags(providerEvalTags, resourceEvalTags)

		missingTags := shared.FilterMissingTags(requiredTags, effectiveTags, caseInsensitive)
		if len(missingTags) > 0 {
			violation := shared.Violation{
				ResourceType: resourceType,
				ResourceName: resourceName,
				Line:         block.DefRange().Start.Line,
				MissingTags:  missingTags,
				FilePath:     filePath,
			}
			if skipAll || SkipResource(block, fileLines) {
				violation.Skip = true
			}
			violations = append(violations, violation)
		}
	}
	return violations
}

// getResourceProvider determines the provider for a resource block
func getResourceProvider(block *hclsyntax.Block, caseInsensitive bool) string {
	if attr, ok := block.Body.Attributes["provider"]; ok {

		// provider is a literal string ("aws")
		val, diags := attr.Expr.Value(nil)
		if !diags.HasErrors() {
			s := shared.NormalizeCase(val.AsString(), caseInsensitive)
			return s
		}
		// provider is not a literal string ("aws.west")
		s := traversalToString(attr.Expr, caseInsensitive)
		if s != "" {
			return s
		}
	}

	// no explicit provider, return default provider
	defaultProvider := shared.NormalizeCase("aws", caseInsensitive)
	return defaultProvider
}

// findTags returns tag map from a resource block (with extractTags), if it has tags
func findTags(block *hclsyntax.Block, tfContext *TerraformContext, caseInsensitive bool) shared.TagMap {
	evalTags := make(shared.TagMap)
	if attr, exists := block.Body.Attributes["tags"]; exists {

		childCtx := tfContext.EvalContext.NewChild()
		if childCtx.Variables == nil {
			childCtx.Variables = make(map[string]cty.Value)
		}
		childCtx.Variables["each"] = cty.ObjectVal(map[string]cty.Value{
			"key":   cty.StringVal(""),
			"value": cty.StringVal(""),
		})
		tagsVal, diags := attr.Expr.Value(childCtx)

		if diags.HasErrors() {
			log.Printf("Error evaluating tags for resource %s.%s: %v", block.Labels[0], block.Labels[1], diags)
			return evalTags
		}

		if !tagsVal.Type().IsObjectType() && !tagsVal.Type().IsMapType() {
			return evalTags
		}
		if tagsVal.IsNull() {
			return evalTags
		}

		for key, val := range tagsVal.AsValueMap() {
			var valStr string
			if val.IsNull() {
				valStr = ""
			} else if val.Type() == cty.String {
				valStr = val.AsString()
			} else {
				strResult, err := convertCtyValueToString(val)
				if err != nil {
					valStr = ""
				} else {
					valStr = strResult
				}
			}

			effectiveKey := shared.NormalizeCase(key, caseInsensitive)
			evalTags[effectiveKey] = []string{valStr}
		}
	}
	return evalTags
}


================================================
FILE: internal/terraform/resources_test.go
================================================
package terraform

import (
	"sort"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclparse"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/jakebark/tag-nag/internal/shared"
	"github.com/zclconf/go-cty/cty"
)

func TestCheckResourcesForTags_Taggability(t *testing.T) {
	parser := hclparse.NewParser()

	tfCode := `
		resource "aws_s3_bucket" "taggable_bucket" {
		  tags = {
			Owner = "test-user" // Missing Environment
		  }
		}

		resource "aws_kms_alias" "non_taggable_alias" {
		  alias_name       = "alias/my-key-alias"
		  target_key_id = "some-key-id"
		}

		resource "aws_instance" "another_taggable" {
		  ami           = "ami-12345"
		  instance_type = "t2.micro"
		  tags = {
			Owner = "test-user"
			Environment = "dev"
		  }
		}

		resource "aws_route53_zone" "unknown_in_schema_should_be_checked" {
			name = "example.com"
			# Missing all tags
		}
	`

	file, diags := parser.ParseHCL([]byte(tfCode), "test.tf")
	if diags.HasErrors() {
		t.Fatalf("Failed to parse test HCL: %v", diags)
	}

	body, ok := file.Body.(*hclsyntax.Body)
	if !ok {
		t.Fatalf("Could not get HCL syntax body")
	}

	requiredTags := shared.TagMap{
		"Owner":       {},
		"Environment": {},
	}
	lines := strings.Split(tfCode, "\n")

	t.Run("With Taggability Filter", func(t *testing.T) {
		taggableMap := map[string]bool{
			"aws_s3_bucket":    true,
			"aws_kms_alias":    false,
			"aws_instance":     true,
			"aws_iam_user":     false, // Example of another non-taggable not in the HCL
			"aws_route53_zone": true,  // Explicitly mark as taggable for test
		}

		mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}}
		mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)}

		violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap, "test.tf")

		expectedViolations := []shared.Violation{
			{ResourceType: "aws_s3_bucket", ResourceName: "taggable_bucket", Line: 2, MissingTags: []string{"Environment"}, FilePath: "test.tf"},
			{ResourceType: "aws_route53_zone", ResourceName: "unknown_in_schema_should_be_checked", Line: 22, MissingTags: []string{"Environment", "Owner"}, FilePath: "test.tf"}, // Order might vary
		}

		sortViolations(violations)
		sortViolations(expectedViolations) // Sort missing tags within each violation for stable comparison

		if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(shared.Violation{})); diff != "" {
			t.Errorf("checkResourcesForTags with filter mismatch (-want +got):\n%s", diff)
		}
	})

	t.Run("Without Taggability Filter (nil map)", func(t *testing.T) {
		taggableMap := map[string]bool(nil)

		mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}}
		mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)}

		violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap, "test.tf")

		expectedViolations := []shared.Violation{
			{ResourceType: "aws_s3_bucket", ResourceName: "taggable_bucket", Line: 2, MissingTags: []string{"Environment"}, FilePath: "test.tf"},
			{ResourceType: "aws_kms_alias", ResourceName: "non_taggable_alias", Line: 8, MissingTags: []string{"Environment", "Owner"}, FilePath: "test.tf"},
			{ResourceType: "aws_route53_zone", ResourceName: "unknown_in_schema_should_be_checked", Line: 22, MissingTags: []string{"Environment", "Owner"}, FilePath: "test.tf"},
		}
		sortViolations(violations)
		sortViolations(expectedViolations)

		if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(shared.Violation{})); diff != "" {
			t.Errorf("checkResourcesForTags without filter mismatch (-want +got):\n%s", diff)
		}
	})

	t.Run("With Taggability Filter - resource type not in map (should assume taggable)", func(t *testing.T) {
		// aws_route53_zone is NOT in this specific taggableMap
		taggableMap := map[string]bool{
			"aws_s3_bucket": true,
			"aws_kms_alias": false,
			"aws_instance":  true,
		}

		mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}}
		mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)}

		violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap, "test.tf")

		expectedViolations := []shared.Violation{
			{ResourceType: "aws_s3_bucket", ResourceName: "taggable_bucket", Line: 2, MissingTags: []string{"Environment"}, FilePath: "test.tf"},
			// aws_route53_zone is assumed taggable as it's not in the map with a 'false' entry
			{ResourceType: "aws_route53_zone", ResourceName: "unknown_in_schema_should_be_checked", Line: 22, MissingTags: []string{"Environment", "Owner"}, FilePath: "test.tf"},
		}

		sortViolations(violations)
		sortViolations(expectedViolations)

		if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(shared.Violation{})); diff != "" {
			t.Errorf("checkResourcesForTags with incomplete filter mismatch (-want +got):\n%s", diff)
		}
	})
}

// Helper to sort violations for consistent comparison
func sortViolations(violations []shared.Violation) {
	for i := range violations {
		sort.Strings(violations[i].MissingTags)
	}
	sort.Slice(violations, func(i, j int) bool {
		if violations[i].ResourceType != violations[j].ResourceType {
			return violations[i].ResourceType < violations[j].ResourceType
		}
		return violations[i].ResourceName < violations[j].ResourceName
	})
}


================================================
FILE: internal/terraform/scan.go
================================================
package terraform

import (
	"errors"
	"io/fs"
	"path/filepath"
)

// scan looks for tf files
func scan(directoryPath string) (bool, error) {
	found := false
	targetExt := ".tf"

	walkErr := filepath.WalkDir(directoryPath, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return nil
		}

		if !d.IsDir() {
			if filepath.Ext(path) == targetExt {
				found = true
				return fs.ErrNotExist // stop scan immediately
			}
		}
		return nil
	})

	if walkErr != nil && !errors.Is(walkErr, fs.ErrNotExist) {
		return false, walkErr
	}

	return found, nil
}


================================================
FILE: internal/terraform/scan_test.go
================================================
package terraform

import (
	"errors"
	"io/fs"
	"os"
	"path/filepath"
	"testing"
)

func TestScanFunction(t *testing.T) {
	createDummyFile := func(t *testing.T, path string) {
		t.Helper()
		err := os.WriteFile(path, []byte("dummy content"), 0644)
		if err != nil {
			t.Fatalf("Failed to create dummy file %s: %v", path, err)
		}
	}

	tests := []struct {
		name     string
		setupDir func(t *testing.T, dir string)
		expected bool
	}{
		{
			name: "empty dir",
			setupDir: func(t *testing.T, dir string) {
				// dir is empty
			},
			expected: false,
		},
		{
			name: "no terraform files",
			setupDir: func(t *testing.T, dir string) {
				createDummyFile(t, filepath.Join(dir, "main.yaml"))
				createDummyFile(t, filepath.Join(dir, "README.md"))
			},
			expected: false,
		},
		{
			name: "terraform file",
			setupDir: func(t *testing.T, dir string) {
				createDummyFile(t, filepath.Join(dir, "main.tf"))
				createDummyFile(t, filepath.Join(dir, "other.txt"))
			},
			expected: true,
		},
		{
			name: "multiple terraform files",
			setupDir: func(t *testing.T, dir string) {
				createDummyFile(t, filepath.Join(dir, "main.tf"))
				createDummyFile(t, filepath.Join(dir, "variables.tf"))
			},
			expected: true,
		},
		{
			name: "nested terraform file",
			setupDir: func(t *testing.T, dir string) {
				subDir := filepath.Join(dir, "subdir")
				err := os.Mkdir(subDir, 0755)
				if err != nil {
					t.Fatalf("Failed to create subdir: %v", err)
				}
				createDummyFile(t, filepath.Join(subDir, "module.tf"))
				createDummyFile(t, filepath.Join(dir, "root.txt"))
			},
			expected: true,
		},
		{
			name: "terraform file, nested non-terraform file",
			setupDir: func(t *testing.T, dir string) {
				subDir := filepath.Join(dir, "subdir")
				err := os.Mkdir(subDir, 0755)
				if err != nil {
					t.Fatalf("Failed to create subdir: %v", err)
				}
				createDummyFile(t, filepath.Join(dir, "main.tf"))
				createDummyFile(t, filepath.Join(subDir, "other.yaml"))
			},
			expected: true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			tmpDir := t.TempDir()
			testPath := tmpDir
			if tc.name == "Non-existent directory" {
				testPath = filepath.Join(tmpDir, "this_dir_should_not_exist")
			} else {
				tc.setupDir(t, tmpDir)
			}
			gotFound, gotErr := scan(testPath)
			if gotErr != nil && !errors.Is(gotErr, fs.ErrNotExist) {
				if tc.name != "Non-existent directory" {
					t.Logf("scan() returned an unexpected error: %v (test will check 'found' status only)", gotErr)
				}
			}
			if gotFound != tc.expected {
				t.Errorf("scan() found = %v, expected %v", gotFound, tc.expected)
			}
		})
	}
}


================================================
FILE: internal/terraform/types.go
================================================
package terraform

import (
	"github.com/hashicorp/hcl/v2"
	"github.com/jakebark/tag-nag/internal/shared"
)

type DefaultTags struct {
	LiteralTags map[string]shared.TagMap
}


type TerraformContext struct {
	EvalContext *hcl.EvalContext
}


================================================
FILE: main.go
================================================
package main

import (
	"log"

	"github.com/jakebark/tag-nag/internal/cloudformation"
	"github.com/jakebark/tag-nag/internal/inputs"
	"github.com/jakebark/tag-nag/internal/output"
	"github.com/jakebark/tag-nag/internal/shared"
	"github.com/jakebark/tag-nag/internal/terraform"
)

func main() {
	log.SetFlags(0) // remove timestamp from prints

	userInput := inputs.ParseFlags()

	if userInput.DryRun {
		log.Printf("\033[32mDry-run: %s\033[0m\n", userInput.Directory)
	} else {
		log.Printf("\033[33mScanning: %s\033[0m\n", userInput.Directory)
	}

	tfViolations := terraform.ProcessDirectory(userInput.Directory, userInput.RequiredTags, userInput.CaseInsensitive, userInput.Skip)
	cfnViolations := cloudformation.ProcessDirectory(userInput.Directory, userInput.RequiredTags, userInput.CaseInsensitive, userInput.CfnSpecPath, userInput.Skip)

	var allViolations []shared.Violation
	allViolations = append(allViolations, tfViolations...)
	allViolations = append(allViolations, cfnViolations...)

	output.ProcessOutput(allViolations, userInput.OutputFormat, userInput.DryRun)
}


================================================
FILE: main_test.go
================================================
package main

import (
	"bytes"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"testing"
)

const binaryName = "tag-nag"

type testCases struct {
	name             string
	filePathOrDir    string
	cliArgs          []string
	expectedExitCode int
	expectedError    bool
	expectedOutput   []string
}

func TestMain(m *testing.M) {
	cmd := exec.Command("go", "build", "-o", binaryName)
	if err := cmd.Run(); err != nil {
		fmt.Fprintf(os.Stderr, "Failed to build %s: %v\n", binaryName, err)
		os.Exit(1)
	}

	exitVal := m.Run()
	os.Remove(binaryName)
	os.Exit(exitVal)
}

func runTagNag(t *testing.T, args ...string) (string, error, int) {
	t.Helper()
	fullArgs := append([]string{"./" + binaryName}, args...)
	cmd := exec.Command(fullArgs[0], fullArgs[1:]...)
	var outbuf, errbuf bytes.Buffer
	cmd.Stdout = &outbuf
	cmd.Stderr = &errbuf

	err := cmd.Run()
	stdout := outbuf.String()
	stderr := errbuf.String()

	fullOutput := stdout
	if stderr != "" {
		fullOutput += "\n" + stderr
	}

	exitCode := 0
	if err != nil {
		if exitError, ok := err.(*exec.ExitError); ok {
			exitCode = exitError.ExitCode()
		} else {
			t.Fatalf("Command execution failed with non-exit error: %v, output: %s", err, fullOutput)
		}
	}
	return fullOutput, err, exitCode
}

func TestInputs(t *testing.T) {
	testCases := []testCases{
		{
			name:             "no dir",
			filePathOrDir:    "",
			cliArgs:          []string{"--tags", "Owner"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{"Error: specify a directory or file to scan"},
		},
		{
			name:             "no tags",
			filePathOrDir:    "testdata/terraform/tags.tf",
			cliArgs:          []string{"nonexistent.yml"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{"specify required tags using --tags or create a .tag-nag.yml config file"},
		},

		{
			name:             "dry run",
			filePathOrDir:    "testdata/terraform/tags.tf",
			cliArgs:          []string{"--tags", "Owner,Environment,Project", "--dry-run"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"Dry-run:", `aws_s3_bucket "this"`, "Missing tags: Project"},
		},
	}
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			var argsForRun []string
			if tc.filePathOrDir != "" {
				argsForRun = append(argsForRun, tc.filePathOrDir)
			}
			argsForRun = append(argsForRun, tc.cliArgs...)

			output, err, exitCode := runTagNag(t, argsForRun...)

			if tc.expectedError && err == nil {
				t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output)
			}
			if !tc.expectedError && err != nil {
				t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output)
			}

			if exitCode != tc.expectedExitCode {
				t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output)
			}

			for _, expectedStr := range tc.expectedOutput {
				if !strings.Contains(output, expectedStr) {
					t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output)
				}
			}
		})
	}
}

func TestTerraform(t *testing.T) {
	testCases := []testCases{
		{
			name:             "tags",
			filePathOrDir:    "testdata/terraform/tags.tf",
			cliArgs:          []string{"--tags", "Owner,Environment"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "missing tags",
			filePathOrDir:    "testdata/terraform/tags.tf",
			cliArgs:          []string{"--tags", "Owner,Environment,Project"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{`aws_s3_bucket "this"`, "Missing tags: Project"},
		},
		{
			name:             "no tags",
			filePathOrDir:    "testdata/terraform/no_tags.tf",
			cliArgs:          []string{"--tags", "Owner, Environment"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{`aws_s3_bucket "this"`, "Missing tags: Environment, Owner"},
		},
		{
			name:             "case insensitive",
			filePathOrDir:    "testdata/terraform/tags.tf",
			cliArgs:          []string{"--tags", "owner,environment", "-c"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "lower case",
			filePathOrDir:    "testdata/terraform/tags.tf",
			cliArgs:          []string{"--tags", "owner"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{`aws_s3_bucket "this"`, "Missing tags: owner"},
		},
		{
			name:             "tag values",
			filePathOrDir:    "testdata/terraform/tags.tf",
			cliArgs:          []string{"--tags", "Owner,Environment[dev,prod]"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "missing tag value",
			filePathOrDir:    "testdata/terraform/tags.tf",
			cliArgs:          []string{"--tags", "Owner,Environment[test]"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{`aws_s3_bucket "this"`, "Missing tags: Environment[test]"},
		},
		{
			name:             "tag values case insensitive",
			filePathOrDir:    "testdata/terraform/tags.tf",
			cliArgs:          []string{"--tags", "Owner,Environment[Dev,Prod]", "-c"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "provider",
			filePathOrDir:    "testdata/terraform/provider.tf",
			cliArgs:          []string{"--tags", "Owner,Environment,Project,Source"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"Found Terraform default tags for provider aws", "No tag violations found"},
		},
		{
			name:             "provider",
			filePathOrDir:    "testdata/terraform/provider.tf",
			cliArgs:          []string{"--tags", "Owner,Environment,Project,Source"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"Found Terraform default tags for provider aws: [Project, Source]", "No tag violations found"},
		},
		{
			name:             "provider case insensitive",
			filePathOrDir:    "testdata/terraform/provider.tf",
			cliArgs:          []string{"--tags", "owner,environment,project,source", "-c"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"Found Terraform default tags for provider aws: [project, source]", "No tag violations found"},
		},
		{
			name:             "provider tag values",
			filePathOrDir:    "testdata/terraform/provider.tf",
			cliArgs:          []string{"--tags", "Owner,Environment[dev,prod],Project,Source[my-repo]"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"Found Terraform default tags for provider aws: [Project, Source]", "No tag violations found"},
		},
		{
			name:             "variable tags",
			filePathOrDir:    "testdata/terraform/referenced_tags.tf",
			cliArgs:          []string{"--tags", "Owner,Environment"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "variable value",
			filePathOrDir:    "testdata/terraform/referenced_values.tf",
			cliArgs:          []string{"--tags", "Owner[jakebark],Environment"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "local value",
			filePathOrDir:    "testdata/terraform/referenced_values.tf",
			cliArgs:          []string{"--tags", "Owner,Environment[dev,prod]"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "variable value case insensitive",
			filePathOrDir:    "testdata/terraform/referenced_values.tf",
			cliArgs:          []string{"--tags", "Owner[Jakebark],Environment", "-c"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "local value case insensitive",
			filePathOrDir:    "testdata/terraform/referenced_values.tf",
			cliArgs:          []string{"--tags", "Owner,Environment[DEV,PROD]", "-c"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "interpolation",
			filePathOrDir:    "testdata/terraform/referenced_values.tf",
			cliArgs:          []string{"--tags", "Owner,Environment,Project[112233],Source[my-repo]"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "interpolation missing value",
			filePathOrDir:    "testdata/terraform/referenced_values.tf",
			cliArgs:          []string{"--tags", "Owner,Environment,Project[112233],Source[not-my-repo]"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{`aws_s3_bucket "this"`, "Missing tags: Source[not-my-repo]"},
		},
		{
			name:             "example repo",
			filePathOrDir:    "testdata/terraform/example_repo",
			cliArgs:          []string{"--tags", "Owner,Environment"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{"Found Terraform default tags for provider aws: [Environment, Owner, Source]", `aws_s3_bucket "baz"`, "Found 1 tag violation(s)"},
		},
		{
			name:             "ignore",
			filePathOrDir:    "testdata/terraform/ignore.tf",
			cliArgs:          []string{"--tags", "Owner,Environment,Project"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{`aws_s3_bucket "this" skipped`},
		},
		{
			name:             "ignore all",
			filePathOrDir:    "testdata/terraform/ignore_all.tf",
			cliArgs:          []string{"--tags", "Owner,Environment,Project"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{`aws_s3_bucket "this" skipped`},
		},
		{
			name:             "lower function",
			filePathOrDir:    "testdata/terraform/function.tf",
			cliArgs:          []string{"--tags", "Environment[dev]"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "skip file",
			filePathOrDir:    "testdata/terraform",
			cliArgs:          []string{"--tags", "Owner,Environment,Project", "-s", "testdata/terraform/tags.tf"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{`aws_s3_bucket "this"`, "Missing tags: Environment, Owner"},
		},
		{
			name:             "skip directory",
			filePathOrDir:    "testdata",
			cliArgs:          []string{"--tags", "Owner,Environment,Project", "-s", "testdata/terraform"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			argsForRun := append([]string{tc.filePathOrDir}, tc.cliArgs...)
			output, err, exitCode := runTagNag(t, argsForRun...)

			if tc.expectedError && err == nil {
				t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output)
			}
			if !tc.expectedError && err != nil {
				t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output)
			}

			if exitCode != tc.expectedExitCode {
				t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output)
			}

			for _, expectedStr := range tc.expectedOutput {
				if !strings.Contains(output, expectedStr) {
					t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output)
				}
			}
		})
	}
}

func TestCloudFormation(t *testing.T) {
	testCases := []testCases{
		{
			name:             "yml",
			filePathOrDir:    "testdata/cloudformation/tags.yml",
			cliArgs:          []string{"--tags", "Owner,Environment"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "yaml",
			filePathOrDir:    "testdata/cloudformation/tags.yaml",
			cliArgs:          []string{"--tags", "Owner,Environment"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "json",
			filePathOrDir:    "testdata/cloudformation/tags.json",
			cliArgs:          []string{"--tags", "Owner,Environment"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "yaml missing tags",
			filePathOrDir:    "testdata/cloudformation/tags.yml",
			cliArgs:          []string{"--tags", "Owner,Environment,Project"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"},
		},
		{
			name:             "json missing tags",
			filePathOrDir:    "testdata/cloudformation/tags.json",
			cliArgs:          []string{"--tags", "Owner,Environment,Project"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"},
		},
		{
			name:             "case insensitive",
			filePathOrDir:    "testdata/cloudformation/tags.yml",
			cliArgs:          []string{"--tags", "owner,environment", "-c"},
			expectedExitCode: 0,
			expectedError:    false,
			expectedOutput:   []string{"No tag violations found"},
		},
		{
			name:             "lower case",
			filePathOrDir:    "testdata/cloudformation/tags.yml",
			cliArgs:          []string{"--tags", "owner"},
			expectedExitCode: 1,
			expectedError:    true,
			expectedOutput:   []string{`AWS::S3::Bucket "this"`, "Missing tags: owner"},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			argsForRun := append([]string{tc.filePathOrDir}, tc.cliArgs...)
			output, err, exitCode := runTagNag(t, argsForRun...)

			if tc.expectedError && err == nil {
				t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output)
			}
			if !tc.expectedError && err != nil {
				t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output)
			}

			if exitCode != tc.expectedExitCode {
				t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output)
			}

			for _, expectedStr := range tc.expectedOutput {
				if !strings.Contains(output, expectedStr) {
					t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output)
				}
			}
		})
	}
}


================================================
FILE: testdata/cloudformation/tags.json
================================================
{
    "AWSTemplateFormatVersion": "2010-09-09",
    "Description": "single resource",
    "Resources": {
        "this": {
            "Type": "AWS::S3::Bucket",
            "Properties": {
                "BucketName": "test-bucket",
                "Tags": [
                    {
                        "Key": "Owner",
                        "Value": "jakebark"
                    },
                    {
                        "Key": "Environment",
                        "Value": "dev"
                    }
                ]
            }
        }
    }
}


================================================
FILE: testdata/cloudformation/tags.yaml
================================================
AWSTemplateFormatVersion: '2010-09-09'
Description: single resource
Resources:
  this:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: test-bucket
      Tags:
        - Key: Owner
          Value: jakebark
        - Key: Environment
          Value: dev


================================================
FILE: testdata/cloudformation/tags.yml
================================================
AWSTemplateFormatVersion: '2010-09-09'
Description: single resource
Resources:
  this:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: test-bucket
      Tags:
        - Key: Owner
          Value: jakebark
        - Key: Environment
          Value: dev


================================================
FILE: testdata/config/blank_config.yml
================================================


================================================
FILE: testdata/config/empty_settings.yml
================================================
tags:
  - key: Owner

settings:

skip:

================================================
FILE: testdata/config/full_config.yml
================================================
tags:
  - key: Owner
  - key: Environment
    values: [Dev, Test, Prod]
  - key: Project

settings:
  case_insensitive: true
  dry_run: false
  cfn_spec: "/path/to/spec.json"

skip:
  - "*.tmp"
  - ".terraform"
  - "test-data/**"

================================================
FILE: testdata/config/invalid_structure.yml
================================================
tags: "Owner,Project"


================================================
FILE: testdata/config/invalid_syntax.yml
================================================
tags:
  - key: Owner
    values: [Dev, Test


================================================
FILE: testdata/config/missing_tags.yml
================================================
settings:
  dry_run: true

skip:
  - "*.tmp"

================================================
FILE: testdata/config/tag_array.yml
================================================
tags:
  - key: Owner
    values: "not-an-array"

================================================
FILE: testdata/config/tag_keys.yml
================================================
tags:
  - key: Owner
  - key: Project

================================================
FILE: testdata/config/tag_values.yml
================================================
tags:
  - key: Owner
  - key: Environment
    values: [Dev, Test, Prod]
  - key: Project

================================================
FILE: testdata/config/yaml_extension.yaml
================================================
tags:
  - key: Owner

================================================
FILE: testdata/terraform/example_repo/locals.tf
================================================
locals {
  tags = {
    Owner       = "jakebark"
    Environment = var.environment
    Source      = "my-repo"
  }
}


================================================
FILE: testdata/terraform/example_repo/main.tf
================================================
resource "aws_s3_bucket" "foo" {
  bucket = "test-bucket"
}

resource "aws_s3_bucket" "bar" {
  bucket = "test-bucket"
  tags = {
    Project = "112233"
  }
}

resource "aws_s3_bucket" "baz" {
  bucket   = "test-bucket"
  provider = aws.west
}


================================================
FILE: testdata/terraform/example_repo/provider.tf
================================================
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = local.tags
  }
}

provider "aws" {
  alias  = "west"
  region = "us-west-1"
}


================================================
FILE: testdata/terraform/example_repo/variables.tf
================================================
variable "environment" {
  type    = string
  default = "dev"
}


================================================
FILE: testdata/terraform/functions.tf
================================================
resource "aws_s3_bucket" "this" {
  bucket = "test-bucket"
  tags = {
    Owner       = "jakebark"
    Environment = lower("Dev")
  }
}


================================================
FILE: testdata/terraform/ignore.tf
================================================
resource "aws_s3_bucket" "this" {
  #tag-nag ignore
  bucket = "test-bucket"
  tags = {
    Owner       = "jakebark"
    Environment = "dev"
  }
}


================================================
FILE: testdata/terraform/ignore_all.tf
================================================
#tag-nag ignore-all
resource "aws_s3_bucket" "this" {
  bucket = "test-bucket"
}


================================================
FILE: testdata/terraform/no_tags.tf
================================================
resource "aws_s3_bucket" "this" {
  bucket = "test-bucket"
}



================================================
FILE: testdata/terraform/provider.tf
================================================
provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      Project = "112233"
      Source  = "my-repo"
    }
  }
}

resource "aws_s3_bucket" "this" {
  bucket = "test-bucket"
  tags = {
    Owner       = "jakebark"
    Environment = "dev"
  }
}


================================================
FILE: testdata/terraform/referenced_tags.tf
================================================
resource "aws_s3_bucket" "this" {
  bucket = "test-bucket"
  tags   = var.tags
}

variable "tags" {
  type = map(string)
  default = {
    Owner       = "jakebark"
    Environment = "dev"
  }
}


================================================
FILE: testdata/terraform/referenced_values.tf
================================================
resource "aws_s3_bucket" "this" {
  bucket = "test-bucket"
  tags = {
    Owner       = var.owner
    Environment = local.environment
    Project     = "${local.project}"
    Source      = "${local.source}"
  }
}

variable "owner" {
  type    = string
  default = "jakebark"
}

variable "source" {
  type    = string
  default = "my-repo"
}

locals {
  environment = "dev"
  project     = "112233"
  source      = var.source
}


================================================
FILE: testdata/terraform/tags.tf
================================================
resource "aws_s3_bucket" "this" {
  bucket = "test-bucket"
  tags = {
    Owner       = "jakebark"
    Environment = "dev"
  }
}
Download .txt
gitextract_b5nvfihg/

├── .github/
│   ├── dependabot.yml
│   └── workflows/
│       ├── docker-hub.yml
│       ├── go_tests.yml
│       └── semgrep.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── examples/
│   ├── .tag-nag.yml
│   ├── codebuild.yml
│   ├── github.yml
│   └── gitlab.yml
├── go.mod
├── go.sum
├── internal/
│   ├── cloudformation/
│   │   ├── helpers.go
│   │   ├── helpers_test.go
│   │   ├── process.go
│   │   ├── resources.go
│   │   ├── resources_test.go
│   │   ├── scan.go
│   │   ├── spec_loader.go
│   │   └── spec_loader_test.go
│   ├── config/
│   │   └── config.go
│   ├── inputs/
│   │   ├── inputs.go
│   │   ├── inputs_test.go
│   │   ├── loader.go
│   │   └── loader_test.go
│   ├── output/
│   │   ├── formatter.go
│   │   ├── formatter_test.go
│   │   ├── json.go
│   │   ├── json_test.go
│   │   ├── junit.go
│   │   ├── junit_test.go
│   │   ├── process.go
│   │   ├── sarif.go
│   │   ├── sarif_test.go
│   │   ├── text.go
│   │   └── text_test.go
│   ├── shared/
│   │   ├── helpers.go
│   │   ├── helpers_test.go
│   │   └── types.go
│   └── terraform/
│       ├── default_tags.go
│       ├── default_tags_test.go
│       ├── helpers.go
│       ├── helpers_test.go
│       ├── process.go
│       ├── references.go
│       ├── resources.go
│       ├── resources_test.go
│       ├── scan.go
│       ├── scan_test.go
│       └── types.go
├── main.go
├── main_test.go
└── testdata/
    ├── cloudformation/
    │   ├── tags.json
    │   ├── tags.yaml
    │   └── tags.yml
    ├── config/
    │   ├── blank_config.yml
    │   ├── empty_settings.yml
    │   ├── full_config.yml
    │   ├── invalid_structure.yml
    │   ├── invalid_syntax.yml
    │   ├── missing_tags.yml
    │   ├── tag_array.yml
    │   ├── tag_keys.yml
    │   ├── tag_values.yml
    │   └── yaml_extension.yaml
    └── terraform/
        ├── example_repo/
        │   ├── locals.tf
        │   ├── main.tf
        │   ├── provider.tf
        │   └── variables.tf
        ├── functions.tf
        ├── ignore.tf
        ├── ignore_all.tf
        ├── no_tags.tf
        ├── provider.tf
        ├── referenced_tags.tf
        ├── referenced_values.tf
        └── tags.tf
Download .txt
SYMBOL INDEX (118 symbols across 40 files)

FILE: internal/cloudformation/helpers.go
  function mapNodes (line 12) | func mapNodes(node *yaml.Node) map[string]*yaml.Node {
  function findMapNode (line 26) | func findMapNode(node *yaml.Node, key string) *yaml.Node {
  function parseYAML (line 44) | func parseYAML(filePath string) (*yaml.Node, error) {
  function skipResource (line 61) | func skipResource(node *yaml.Node, lines []string) bool {

FILE: internal/cloudformation/helpers_test.go
  function createYamlNode (line 10) | func createYamlNode(t *testing.T, yamlStr string) *yaml.Node {
  function testMapNodes (line 23) | func testMapNodes(t *testing.T) {
  function testFindMapNode (line 62) | func testFindMapNode(t *testing.T) {

FILE: internal/cloudformation/process.go
  function ProcessDirectory (line 16) | func ProcessDirectory(directoryPath string, requiredTags map[string][]st...
  function processFile (line 78) | func processFile(filePath string, requiredTags shared.TagMap, caseInsens...

FILE: internal/cloudformation/resources.go
  function checkResourcesForTags (line 13) | func checkResourcesForTags(resourcesMapping map[string]*yaml.Node, requi...
  function extractTagMap (line 63) | func extractTagMap(properties map[string]any, caseInsensitive bool) (sha...

FILE: internal/cloudformation/resources_test.go
  function TestExtractTagMap (line 10) | func TestExtractTagMap(t *testing.T) {

FILE: internal/cloudformation/scan.go
  function scan (line 10) | func scan(directoryPath string) (bool, error) {

FILE: internal/cloudformation/spec_loader.go
  type cfnSpec (line 11) | type cfnSpec struct
  type cfnResourceType (line 16) | type cfnResourceType struct
    method isTaggable (line 34) | func (rt cfnResourceType) isTaggable(specData *cfnSpec) bool {
  type cfnProperty (line 20) | type cfnProperty struct
  type cfnPropertyType (line 29) | type cfnPropertyType struct
  function loadTaggableResourcesFromSpec (line 51) | func loadTaggableResourcesFromSpec(specFilePath string) (map[string]bool...

FILE: internal/cloudformation/spec_loader_test.go
  function createTestSpec (line 9) | func createTestSpec() *cfnSpec {
  function TestIsTaggable (line 31) | func TestIsTaggable(t *testing.T) {

FILE: internal/config/config.go
  constant TagNagIgnore (line 9) | TagNagIgnore       = "#tag-nag ignore"
  constant TagNagIgnoreAll (line 10) | TagNagIgnoreAll    = "#tag-nag ignore-all"
  constant DefaultConfigFile (line 11) | DefaultConfigFile  = ".tag-nag.yml"
  constant AltConfigFile (line 12) | AltConfigFile      = ".tag-nag.yaml"

FILE: internal/inputs/inputs.go
  type UserInput (line 13) | type UserInput struct
  function ParseFlags (line 24) | func ParseFlags() UserInput {
  function parseTags (line 112) | func parseTags(input string) (shared.TagMap, error) {
  function parseTag (line 131) | func parseTag(tagComponent string) (key string, values []string, err err...
  function splitTags (line 173) | func splitTags(input string) []string {

FILE: internal/inputs/inputs_test.go
  function TestParseTags (line 10) | func TestParseTags(t *testing.T) {
  function TestSplitTags (line 137) | func TestSplitTags(t *testing.T) {

FILE: internal/inputs/loader.go
  type Config (line 12) | type Config struct
    method convertToTagMap (line 59) | func (c *Config) convertToTagMap() shared.TagMap {
  type TagDefinition (line 18) | type TagDefinition struct
  type Settings (line 23) | type Settings struct
  function FindAndLoadConfigFile (line 31) | func FindAndLoadConfigFile() (*Config, error) {
  function processConfigFile (line 44) | func processConfigFile(path string) (*Config, error) {

FILE: internal/inputs/loader_test.go
  function TestProcessConfigFile (line 11) | func TestProcessConfigFile(t *testing.T) {
  function TestFindAndLoadConfigFile (line 203) | func TestFindAndLoadConfigFile(t *testing.T) {
  function TestConvertToTagMap (line 337) | func TestConvertToTagMap(t *testing.T) {

FILE: internal/output/formatter.go
  type Formatter (line 5) | type Formatter interface
  function GetFormatter (line 10) | func GetFormatter(format shared.OutputFormat) Formatter {

FILE: internal/output/formatter_test.go
  function TestGetFormatter (line 10) | func TestGetFormatter(t *testing.T) {

FILE: internal/output/json.go
  type JSONFormatter (line 10) | type JSONFormatter struct
    method Format (line 26) | func (f *JSONFormatter) Format(violations []shared.Violation) ([]byte,...
  type JSONOutput (line 13) | type JSONOutput struct
  type Summary (line 19) | type Summary struct

FILE: internal/output/json_test.go
  function TestJSONFormatter_Format (line 10) | func TestJSONFormatter_Format(t *testing.T) {

FILE: internal/output/junit.go
  type JUnitXMLFormatter (line 11) | type JUnitXMLFormatter struct
    method Format (line 34) | func (f *JUnitXMLFormatter) Format(violations []shared.Violation) ([]b...
  type TestSuite (line 13) | type TestSuite struct
  type TestCase (line 21) | type TestCase struct
  type Failure (line 28) | type Failure struct

FILE: internal/output/junit_test.go
  function TestJUnitXMLFormatter_Format (line 11) | func TestJUnitXMLFormatter_Format(t *testing.T) {

FILE: internal/output/process.go
  function ProcessOutput (line 12) | func ProcessOutput(violations []shared.Violation, format shared.OutputFo...

FILE: internal/output/sarif.go
  type SARIFFormatter (line 11) | type SARIFFormatter struct
    method Format (line 51) | func (f *SARIFFormatter) Format(violations []shared.Violation) ([]byte...
  type sarifOutput (line 13) | type sarifOutput struct
  type sarifRun (line 19) | type sarifRun struct
  type sarifResult (line 29) | type sarifResult struct
  type sarifLocation (line 39) | type sarifLocation struct

FILE: internal/output/sarif_test.go
  function TestSARIFFormatter_Format (line 10) | func TestSARIFFormatter_Format(t *testing.T) {

FILE: internal/output/text.go
  type TextFormatter (line 10) | type TextFormatter struct
    method Format (line 13) | func (f *TextFormatter) Format(violations []shared.Violation) ([]byte,...

FILE: internal/output/text_test.go
  function TestTextFormatter_Format (line 10) | func TestTextFormatter_Format(t *testing.T) {

FILE: internal/shared/helpers.go
  function FilterMissingTags (line 10) | func FilterMissingTags(requiredTags TagMap, effectiveTags TagMap, caseIn...
  function matchTagKey (line 39) | func matchTagKey(requiredKey string, effectiveTags TagMap, caseInsensiti...
  function matchTagValue (line 49) | func matchTagValue(allowedValues []string, effectiveValues []string, cas...
  function NormalizeCase (line 68) | func NormalizeCase(input string, caseInsensitive bool) string {
  function CompareCase (line 76) | func CompareCase(first, second string, caseInsensitive bool) bool {

FILE: internal/shared/helpers_test.go
  function TestFilterMissingTags (line 9) | func TestFilterMissingTags(t *testing.T) {
  function TestNormalizeCase (line 147) | func TestNormalizeCase(t *testing.T) {
  function TestCompareCase (line 214) | func TestCompareCase(t *testing.T) {

FILE: internal/shared/types.go
  type TagMap (line 3) | type TagMap
  type Violation (line 5) | type Violation struct
  type OutputFormat (line 14) | type OutputFormat
  constant OutputFormatText (line 17) | OutputFormatText     OutputFormat = "text"
  constant OutputFormatJSON (line 18) | OutputFormatJSON     OutputFormat = "json"
  constant OutputFormatJUnitXML (line 19) | OutputFormatJUnitXML OutputFormat = "junit-xml"
  constant OutputFormatSARIF (line 20) | OutputFormatSARIF    OutputFormat = "sarif"

FILE: internal/terraform/default_tags.go
  function processDefaultTags (line 16) | func processDefaultTags(tfFiles []tfFile, tfContext *TerraformContext, c...
  function processProviders (line 43) | func processProviders(body *hclsyntax.Body, defaultTags *DefaultTags, tf...
  function getProviderID (line 64) | func getProviderID(block *hclsyntax.Block, caseInsensitive bool) string {
  function normalizeProviderID (line 79) | func normalizeProviderID(providerName, alias string, caseInsensitive boo...
  function getDefaultTags (line 91) | func getDefaultTags(block *hclsyntax.Block, tfContext *TerraformContext,...

FILE: internal/terraform/default_tags_test.go
  function TestNormalizeProviderID (line 8) | func TestNormalizeProviderID(t *testing.T) {

FILE: internal/terraform/helpers.go
  function traversalToString (line 19) | func traversalToString(expr hcl.Expression, caseInsensitive bool) string {
  function mergeTags (line 46) | func mergeTags(tagMaps ...shared.TagMap) shared.TagMap {
  function SkipResource (line 57) | func SkipResource(block *hclsyntax.Block, lines []string) bool {
  function convertCtyValueToString (line 67) | func convertCtyValueToString(val cty.Value) (string, error) {
  function loadTaggableResources (line 104) | func loadTaggableResources(providerAddr string) map[string]bool {

FILE: internal/terraform/helpers_test.go
  function TestTraversalToString (line 13) | func TestTraversalToString(t *testing.T) {
  function TestMergeTags (line 56) | func TestMergeTags(t *testing.T) {
  function TestConvertCtyValueToString (line 115) | func TestConvertCtyValueToString(t *testing.T) {

FILE: internal/terraform/process.go
  type tfFile (line 19) | type tfFile struct
  function ProcessDirectory (line 25) | func ProcessDirectory(directoryPath string, requiredTags map[string][]st...
  function collectFiles (line 72) | func collectFiles(directoryPath string, skip []string) ([]tfFile, error) {
  function skipDirectories (line 97) | func skipDirectories(path string, info os.FileInfo, skip []string) bool {
  function processFile (line 117) | func processFile(filePath string, requiredTags shared.TagMap, defaultTag...

FILE: internal/terraform/references.go
  function buildTagContext (line 17) | func buildTagContext(directoryPath string) (*TerraformContext, error) {

FILE: internal/terraform/resources.go
  function checkResourcesForTags (line 13) | func checkResourcesForTags(body *hclsyntax.Body, requiredTags shared.Tag...
  function getResourceProvider (line 73) | func getResourceProvider(block *hclsyntax.Block, caseInsensitive bool) s...
  function findTags (line 95) | func findTags(block *hclsyntax.Block, tfContext *TerraformContext, caseI...

FILE: internal/terraform/resources_test.go
  function TestCheckResourcesForTags_Taggability (line 17) | func TestCheckResourcesForTags_Taggability(t *testing.T) {
  function sortViolations (line 140) | func sortViolations(violations []shared.Violation) {

FILE: internal/terraform/scan.go
  function scan (line 10) | func scan(directoryPath string) (bool, error) {

FILE: internal/terraform/scan_test.go
  function TestScanFunction (line 11) | func TestScanFunction(t *testing.T) {

FILE: internal/terraform/types.go
  type DefaultTags (line 8) | type DefaultTags struct
  type TerraformContext (line 13) | type TerraformContext struct

FILE: main.go
  function main (line 13) | func main() {

FILE: main_test.go
  constant binaryName (line 12) | binaryName = "tag-nag"
  type testCases (line 14) | type testCases struct
  function TestMain (line 23) | func TestMain(m *testing.M) {
  function runTagNag (line 35) | func runTagNag(t *testing.T, args ...string) (string, error, int) {
  function TestInputs (line 63) | func TestInputs(t *testing.T) {
  function TestTerraform (line 121) | func TestTerraform(t *testing.T) {
  function TestCloudFormation (line 350) | func TestCloudFormation(t *testing.T) {
Condensed preview — 79 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (159K chars).
[
  {
    "path": ".github/dependabot.yml",
    "chars": 408,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n    open-p"
  },
  {
    "path": ".github/workflows/docker-hub.yml",
    "chars": 958,
    "preview": "name: publish to docker hub\n\non:\n  release:\n    types: [published]\n\njobs:\n  build-and-push:\n    runs-on: ubuntu-latest\n "
  },
  {
    "path": ".github/workflows/go_tests.yml",
    "chars": 506,
    "preview": "name: go test\n\non:\n  pull_request:\n    branches: [ main ] \n\njobs:\n  test: \n    runs-on: ${{ matrix.os }}\n    strategy:\n "
  },
  {
    "path": ".github/workflows/semgrep.yml",
    "chars": 278,
    "preview": "name: semgrep\n\non:\n  pull_request:\n    branches: [ main ] \n\njobs:\n  semgrep:\n    runs-on: ubuntu-latest\n    \n    contain"
  },
  {
    "path": ".gitignore",
    "chars": 281,
    "preview": "*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n*.test\n**/test/*\ntodo.md\n\n# Output of the go coverage tool, specifically when used with"
  },
  {
    "path": "Dockerfile",
    "chars": 867,
    "preview": "FROM golang:1.22 AS builder\n\nWORKDIR /app\n\nCOPY go.mod go.sum ./\nRUN go mod download \nRUN go mod tidy\n\nCOPY . .\n\nRUN go "
  },
  {
    "path": "LICENSE",
    "chars": 1065,
    "preview": "MIT License\n\nCopyright (c) 2025 jakebark\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\no"
  },
  {
    "path": "README.md",
    "chars": 3151,
    "preview": "# tag-nag\n\n<img src=\"./img/demo.gif\" width=\"650\">\n\nValidate AWS tags in Terraform and CloudFormation.  \n\n## Installation"
  },
  {
    "path": "examples/.tag-nag.yml",
    "chars": 350,
    "preview": "tags:\n  - key: Owner\n  - key: Environment\n    values: [Dev, Test, Prod]\n  - key: Project\n\nsettings:\n  case_insensitive: "
  },
  {
    "path": "examples/codebuild.yml",
    "chars": 220,
    "preview": "## codebuild image 'jakebar/tag-nag:$latest'\nversion: 0.2\n\nphases:\n  build:\n    commands:\n      - cd \"$CODEBUILD_SRC_DIR"
  },
  {
    "path": "examples/github.yml",
    "chars": 410,
    "preview": "name: tag-nag\n\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  tag-nag:\n    runs-on: ubuntu-latest\n    \n    container:"
  },
  {
    "path": "examples/gitlab.yml",
    "chars": 186,
    "preview": "stages:\n  - validate\n\ntag-nag:\n  stage: validate\n  image: jakebark/tag-nag:latest\n  script:\n    - terraform init -backen"
  },
  {
    "path": "go.mod",
    "chars": 649,
    "preview": "module github.com/jakebark/tag-nag\n\ngo 1.22\n\nrequire (\n\tgithub.com/hashicorp/hcl/v2 v2.23.0\n\tgithub.com/spf13/pflag v1.0"
  },
  {
    "path": "go.sum",
    "chars": 3190,
    "preview": "github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=\ngithub.com/agext/levenshtein v1.2.1/"
  },
  {
    "path": "internal/cloudformation/helpers.go",
    "chars": 1525,
    "preview": "package cloudformation\n\nimport (\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/config\"\n\t\"gopkg.in/yaml.v3\"\n)\n"
  },
  {
    "path": "internal/cloudformation/helpers_test.go",
    "chars": 3443,
    "preview": "package cloudformation\n\nimport (\n\t\"testing\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\n// helper to create a yaml.Node for testing\nfunc cre"
  },
  {
    "path": "internal/cloudformation/process.go",
    "chars": 3024,
    "preview": "package cloudformation\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-na"
  },
  {
    "path": "internal/cloudformation/resources.go",
    "chars": 2732,
    "preview": "package cloudformation\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"gopkg.in/yam"
  },
  {
    "path": "internal/cloudformation/resources_test.go",
    "chars": 3038,
    "preview": "package cloudformation\n\nimport (\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/jakebark/tag-nag/internal/shar"
  },
  {
    "path": "internal/cloudformation/scan.go",
    "chars": 643,
    "preview": "package cloudformation\n\nimport (\n\t\"errors\"\n\t\"io/fs\"\n\t\"path/filepath\"\n)\n\n// scan looks for cfn files\nfunc scan(directoryP"
  },
  {
    "path": "internal/cloudformation/spec_loader.go",
    "chars": 2414,
    "preview": "package cloudformation\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"strings\"\n)\n\ntype cfnSpec struct {\n\tResourceTypes"
  },
  {
    "path": "internal/cloudformation/spec_loader_test.go",
    "chars": 1528,
    "preview": "package cloudformation\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\n// helper to create a test spec file\nfunc createTestSpec"
  },
  {
    "path": "internal/config/config.go",
    "chars": 2503,
    "preview": "package config\n\nimport (\n\t\"github.com/zclconf/go-cty/cty/function\"\n\t\"github.com/zclconf/go-cty/cty/function/stdlib\"\n)\n\nc"
  },
  {
    "path": "internal/inputs/inputs.go",
    "chars": 5573,
    "preview": "package inputs\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"github.com/spf"
  },
  {
    "path": "internal/inputs/inputs_test.go",
    "chars": 3687,
    "preview": "package inputs\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestParseTags(t *"
  },
  {
    "path": "internal/inputs/loader.go",
    "chars": 1643,
    "preview": "package inputs\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/jakebark/tag-nag/internal/config\"\n\t\"github.com/jakebark/tag-nag/inte"
  },
  {
    "path": "internal/inputs/loader_test.go",
    "chars": 10396,
    "preview": "package inputs\n\nimport (\n\t\"os\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestProcess"
  },
  {
    "path": "internal/output/formatter.go",
    "chars": 569,
    "preview": "package output\n\nimport \"github.com/jakebark/tag-nag/internal/shared\"\n\ntype Formatter interface {\n\tFormat(violations []sh"
  },
  {
    "path": "internal/output/formatter_test.go",
    "chars": 1219,
    "preview": "package output\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestGetFormatter("
  },
  {
    "path": "internal/output/json.go",
    "chars": 1031,
    "preview": "package output\n\nimport (\n\t\"encoding/json\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\n// JSONFormatter implements"
  },
  {
    "path": "internal/output/json_test.go",
    "chars": 1879,
    "preview": "package output\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestJSONFor"
  },
  {
    "path": "internal/output/junit.go",
    "chars": 1459,
    "preview": "package output\n\nimport (\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\ntype JUnit"
  },
  {
    "path": "internal/output/junit_test.go",
    "chars": 2401,
    "preview": "package output\n\nimport (\n\t\"encoding/xml\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc T"
  },
  {
    "path": "internal/output/process.go",
    "chars": 897,
    "preview": "package output\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\n// ProcessOutput handles"
  },
  {
    "path": "internal/output/sarif.go",
    "chars": 2034,
    "preview": "package output\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\ntype SARI"
  },
  {
    "path": "internal/output/sarif_test.go",
    "chars": 2577,
    "preview": "package output\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestSARIFFo"
  },
  {
    "path": "internal/output/text.go",
    "chars": 939,
    "preview": "package output\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\ntype TextFormatter struct{"
  },
  {
    "path": "internal/output/text_test.go",
    "chars": 2675,
    "preview": "package output\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestTextFormatter"
  },
  {
    "path": "internal/shared/helpers.go",
    "chars": 2267,
    "preview": "package shared\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n)\n\n// FilterMissingTags checks effectiveTags against requiredTags\nfun"
  },
  {
    "path": "internal/shared/helpers_test.go",
    "chars": 8405,
    "preview": "package shared\n\nimport (\n\t\"reflect\"\n\t\"sort\"\n\t\"testing\"\n)\n\nfunc TestFilterMissingTags(t *testing.T) {\n\ttestCases := []str"
  },
  {
    "path": "internal/shared/types.go",
    "chars": 547,
    "preview": "package shared\n\ntype TagMap map[string][]string\n\ntype Violation struct {\n\tResourceType string   `json:\"resource_type\"`\n\t"
  },
  {
    "path": "internal/terraform/default_tags.go",
    "chars": 4179,
    "preview": "package terraform\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2/hclparse\"\n\t\"github.com/hashi"
  },
  {
    "path": "internal/terraform/default_tags_test.go",
    "chars": 747,
    "preview": "package terraform\n\nimport (\n\t\"testing\"\n)\n\n// Test for normalizeProviderID\nfunc TestNormalizeProviderID(t *testing.T) {\n\t"
  },
  {
    "path": "internal/terraform/helpers.go",
    "chars": 3774,
    "preview": "package terraform\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os/exec\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2\"\n\t\"githu"
  },
  {
    "path": "internal/terraform/helpers_test.go",
    "chars": 4187,
    "preview": "package terraform\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/hashicorp/hcl/v2\"\n\t\"github.com/hashicorp/hcl/v2/hclsynta"
  },
  {
    "path": "internal/terraform/process.go",
    "chars": 3810,
    "preview": "package terraform\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2\"\n\t\"github."
  },
  {
    "path": "internal/terraform/references.go",
    "chars": 3627,
    "preview": "package terraform\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/hashicorp/hcl/v2\"\n\t\"github.com/hashicorp/"
  },
  {
    "path": "internal/terraform/resources.go",
    "chars": 4202,
    "preview": "package terraform\n\nimport (\n\t\"log\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2/hclsyntax\"\n\t\"github.com/jakebark/tag-nag/in"
  },
  {
    "path": "internal/terraform/resources_test.go",
    "chars": 5627,
    "preview": "package terraform\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/google/go-cmp/cm"
  },
  {
    "path": "internal/terraform/scan.go",
    "chars": 574,
    "preview": "package terraform\n\nimport (\n\t\"errors\"\n\t\"io/fs\"\n\t\"path/filepath\"\n)\n\n// scan looks for tf files\nfunc scan(directoryPath st"
  },
  {
    "path": "internal/terraform/scan_test.go",
    "chars": 2655,
    "preview": "package terraform\n\nimport (\n\t\"errors\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestScanFunction(t *testing.T) "
  },
  {
    "path": "internal/terraform/types.go",
    "chars": 240,
    "preview": "package terraform\n\nimport (\n\t\"github.com/hashicorp/hcl/v2\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\ntype Defaul"
  },
  {
    "path": "main.go",
    "chars": 1076,
    "preview": "package main\n\nimport (\n\t\"log\"\n\n\t\"github.com/jakebark/tag-nag/internal/cloudformation\"\n\t\"github.com/jakebark/tag-nag/inte"
  },
  {
    "path": "main_test.go",
    "chars": 14642,
    "preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"testing\"\n)\n\nconst binaryName = \"tag-nag\"\n\ntype test"
  },
  {
    "path": "testdata/cloudformation/tags.json",
    "chars": 569,
    "preview": "{\n    \"AWSTemplateFormatVersion\": \"2010-09-09\",\n    \"Description\": \"single resource\",\n    \"Resources\": {\n        \"this\":"
  },
  {
    "path": "testdata/cloudformation/tags.yaml",
    "chars": 266,
    "preview": "AWSTemplateFormatVersion: '2010-09-09'\nDescription: single resource\nResources:\n  this:\n    Type: AWS::S3::Bucket\n    Pro"
  },
  {
    "path": "testdata/cloudformation/tags.yml",
    "chars": 266,
    "preview": "AWSTemplateFormatVersion: '2010-09-09'\nDescription: single resource\nResources:\n  this:\n    Type: AWS::S3::Bucket\n    Pro"
  },
  {
    "path": "testdata/config/blank_config.yml",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "testdata/config/empty_settings.yml",
    "chars": 38,
    "preview": "tags:\n  - key: Owner\n\nsettings:\n\nskip:"
  },
  {
    "path": "testdata/config/full_config.yml",
    "chars": 229,
    "preview": "tags:\n  - key: Owner\n  - key: Environment\n    values: [Dev, Test, Prod]\n  - key: Project\n\nsettings:\n  case_insensitive: "
  },
  {
    "path": "testdata/config/invalid_structure.yml",
    "chars": 22,
    "preview": "tags: \"Owner,Project\"\n"
  },
  {
    "path": "testdata/config/invalid_syntax.yml",
    "chars": 44,
    "preview": "tags:\n  - key: Owner\n    values: [Dev, Test\n"
  },
  {
    "path": "testdata/config/missing_tags.yml",
    "chars": 44,
    "preview": "settings:\n  dry_run: true\n\nskip:\n  - \"*.tmp\""
  },
  {
    "path": "testdata/config/tag_array.yml",
    "chars": 47,
    "preview": "tags:\n  - key: Owner\n    values: \"not-an-array\""
  },
  {
    "path": "testdata/config/tag_keys.yml",
    "chars": 37,
    "preview": "tags:\n  - key: Owner\n  - key: Project"
  },
  {
    "path": "testdata/config/tag_values.yml",
    "chars": 88,
    "preview": "tags:\n  - key: Owner\n  - key: Environment\n    values: [Dev, Test, Prod]\n  - key: Project"
  },
  {
    "path": "testdata/config/yaml_extension.yaml",
    "chars": 20,
    "preview": "tags:\n  - key: Owner"
  },
  {
    "path": "testdata/terraform/example_repo/locals.tf",
    "chars": 117,
    "preview": "locals {\n  tags = {\n    Owner       = \"jakebark\"\n    Environment = var.environment\n    Source      = \"my-repo\"\n  }\n}\n"
  },
  {
    "path": "testdata/terraform/example_repo/main.tf",
    "chars": 244,
    "preview": "resource \"aws_s3_bucket\" \"foo\" {\n  bucket = \"test-bucket\"\n}\n\nresource \"aws_s3_bucket\" \"bar\" {\n  bucket = \"test-bucket\"\n "
  },
  {
    "path": "testdata/terraform/example_repo/provider.tf",
    "chars": 263,
    "preview": "terraform {\n  required_providers {\n    aws = {\n      source  = \"hashicorp/aws\"\n      version = \"~> 5.0\"\n    }\n  }\n}\n\npro"
  },
  {
    "path": "testdata/terraform/example_repo/variables.tf",
    "chars": 64,
    "preview": "variable \"environment\" {\n  type    = string\n  default = \"dev\"\n}\n"
  },
  {
    "path": "testdata/terraform/functions.tf",
    "chars": 136,
    "preview": "resource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n  tags = {\n    Owner       = \"jakebark\"\n    Environment = low"
  },
  {
    "path": "testdata/terraform/ignore.tf",
    "chars": 147,
    "preview": "resource \"aws_s3_bucket\" \"this\" {\n  #tag-nag ignore\n  bucket = \"test-bucket\"\n  tags = {\n    Owner       = \"jakebark\"\n   "
  },
  {
    "path": "testdata/terraform/ignore_all.tf",
    "chars": 81,
    "preview": "#tag-nag ignore-all\nresource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n}\n"
  },
  {
    "path": "testdata/terraform/no_tags.tf",
    "chars": 62,
    "preview": "resource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n}\n\n"
  },
  {
    "path": "testdata/terraform/provider.tf",
    "chars": 263,
    "preview": "provider \"aws\" {\n  region = \"us-east-1\"\n  default_tags {\n    tags = {\n      Project = \"112233\"\n      Source  = \"my-repo\""
  },
  {
    "path": "testdata/terraform/referenced_tags.tf",
    "chars": 194,
    "preview": "resource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n  tags   = var.tags\n}\n\nvariable \"tags\" {\n  type = map(string)"
  },
  {
    "path": "testdata/terraform/referenced_values.tf",
    "chars": 427,
    "preview": "resource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n  tags = {\n    Owner       = var.owner\n    Environment = loca"
  },
  {
    "path": "testdata/terraform/tags.tf",
    "chars": 129,
    "preview": "resource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n  tags = {\n    Owner       = \"jakebark\"\n    Environment = \"de"
  }
]

About this extraction

This page contains the full source code of the jakebark/tag-nag GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 79 files (133.5 KB), approximately 41.3k tokens, and a symbol index with 118 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!