Repository: mgechev/revive

Issue:
Rule to check matching of package directory names
**Is your feature request related to a problem? Please describe.**

It's considered good practice to name a directory the same as its package. For example, I recently discovered [here](https://github.com/kubernetes-sigs/kueue/pull/5954) that the package is named `maps`, but the directory is named `ptr`, which is misleading.

**Describe the solution you'd like**

Add a new rule, `package-directory-mismatch`, to detect such cases.

**Describe alternatives you've considered**

Write a tool with AI to detect this:

<details><summary>Details</summary>
<p>

```go
package main

import (
	"fmt"
	"go/parser"
	"go/token"
	"log"
	"os"
	"path/filepath"
	"strings"
)

type Mismatch struct {
	Path        string
	FolderName  string
	PackageName string
	Files       []string
}

func main() {
	if len(os.Args) < 2 {
		fmt.Println("Usage: go run main.go <project-path>")
		fmt.Println("Example: go run main.go ./my-project")
		os.Exit(1)
	}

	projectPath := os.Args[1]

	mismatches, err := findPackageFolderMismatches(projectPath)
	if err != nil {
		log.Fatal(err)
	}

	if len(mismatches) == 0 {
		fmt.Println("✅ No package-folder name mismatches found!")
		return
	}

	fmt.Printf("🔍 Found %d package-folder name mismatches:\n\n", len(mismatches))

	for _, mismatch := range mismatches {
		fmt.Printf("❌ Mismatch in: %s\n", mismatch.Path)
		fmt.Printf("   Folder name: %s\n", mismatch.FolderName)
		fmt.Printf("   Package name: %s\n", mismatch.PackageName)
		fmt.Printf("   Files: %s\n", strings.Join(mismatch.Files, ", "))
		fmt.Println()
	}
}

func findPackageFolderMismatches(rootPath string) ([]Mismatch, error) {
	var mismatches []Mismatch

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

		// Skip non-directories
		if !info.IsDir() {
			return nil
		}

		// Skip common non-Go directories
		if shouldSkipDir(info.Name()) {
			return filepath.SkipDir
		}

		// Check if this directory contains Go files
		goFiles, err := findGoFiles(path)
		if err != nil {
			return err
		}

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

		// Get package name from Go files
		packageName, err := getPackageName(path, goFiles)
		if err != nil {
			return err
		}

		// Skip if no package name found (shouldn't happen with Go files)
		if packageName == "" {
			return nil
		}

		// Get folder name
		folderName := filepath.Base(path)

		// Check for mismatch (with some exceptions)
		if shouldCheckMismatch(folderName, packageName) {
			mismatches = append(mismatches, Mismatch{
				Path:        path,
				FolderName:  folderName,
				PackageName: packageName,
				Files:       goFiles,
			})
		}

		return nil
	})

	return mismatches, err
}

func shouldSkipDir(dirName string) bool {
	skipDirs := []string{
		".git", ".github", ".vscode", ".idea",
		"node_modules", "vendor", "build", "bin", "dist",
		".DS_Store", "Thumbs.db",
	}

	for _, skip := range skipDirs {
		if dirName == skip {
			return true
		}
	}

	// Skip hidden directories
	if strings.HasPrefix(dirName, ".") {
		return true
	}

	return false
}

func findGoFiles(dir string) ([]string, error) {
	var goFiles []string

	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}

	for _, entry := range entries {
		if entry.IsDir() {
			continue
		}

		if strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") {
			goFiles = append(goFiles, entry.Name())
		}
	}

	return goFiles, nil
}

func getPackageName(dir string, goFiles []string) (string, error) {
	if len(goFiles) == 0 {
		return "", nil
	}

	// Parse the first Go file to get package name
	filePath := filepath.Join(dir, goFiles[0])

	fset := token.NewFileSet()
	node, err := parser.ParseFile(fset, filePath, nil, parser.PackageClauseOnly)
	if err != nil {
		return "", fmt.Errorf("failed to parse %s: %v", filePath, err)
	}

	if node.Name == nil {
		return "", fmt.Errorf("no package name found in %s", filePath)
	}

	packageName := node.Name.Name

	// Verify all Go files in the directory have the same package name
	for _, goFile := range goFiles[1:] {
		filePath := filepath.Join(dir, goFile)
		node, err := parser.ParseFile(fset, filePath, nil, parser.PackageClauseOnly)
		if err != nil {
			continue // Skip files that can't be parsed
		}

		if node.Name != nil && node.Name.Name != packageName {
			return "", fmt.Errorf("inconsistent package names in %s: found %s and %s",
				dir, packageName, node.Name.Name)
		}
	}

	return packageName, nil
}

func shouldCheckMismatch(folderName, packageName string) bool {
	// Skip if names match
	if folderName == packageName {
		return false
	}

	// Skip main packages (can be in any folder)
	if packageName == "main" {
		return false
	}

	// Skip test packages
	if strings.HasSuffix(packageName, "_test") {
		return false
	}

	// Skip internal packages with special naming conventions
	if isInternalPackage(folderName, packageName) {
		return false
	}

	// Report all other mismatches
	return true
}

func isInternalPackage(folderName, packageName string) bool {
	// Some common patterns where folder and package names legitimately differ
	commonPatterns := map[string][]string{
		"cmd":      {"main"},
		"internal": {}, // internal packages can have different names
		"vendor":   {}, // vendor packages can have different names
	}

	// Check if folder is in a common pattern directory
	for pattern := range commonPatterns {
		if strings.Contains(folderName, pattern) {
			return true
		}
	}

	// Check for version suffixes (e.g., v1, v2)
	if strings.Contains(folderName, "v") &&
		(strings.Contains(packageName, folderName) || strings.Contains(folderName, packageName)) {
		return true
	}

	return false
}
```

</p>
</details> 

**Additional context**

IntellijIDEA has [a similar check](https://www.jetbrains.com/help/inspectopedia/PackageDirectoryMismatch.html) for Kotlin.


Discussion:

Solution patch:
diff --git a/README.md b/README.md
index c12da86..21d8056 100644
--- a/README.md
+++ b/README.md
@@ -570,6 +570,7 @@ List of all available rules. The rules ported from `golint` are left unchanged a
 | [`nested-structs`](./RULES_DESCRIPTIONS.md#nested-structs)          |  n/a   |  Warns on structs within structs |    no    |  no   |
 | [`optimize-operands-order`](./RULES_DESCRIPTIONS.md#optimize-operands-order)          |  n/a   |  Checks inefficient conditional expressions |    no    |  no   |
 | [`package-comments`](./RULES_DESCRIPTIONS.md#package-comments)    |  n/a   | Package commenting conventions.                                  |   yes    |  no   |
+| [`package-directory-mismatch`](./RULES_DESCRIPTIONS.md#package-directory-mismatch)    | string | Checks that package name matches containing directory name     |   no    |  no   |
 | [`range-val-address`](./RULES_DESCRIPTIONS.md#range-val-address)|  n/a   | Warns if address of range value is used dangerously |    no    |  yes   |
 | [`range-val-in-closure`](./RULES_DESCRIPTIONS.md#range-val-in-closure)|  n/a   | Warns if range value is used in a closure dispatched as goroutine|    no    |  no   |
 | [`range`](./RULES_DESCRIPTIONS.md#range)               |  n/a   | Prevents redundant variables when iterating over a collection.   |   yes    |  no   |
diff --git a/RULES_DESCRIPTIONS.md b/RULES_DESCRIPTIONS.md
index 2463f86..d1c2e51 100644
--- a/RULES_DESCRIPTIONS.md
+++ b/RULES_DESCRIPTIONS.md
@@ -64,6 +64,7 @@ List of all available rules.
   - [nested-structs](#nested-structs)
   - [optimize-operands-order](#optimize-operands-order)
   - [package-comments](#package-comments)
+  - [package-directory-mismatch](#package-directory-mismatch)
   - [range-val-address](#range-val-address)
   - [range-val-in-closure](#range-val-in-closure)
   - [range](#range)
@@ -963,6 +964,48 @@ More [information here](https://go.dev/wiki/CodeReviewComments#package-comments)
 
 _Configuration_: N/A
 
+## package-directory-mismatch
+
+_Description_: It is considered a good practice to name a package after the directory containing it.
+This rule warns when the package name declared in the file does not match the name of the directory containing the file.
+
+The following cases are excluded from this check:
+
+- Package `main` (executable packages)
+- Files in `testdata` directories (at any level) - by default
+- Files directly in `internal` directories (but files in subdirectories of `internal` are checked)
+
+For test files (files with `_test` suffix), package name additionally checked if it matches directory name with  `_test` suffix appended.
+
+The rule normalizes both directory and package names before comparison by removing hyphens (`-`),
+underscores (`_`), and dots (`.`). This allows package `foo_barbuz` be equal with directory `foo-bar.buz`.
+
+For files in version directories (`v1`, `v2`, etc.), package name is checked if it matches either the version directory or its parent directory.
+
+_Configuration_: Named arguments for directory exclusions.
+
+Examples:
+
+Default behavior excludes paths containing `testdata`
+
+```toml
+[rule.package-directory-mismatch]
+```
+
+Ignore specific directories with `ignore-directories`
+
+```toml
+[rule.package-directory-mismatch]
+arguments = [{ ignore-directories = ["testcases", "testinfo"] }]
+```
+
+Include all directories (`testdata` also)
+
+```toml
+[rule.package-directory-mismatch]
+arguments = [{ ignoreDirectories = [] }]
+```
+
 ## range-val-address
 
 _Description_: Range variables in a loop are reused at each iteration.
@@ -1104,7 +1147,7 @@ _Configuration_: N/A
 
 _Description_: Struct tags are not checked at compile time.
 This rule spots errors in struct tags of the following types:
-asn1, bson, datastore, default, json, mapstructure, properties, protobuf, required, toml, url, validate, xml, yaml.
+asn1, bson, datastore, default, json, mapstructure, properties, protobuf, required, spanner, toml, url, validate, xml, yaml.
 
 _Configuration_: (optional) list of user defined options.
 
diff --git a/config/config.go b/config/config.go
index eb1585a..a926726 100644
--- a/config/config.go
+++ b/config/config.go
@@ -111,6 +111,7 @@ var allRules = append([]lint.Rule{
 	&rule.IdenticalIfElseIfBranchesRule{},
 	&rule.IdenticalSwitchBranchesRule{},
 	&rule.UselessFallthroughRule{},
+	&rule.PackageDirectoryMismatchRule{},
 }, defaultRules...)
 
 // allFormatters is a list of all available formatters to output the linting results.
diff --git a/rule/datarace.go b/rule/datarace.go
index 54c77ff..de63c06 100644
--- a/rule/datarace.go
+++ b/rule/datarace.go
@@ -8,6 +8,9 @@ import (
 	"github.com/mgechev/revive/lint"
 )
 
+//nolint:staticcheck // TODO: ast.Object is deprecated
+type nodeUID *ast.Object // type of the unique id for AST nodes
+
 // DataRaceRule lints assignments to value method-receivers.
 type DataRaceRule struct{}
 
@@ -23,8 +26,7 @@ func (r *DataRaceRule) Apply(file *lint.File, _ lint.Arguments) []lint.Failure {
 
 		funcResults := funcDecl.Type.Results
 
-		//nolint:staticcheck // TODO: ast.Object is deprecated
-		returnIDs := map[*ast.Object]struct{}{}
+		returnIDs := map[nodeUID]struct{}{}
 		if funcResults != nil {
 			returnIDs = r.extractReturnIDs(funcResults.List)
 		}
@@ -36,7 +38,7 @@ func (r *DataRaceRule) Apply(file *lint.File, _ lint.Arguments) []lint.Failure {
 		fl := &lintFunctionForDataRaces{
 			onFailure: onFailure,
 			returnIDs: returnIDs,
-			rangeIDs:  map[*ast.Object]struct{}{}, //nolint:staticcheck // TODO: ast.Object is deprecated
+			rangeIDs:  map[nodeUID]struct{}{},
 			go122for:  isGo122,
 		}
 
@@ -51,9 +53,8 @@ func (*DataRaceRule) Name() string {
 	return "datarace"
 }
 
-//nolint:staticcheck // TODO: ast.Object is deprecated
-func (*DataRaceRule) extractReturnIDs(fields []*ast.Field) map[*ast.Object]struct{} {
-	r := map[*ast.Object]struct{}{}
+func (*DataRaceRule) extractReturnIDs(fields []*ast.Field) map[nodeUID]struct{} {
+	r := map[nodeUID]struct{}{}
 	for _, f := range fields {
 		for _, id := range f.Names {
 			r[id.Obj] = struct{}{}
@@ -66,8 +67,8 @@ func (*DataRaceRule) extractReturnIDs(fields []*ast.Field) map[*ast.Object]struc
 type lintFunctionForDataRaces struct {
 	_         struct{}
 	onFailure func(failure lint.Failure)
-	returnIDs map[*ast.Object]struct{} //nolint:staticcheck // TODO: ast.Object is deprecated
-	rangeIDs  map[*ast.Object]struct{} //nolint:staticcheck // TODO: ast.Object is deprecated
+	returnIDs map[nodeUID]struct{}
+	rangeIDs  map[nodeUID]struct{}
 
 	go122for bool
 }
diff --git a/rule/package_directory_mismatch.go b/rule/package_directory_mismatch.go
new file mode 100644
index 0000000..b150ae6
--- /dev/null
+++ b/rule/package_directory_mismatch.go
@@ -0,0 +1,156 @@
+package rule
+
+import (
+	"fmt"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/mgechev/revive/lint"
+)
+
+// PackageDirectoryMismatchRule detects when package name doesn't match directory name.
+type PackageDirectoryMismatchRule struct {
+	ignoredDirs *regexp.Regexp
+}
+
+const defaultIgnoredDirs = "testdata"
+
+// Configure the rule to exclude certain directories.
+func (r *PackageDirectoryMismatchRule) Configure(arguments lint.Arguments) error {
+	if len(arguments) < 1 {
+		var err error
+		r.ignoredDirs, err = r.buildIgnoreRegex([]string{defaultIgnoredDirs})
+		return err
+	}
+
+	args, ok := arguments[0].(map[string]any)
+	if !ok {
+		return fmt.Errorf("invalid argument type: expected map[string]any, got %T", arguments[0])
+	}
+
+	for k, v := range args {
+		if !isRuleOption(k, "ignoreDirectories") {
+			return fmt.Errorf("unknown argument %s for %s rule", k, r.Name())
+		}
+
+		ignoredAny, ok := v.([]any)
+		if !ok {
+			return fmt.Errorf("invalid value %v for argument %s of rule %s, expected []string got %T", v, k, r.Name(), v)
+		}
+
+		ignoredDirs := make([]string, len(ignoredAny))
+		for i, item := range ignoredAny {
+			str, ok := item.(string)
+			if !ok {
+				return fmt.Errorf("invalid value in %s argument of rule %s: expected string, got %T", k, r.Name(), item)
+			}
+			ignoredDirs[i] = str
+		}
+
+		var err error
+		r.ignoredDirs, err = r.buildIgnoreRegex(ignoredDirs)
+		return err
+	}
+
+	return nil
+}
+
+func (*PackageDirectoryMismatchRule) buildIgnoreRegex(ignoredDirs []string) (*regexp.Regexp, error) {
+	if len(ignoredDirs) == 0 {
+		return nil, nil
+	}
+
+	patterns := make([]string, len(ignoredDirs))
+	for i, dir := range ignoredDirs {
+		patterns[i] = regexp.QuoteMeta(dir)
+	}
+	pattern := strings.Join(patterns, "|")
+
+	regex, err := regexp.Compile(pattern)
+	if err != nil {
+		return nil, fmt.Errorf("failed to compile regex for ignored directories: %w", err)
+	}
+
+	return regex, nil
+}
+
+// skipDirs contains directory names that should be unconditionally ignored when checking.
+// These entries handle edge cases where filepath.Base might return these values.
+var skipDirs = map[string]struct{}{
+	".": {}, // Current directory
+	"/": {}, // Root directory
+	"":  {}, // Empty path
+}
+
+// semanticallyEqual checks if package and directory names are semantically equal to each other.
+func (PackageDirectoryMismatchRule) semanticallyEqual(packageName, dirName string) bool {
+	normDir := normalizePath(dirName)
+	normPkg := normalizePath(packageName)
+	return normDir == normPkg || normDir == "go"+normPkg
+}
+
+// Apply applies the rule to the given file.
+func (r *PackageDirectoryMismatchRule) Apply(file *lint.File, _ lint.Arguments) []lint.Failure {
+	if file.Pkg.IsMain() {
+		return nil
+	}
+
+	dirPath := filepath.Dir(file.Name)
+	dirName := filepath.Base(dirPath)
+
+	if r.ignoredDirs != nil && r.ignoredDirs.MatchString(dirPath) {
+		return nil
+	}
+
+	// Check if we got an invalid directory.
+	if _, skipDir := skipDirs[dirName]; skipDir {
+		return nil
+	}
+
+	// Files directly in 'internal/' (like 'internal/abcd.go') should not be checked.
+	// But files in subdirectories of 'internal/' (like 'internal/foo/abcd.go') should be checked.
+	if dirName == "internal" {
+		return nil
+	}
+
+	packageName := file.AST.Name.Name
+
+	if r.semanticallyEqual(packageName, dirName) {
+		return nil
+	}
+
+	if file.IsTest() {
+		// External test package (directory + '_test' suffix)
+		if r.semanticallyEqual(packageName, dirName+"_test") {
+			return nil
+		}
+	}
+
+	// define a default failure message
+	failure := fmt.Sprintf("package name %q does not match directory name %q", packageName, dirName)
+
+	// For version directories (v1, v2, etc.), we need to check also the parent directory
+	if isVersionPath(dirName) {
+		parentDirName := filepath.Base(filepath.Dir(dirPath))
+		if r.semanticallyEqual(packageName, parentDirName) {
+			return nil
+		}
+
+		failure = fmt.Sprintf("package name %q does not match directory name %q or parent directory name %q", packageName, dirName, parentDirName)
+	}
+
+	return []lint.Failure{
+		{
+			Failure:    failure,
+			Confidence: 1,
+			Node:       file.AST.Name,
+			Category:   lint.FailureCategoryNaming,
+		},
+	}
+}
+
+// Name returns the rule name.
+func (*PackageDirectoryMismatchRule) Name() string {
+	return "package-directory-mismatch"
+}
diff --git a/rule/struct_tag.go b/rule/struct_tag.go
index a4cfec7..396ffbb 100644
--- a/rule/struct_tag.go
+++ b/rule/struct_tag.go
@@ -30,6 +30,7 @@ const (
 	keyProperties   tagKey = "properties"
 	keyProtobuf     tagKey = "protobuf"
 	keyRequired     tagKey = "required"
+	keySpanner      tagKey = "spanner"
 	keyTOML         tagKey = "toml"
 	keyURL          tagKey = "url"
 	keyValidate     tagKey = "validate"
@@ -49,6 +50,7 @@ var tagCheckers = map[tagKey]tagChecker{
 	keyProperties:   checkPropertiesTag,
 	keyProtobuf:     checkProtobufTag,
 	keyRequired:     checkRequiredTag,
+	keySpanner:      checkSpannerTag,
 	keyTOML:         checkTOMLTag,
 	keyURL:          checkURLTag,
 	keyValidate:     checkValidateTag,
@@ -198,7 +200,7 @@ func (w lintStructTagRule) checkTagNameIfNeed(checkCtx *checkContext, tag *struc
 
 	key := tagKey(tag.Key)
 	switch key {
-	case keyBSON, keyJSON, keyXML, keyYAML, keyProtobuf:
+	case keyBSON, keyJSON, keyXML, keyYAML, keyProtobuf, keySpanner:
 	default:
 		return "", true
 	}
@@ -558,6 +560,18 @@ func checkYAMLTag(checkCtx *checkContext, tag *structtag.Tag, _ ast.Expr) (messa
 	return "", true
 }
 
+func checkSpannerTag(checkCtx *checkContext, tag *structtag.Tag, _ ast.Expr) (message string, succeeded bool) {
+	if tag.Name == "-" {
+		return "", true
+	}
+	for _, opt := range tag.Options {
+		if !checkCtx.isUserDefined(keySpanner, opt) {
+			return fmt.Sprintf(msgUnknownOption, opt), false
+		}
+	}
+	return "", true
+}
+
 func checkValidateOptionsAlternatives(checkCtx *checkContext, alternatives []string) (message string, succeeded bool) {
 	for _, alternative := range alternatives {
 		alternative := strings.TrimSpace(alternative)
diff --git a/rule/utils.go b/rule/utils.go
index cfb87c5..24a7234 100644
--- a/rule/utils.go
+++ b/rule/utils.go
@@ -55,6 +55,30 @@ func normalizeRuleOption(arg string) string {
 	return strings.ToLower(strings.ReplaceAll(arg, "-", ""))
 }
 
+var normalizePathReplacer = strings.NewReplacer("-", "", "_", "", ".", "")
+
+// normalizePath removes hyphens, underscores, and dots from the name
+//
+// Example: normalizePath("foo.bar-_buz") -> "foobarbuz".
+func normalizePath(name string) string {
+	return normalizePathReplacer.Replace(name)
+}
+
+// isVersionPath checks if a directory name is a version directory (v1, V2, etc.)
+func isVersionPath(name string) bool {
+	if len(name) < 2 || (name[0] != 'v' && name[0] != 'V') {
+		return false
+	}
+
+	for i := 1; i < len(name); i++ {
+		if name[i] < '0' || name[i] > '9' {
+			return false
+		}
+	}
+
+	return true
+}
+
 var directiveCommentRE = regexp.MustCompile("^//(line |extern |export |[a-z0-9]+:[a-z0-9])") // see https://go-review.googlesource.com/c/website/+/442516/1..2/_content/doc/comment.md#494
 
 func isDirectiveComment(line string) bool {



Test patch:
diff --git a/test/package_directory_mismatch_test.go b/test/package_directory_mismatch_test.go
new file mode 100644
index 0000000..7e81231
--- /dev/null
+++ b/test/package_directory_mismatch_test.go
@@ -0,0 +1,77 @@
+package test
+
+import (
+	"testing"
+
+	"github.com/mgechev/revive/lint"
+	"github.com/mgechev/revive/rule"
+)
+
+func TestPackageDirectoryMismatch(t *testing.T) {
+	// Configure rule to ignore no directories so our tests in 'testdata/' can run
+	config := &lint.RuleConfig{Arguments: []any{map[string]any{"ignore-directories": []any{}}}}
+
+	testRule(t, "package_directory_mismatch/good/good", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/bad/bad", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/maincmd/main", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/mixed/good", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/mixed/bad", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/go-good/good1", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/go-good/good2", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/go-good/bad", &rule.PackageDirectoryMismatchRule{}, config)
+
+	// Test normalization cases
+	testRule(t, "package_directory_mismatch/normalization/fo-ob_ar/foobar", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/normalization/foo_bar/foobar", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/normalization/foo.b_ar/foobar", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/normalization/go-foo_bar/foo_bar", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/normalization/go-foo_bar/foobar", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/normalization/go-foo-bar/foo_bar", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/normalization/go-foo-bar/foobar", &rule.PackageDirectoryMismatchRule{}, config)
+
+	// Edge cases that are not logic, but we decided to ignore to have a simpler implementation
+	testRule(t, "package_directory_mismatch/normalization/go-od/go_od", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/normalization/go-od/good", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/normalization/go-od/od", &rule.PackageDirectoryMismatchRule{}, config)
+
+	// Test version directories (v1, v2, etc.)
+	testRule(t, "package_directory_mismatch/api/v1/api", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/api/V1/api", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/api/v1v/api", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/api/v2/v2", &rule.PackageDirectoryMismatchRule{}, config)
+
+	// Test internal directory variations
+	testRule(t, "package_directory_mismatch/internal/good/good", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/internal/bad/bad", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/internal/any", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/internal/api/v1/api", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/internal/api/v2/api", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/internal/v1/api", &rule.PackageDirectoryMismatchRule{}, config)
+
+	// Test handling of testfiles
+	testRule(t, "package_directory_mismatch/test/good_test", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/test/good", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/test/bad_test", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/test/bad", &rule.PackageDirectoryMismatchRule{}, config)
+}
+
+func TestPackageDirectoryMismatchWithDefaultConfig(t *testing.T) {
+	// Test with default configuration (should ignore testdata directories by default)
+	testRule(t, "package_directory_mismatch/testdata/ignored", &rule.PackageDirectoryMismatchRule{})
+}
+
+func TestPackageDirectoryMismatchWithTestDirectories(t *testing.T) {
+	// Test with new format that excludes specific test directories
+	config := &lint.RuleConfig{Arguments: []any{map[string]any{"ignore-directories": []any{"testinfo", "testutils"}}}}
+
+	testRule(t, "package_directory_mismatch/testinfo/good", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/testutils/good", &rule.PackageDirectoryMismatchRule{}, config)
+}
+
+func TestPackageDirectoryMismatchWithMultipleDirectories(t *testing.T) {
+	// Test with new named argument format that excludes both "testutils" and "testinfo"
+	config := &lint.RuleConfig{Arguments: []any{map[string]any{"ignoreDirectories": []any{"testutils", "testinfo"}}}}
+
+	testRule(t, "package_directory_mismatch/testutils/good", &rule.PackageDirectoryMismatchRule{}, config)
+	testRule(t, "package_directory_mismatch/testinfo/good", &rule.PackageDirectoryMismatchRule{}, config)
+}
diff --git a/test/struct_tag_test.go b/test/struct_tag_test.go
index 389f0a8..40cc8a8 100644
--- a/test/struct_tag_test.go
+++ b/test/struct_tag_test.go
@@ -21,6 +21,7 @@ func TestStructTagWithUserOptions(t *testing.T) {
 			"mapstructure,myMapstructureOption",
 			"validate,displayName",
 			"toml,unknown",
+			"spanner,mySpannerOption",
 		},
 	})
 }
diff --git a/testdata/package_directory_mismatch/api/V1/api.go b/testdata/package_directory_mismatch/api/V1/api.go
new file mode 100644
index 0000000..778f64e
--- /dev/null
+++ b/testdata/package_directory_mismatch/api/V1/api.go
@@ -0,0 +1 @@
+package api
diff --git a/testdata/package_directory_mismatch/api/v1/api.go b/testdata/package_directory_mismatch/api/v1/api.go
new file mode 100644
index 0000000..778f64e
--- /dev/null
+++ b/testdata/package_directory_mismatch/api/v1/api.go
@@ -0,0 +1 @@
+package api
diff --git a/testdata/package_directory_mismatch/api/v1v/api.go b/testdata/package_directory_mismatch/api/v1v/api.go
new file mode 100644
index 0000000..5df441e
--- /dev/null
+++ b/testdata/package_directory_mismatch/api/v1v/api.go
@@ -0,0 +1 @@
+package api // MATCH /package name "api" does not match directory name "v1v"/
diff --git a/testdata/package_directory_mismatch/api/v2/v2.go b/testdata/package_directory_mismatch/api/v2/v2.go
new file mode 100644
index 0000000..5ec3cc8
--- /dev/null
+++ b/testdata/package_directory_mismatch/api/v2/v2.go
@@ -0,0 +1 @@
+package v2
diff --git a/testdata/package_directory_mismatch/bad/bad.go b/testdata/package_directory_mismatch/bad/bad.go
new file mode 100644
index 0000000..13795be
--- /dev/null
+++ b/testdata/package_directory_mismatch/bad/bad.go
@@ -0,0 +1 @@
+package ptr // MATCH /package name "ptr" does not match directory name "bad"/
diff --git a/testdata/package_directory_mismatch/go-good/bad.go b/testdata/package_directory_mismatch/go-good/bad.go
new file mode 100644
index 0000000..c8b2cf7
--- /dev/null
+++ b/testdata/package_directory_mismatch/go-good/bad.go
@@ -0,0 +1 @@
+package bad // MATCH /package name "bad" does not match directory name "go-good"/
diff --git a/testdata/package_directory_mismatch/go-good/good1.go b/testdata/package_directory_mismatch/go-good/good1.go
new file mode 100644
index 0000000..262eb84
--- /dev/null
+++ b/testdata/package_directory_mismatch/go-good/good1.go
@@ -0,0 +1 @@
+package go_od
diff --git a/testdata/package_directory_mismatch/go-good/good2.go b/testdata/package_directory_mismatch/go-good/good2.go
new file mode 100644
index 0000000..0860caf
--- /dev/null
+++ b/testdata/package_directory_mismatch/go-good/good2.go
@@ -0,0 +1 @@
+package go_good
diff --git a/testdata/package_directory_mismatch/good/good.go b/testdata/package_directory_mismatch/good/good.go
new file mode 100644
index 0000000..fd2a96f
--- /dev/null
+++ b/testdata/package_directory_mismatch/good/good.go
@@ -0,0 +1 @@
+package good
diff --git a/testdata/package_directory_mismatch/internal/any.go b/testdata/package_directory_mismatch/internal/any.go
new file mode 100644
index 0000000..4e8f3d0
--- /dev/null
+++ b/testdata/package_directory_mismatch/internal/any.go
@@ -0,0 +1 @@
+package any
diff --git a/testdata/package_directory_mismatch/internal/api/v1/api.go b/testdata/package_directory_mismatch/internal/api/v1/api.go
new file mode 100644
index 0000000..16d832e
--- /dev/null
+++ b/testdata/package_directory_mismatch/internal/api/v1/api.go
@@ -0,0 +1 @@
+package bad // MATCH /package name "bad" does not match directory name "v1" or parent directory name "api"/
diff --git a/testdata/package_directory_mismatch/internal/api/v2/api.go b/testdata/package_directory_mismatch/internal/api/v2/api.go
new file mode 100644
index 0000000..778f64e
--- /dev/null
+++ b/testdata/package_directory_mismatch/internal/api/v2/api.go
@@ -0,0 +1 @@
+package api
diff --git a/testdata/package_directory_mismatch/internal/bad/bad.go b/testdata/package_directory_mismatch/internal/bad/bad.go
new file mode 100644
index 0000000..0ff56f4
--- /dev/null
+++ b/testdata/package_directory_mismatch/internal/bad/bad.go
@@ -0,0 +1 @@
+package wrong // MATCH /package name "wrong" does not match directory name "bad"/
diff --git a/testdata/package_directory_mismatch/internal/good/good.go b/testdata/package_directory_mismatch/internal/good/good.go
new file mode 100644
index 0000000..fd2a96f
--- /dev/null
+++ b/testdata/package_directory_mismatch/internal/good/good.go
@@ -0,0 +1 @@
+package good
diff --git a/testdata/package_directory_mismatch/internal/v1/api.go b/testdata/package_directory_mismatch/internal/v1/api.go
new file mode 100644
index 0000000..d2bab4f
--- /dev/null
+++ b/testdata/package_directory_mismatch/internal/v1/api.go
@@ -0,0 +1 @@
+package api // MATCH /package name "api" does not match directory name "v1" or parent directory name "internal"/
diff --git a/testdata/package_directory_mismatch/maincmd/main.go b/testdata/package_directory_mismatch/maincmd/main.go
new file mode 100644
index 0000000..c2ab919
--- /dev/null
+++ b/testdata/package_directory_mismatch/maincmd/main.go
@@ -0,0 +1,2 @@
+// main packages can be in any directory
+package main
diff --git a/testdata/package_directory_mismatch/mixed/bad.go b/testdata/package_directory_mismatch/mixed/bad.go
new file mode 100644
index 0000000..3f7d936
--- /dev/null
+++ b/testdata/package_directory_mismatch/mixed/bad.go
@@ -0,0 +1 @@
+package wrong // MATCH /package name "wrong" does not match directory name "mixed"/
diff --git a/testdata/package_directory_mismatch/mixed/good.go b/testdata/package_directory_mismatch/mixed/good.go
new file mode 100644
index 0000000..ad135a5
--- /dev/null
+++ b/testdata/package_directory_mismatch/mixed/good.go
@@ -0,0 +1 @@
+package mixed
diff --git a/testdata/package_directory_mismatch/normalization/fo-ob_ar/foobar.go b/testdata/package_directory_mismatch/normalization/fo-ob_ar/foobar.go
new file mode 100644
index 0000000..7348950
--- /dev/null
+++ b/testdata/package_directory_mismatch/normalization/fo-ob_ar/foobar.go
@@ -0,0 +1 @@
+package foobar
diff --git a/testdata/package_directory_mismatch/normalization/foo.b_ar/foobar.go b/testdata/package_directory_mismatch/normalization/foo.b_ar/foobar.go
new file mode 100644
index 0000000..7348950
--- /dev/null
+++ b/testdata/package_directory_mismatch/normalization/foo.b_ar/foobar.go
@@ -0,0 +1 @@
+package foobar
diff --git a/testdata/package_directory_mismatch/normalization/foo_bar/foobar.go b/testdata/package_directory_mismatch/normalization/foo_bar/foobar.go
new file mode 100644
index 0000000..7348950
--- /dev/null
+++ b/testdata/package_directory_mismatch/normalization/foo_bar/foobar.go
@@ -0,0 +1 @@
+package foobar
diff --git a/testdata/package_directory_mismatch/normalization/go-foo-bar/foo_bar.go b/testdata/package_directory_mismatch/normalization/go-foo-bar/foo_bar.go
new file mode 100644
index 0000000..4311cda
--- /dev/null
+++ b/testdata/package_directory_mismatch/normalization/go-foo-bar/foo_bar.go
@@ -0,0 +1 @@
+package foo_bar
diff --git a/testdata/package_directory_mismatch/normalization/go-foo-bar/foobar.go b/testdata/package_directory_mismatch/normalization/go-foo-bar/foobar.go
new file mode 100644
index 0000000..7348950
--- /dev/null
+++ b/testdata/package_directory_mismatch/normalization/go-foo-bar/foobar.go
@@ -0,0 +1 @@
+package foobar
diff --git a/testdata/package_directory_mismatch/normalization/go-foo_bar/foo_bar.go b/testdata/package_directory_mismatch/normalization/go-foo_bar/foo_bar.go
new file mode 100644
index 0000000..4311cda
--- /dev/null
+++ b/testdata/package_directory_mismatch/normalization/go-foo_bar/foo_bar.go
@@ -0,0 +1 @@
+package foo_bar
diff --git a/testdata/package_directory_mismatch/normalization/go-foo_bar/foobar.go b/testdata/package_directory_mismatch/normalization/go-foo_bar/foobar.go
new file mode 100644
index 0000000..7348950
--- /dev/null
+++ b/testdata/package_directory_mismatch/normalization/go-foo_bar/foobar.go
@@ -0,0 +1 @@
+package foobar
diff --git a/testdata/package_directory_mismatch/normalization/go-od/go_od.go b/testdata/package_directory_mismatch/normalization/go-od/go_od.go
new file mode 100644
index 0000000..262eb84
--- /dev/null
+++ b/testdata/package_directory_mismatch/normalization/go-od/go_od.go
@@ -0,0 +1 @@
+package go_od
diff --git a/testdata/package_directory_mismatch/normalization/go-od/good.go b/testdata/package_directory_mismatch/normalization/go-od/good.go
new file mode 100644
index 0000000..fd2a96f
--- /dev/null
+++ b/testdata/package_directory_mismatch/normalization/go-od/good.go
@@ -0,0 +1 @@
+package good
diff --git a/testdata/package_directory_mismatch/normalization/go-od/od.go b/testdata/package_directory_mismatch/normalization/go-od/od.go
new file mode 100644
index 0000000..35ca760
--- /dev/null
+++ b/testdata/package_directory_mismatch/normalization/go-od/od.go
@@ -0,0 +1 @@
+package od
diff --git a/testdata/package_directory_mismatch/test/bad.go b/testdata/package_directory_mismatch/test/bad.go
new file mode 100644
index 0000000..6928693
--- /dev/null
+++ b/testdata/package_directory_mismatch/test/bad.go
@@ -0,0 +1 @@
+package bad // MATCH /package name "bad" does not match directory name "test"/
diff --git a/testdata/package_directory_mismatch/test/bad_test.go b/testdata/package_directory_mismatch/test/bad_test.go
new file mode 100644
index 0000000..2e57936
--- /dev/null
+++ b/testdata/package_directory_mismatch/test/bad_test.go
@@ -0,0 +1 @@
+package bad_test // MATCH /package name "bad_test" does not match directory name "test"/
diff --git a/testdata/package_directory_mismatch/test/good.go b/testdata/package_directory_mismatch/test/good.go
new file mode 100644
index 0000000..56e5404
--- /dev/null
+++ b/testdata/package_directory_mismatch/test/good.go
@@ -0,0 +1 @@
+package test
diff --git a/testdata/package_directory_mismatch/test/good_test.go b/testdata/package_directory_mismatch/test/good_test.go
new file mode 100644
index 0000000..c94a197
--- /dev/null
+++ b/testdata/package_directory_mismatch/test/good_test.go
@@ -0,0 +1 @@
+package test_test
diff --git a/testdata/package_directory_mismatch/testdata/bad.go b/testdata/package_directory_mismatch/testdata/bad.go
new file mode 100644
index 0000000..379c11c
--- /dev/null
+++ b/testdata/package_directory_mismatch/testdata/bad.go
@@ -0,0 +1 @@
+package bad // MATCH /package name "bad" does not match directory name "testdata"/
diff --git a/testdata/package_directory_mismatch/testdata/ignored.go b/testdata/package_directory_mismatch/testdata/ignored.go
new file mode 100644
index 0000000..b78ed45
--- /dev/null
+++ b/testdata/package_directory_mismatch/testdata/ignored.go
@@ -0,0 +1 @@
+package ignored
diff --git a/testdata/package_directory_mismatch/testinfo/good.go b/testdata/package_directory_mismatch/testinfo/good.go
new file mode 100644
index 0000000..801dc75
--- /dev/null
+++ b/testdata/package_directory_mismatch/testinfo/good.go
@@ -0,0 +1,2 @@
+// This would normally cause a violation, but should be excluded by regex
+package good
diff --git a/testdata/package_directory_mismatch/testutils/good.go b/testdata/package_directory_mismatch/testutils/good.go
new file mode 100644
index 0000000..fd2a96f
--- /dev/null
+++ b/testdata/package_directory_mismatch/testutils/good.go
@@ -0,0 +1 @@
+package good
diff --git a/testdata/struct_tag.go b/testdata/struct_tag.go
index e9d9933..2382008 100644
--- a/testdata/struct_tag.go
+++ b/testdata/struct_tag.go
@@ -88,6 +88,12 @@ type TestDuplicatedProtobufTags struct {
 	C int `protobuf:"varint,name=c"` // MATCH /duplicated tag name "c" in protobuf tag/
 }
 
+type SpannerDuplicatedTags struct {
+	A int `spanner:"field_a"`
+	B int `spanner:"field_a"` // MATCH /duplicated tag name "field_a" in spanner tag/
+	C int `spanner:"field_c"`
+}
+
 // test case from
 // sigs.k8s.io/kustomize/api/types/helmchartargs.go
 
@@ -182,3 +188,11 @@ type PropertiesTags struct {
 	Field []string          `properties:",default=a;b;c"`
 	Field map[string]string `properties:"myName,omitempty"` // MATCH /unknown or malformed option "omitempty" in properties tag/
 }
+
+type SpannerUser struct {
+	ID        int       `spanner:"user_id"`
+	Name      string    `spanner:"full_name"`
+	Email     string    `spanner:"-"`                    // Valid: ignore field
+	CreatedAt time.Time `spanner:"created_at"`
+	UpdatedAt time.Time `spanner:"updated_at,unknown"`   // MATCH /unknown option "unknown" in spanner tag/
+}
\ No newline at end of file
diff --git a/testdata/struct_tag_user_options.go b/testdata/struct_tag_user_options.go
index f2a925b..7ec12e2 100644
--- a/testdata/struct_tag_user_options.go
+++ b/testdata/struct_tag_user_options.go
@@ -46,3 +46,8 @@ type TomlUser struct {
 	Username string `toml:"username,omitempty"`
 	Location string `toml:"location,unknown"`
 }
+
+type SpannerUserOptions struct {
+	ID        int    `spanner:"user_id,mySpannerOption"`
+	Name      string `spanner:"full_name,unknownOption"` // MATCH /unknown option "unknownOption" in spanner tag/
+}
\ No newline at end of file