diff --git a/.structlint.yaml b/.structlint.yaml index cd27984..45e2218 100644 --- a/.structlint.yaml +++ b/.structlint.yaml @@ -121,6 +121,8 @@ ignore: - ".idea" - ".vscode" - "*.sublime-*" + - ".agents" + - ".codex" # OS files - ".DS_Store" @@ -130,3 +132,4 @@ ignore: - "*.log" - "*.tmp" - "*.temp" + - "structlint" diff --git a/README.md b/README.md index 4673698..d10558f 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,11 @@ structlint validate | Inconsistent project structure across team | Enforce allowed/disallowed paths | | Sensitive files committed (.env, keys) | Block forbidden file patterns | | Missing essential files (README, configs) | Require specific files | +| Files drifting into the wrong layer | Enforce placement rules | +| Packages missing local entrypoints/docs | Enforce required groups | +| Cross-layer imports creeping in | Enforce Go, JS/TS, and Python boundaries | | AI tools placing files incorrectly | Clear structure rules for AI context | -| CI/CD structural compliance | JSON reports + exit codes | +| CI/CD structural compliance | JSON/SARIF/GitHub reports + exit codes | ## Configuration @@ -157,6 +160,32 @@ ignore: +
+Organization Drift Rules + +```yaml +placement: + - id: migrations-only + files: ["*.sql"] + mustBeUnder: ["migrations/**"] + +requiredGroups: + - id: build-entrypoint + oneOf: ["Makefile", "Taskfile.yml", "justfile"] + - id: commands-have-main + eachDirMatching: "cmd/*" + mustContain: ["main.go"] + +boundaries: + - id: domain-no-infrastructure + from: "internal/domain/**" + cannotImport: ["internal/db/**", "internal/http/**"] +``` + +Boundary rules are language-aware for Go, JavaScript, TypeScript, and Python imports. See [Configuration Reference](docs/user/configuration.md) and [CI/CD Integration](docs/user/ci-cd-integration.md). + +
+
Glob Pattern Syntax diff --git a/docs/AI/configuration-schema.md b/docs/AI/configuration-schema.md index ffef5ab..bd3b90e 100644 --- a/docs/AI/configuration-schema.md +++ b/docs/AI/configuration-schema.md @@ -13,6 +13,9 @@ file_naming_pattern: disallowed: [] # []string - glob patterns required: [] # []string - glob patterns +placement: [] # []PlacementRule - file placement contracts +requiredGroups: [] # []RequiredGroup - one-of and per-directory requirements +boundaries: [] # []BoundaryRule - import boundary contracts ignore: [] # []string - exact paths or patterns ``` @@ -25,6 +28,9 @@ type Config struct { DirStructure DirStructureConfig `yaml:"dir_structure" json:"dir_structure"` FileNamingPattern FileNamingPatternConfig `yaml:"file_naming_pattern" json:"file_naming_pattern"` Ignore []string `yaml:"ignore" json:"ignore"` + Placement []PlacementRule `yaml:"placement" json:"placement"` + RequiredGroups []RequiredGroup `yaml:"requiredGroups" json:"requiredGroups"` + Boundaries []BoundaryRule `yaml:"boundaries" json:"boundaries"` } type DirStructureConfig struct { @@ -38,6 +44,30 @@ type FileNamingPatternConfig struct { Disallowed []string `yaml:"disallowed" json:"disallowed"` Required []string `yaml:"required" json:"required"` } + +type PlacementRule struct { + ID string `yaml:"id" json:"id"` + Files []string `yaml:"files" json:"files"` + MustBeUnder []string `yaml:"mustBeUnder" json:"mustBeUnder"` + Severity string `yaml:"severity" json:"severity"` +} + +type RequiredGroup struct { + ID string `yaml:"id" json:"id"` + OneOf []string `yaml:"oneOf" json:"oneOf"` + EachDirMatching string `yaml:"eachDirMatching" json:"eachDirMatching"` + MustContain []string `yaml:"mustContain" json:"mustContain"` + MustContainOneOf []string `yaml:"mustContainOneOf" json:"mustContainOneOf"` + RequireMatch bool `yaml:"requireMatch" json:"requireMatch"` + Severity string `yaml:"severity" json:"severity"` +} + +type BoundaryRule struct { + ID string `yaml:"id" json:"id"` + From string `yaml:"from" json:"from"` + CannotImport []string `yaml:"cannotImport" json:"cannotImport"` + Severity string `yaml:"severity" json:"severity"` +} ``` ## Glob Pattern Syntax @@ -121,6 +151,41 @@ ignore: - "bin" ``` +### Placement + +**placement**: Matching files must be under one of the configured roots. + +```yaml +placement: + - id: sql-in-migrations + files: ["*.sql"] + mustBeUnder: ["migrations/**"] +``` + +### Required Groups + +**requiredGroups**: Higher-level required-file contracts. + +```yaml +requiredGroups: + - id: build-entrypoint + oneOf: ["Makefile", "Taskfile.yml", "justfile"] + - id: commands-have-main + eachDirMatching: "cmd/*" + mustContain: ["main.go"] +``` + +### Boundaries + +**boundaries**: Import boundary rules for Go, JS/TS, and Python source files. + +```yaml +boundaries: + - id: domain-no-db + from: "internal/domain/**" + cannotImport: ["internal/db/**"] +``` + ## JSON Report Schema ```go @@ -129,10 +194,20 @@ ignore: type JSONReport struct { Successes int `json:"successes"` Failures int `json:"failures"` + TotalViolations int `json:"total_violations"` Errors []string `json:"errors"` + Violations []Violation `json:"violations"` Summary ValidationSummary `json:"summary,omitempty"` } +type Violation struct { + Code string `json:"code"` + Severity string `json:"severity"` + Path string `json:"path"` + Rule string `json:"rule"` + Message string `json:"message"` +} + type ValidationSummary struct { DirsChecked int `json:"directories_checked"` FilesChecked int `json:"files_checked"` diff --git a/docs/user/ci-cd-integration.md b/docs/user/ci-cd-integration.md index e01f7cc..79d3171 100644 --- a/docs/user/ci-cd-integration.md +++ b/docs/user/ci-cd-integration.md @@ -27,6 +27,30 @@ jobs: run: structlint validate --config .structlint.yaml ``` +### GitHub Annotations + +Use annotation output when you want violations to appear inline on pull requests. + +```yaml + - name: Validate structure + run: structlint validate --format github +``` + +### SARIF Upload + +Use SARIF when your pipeline collects code-scanning reports. + +```yaml + - name: Validate structure + run: structlint validate --format sarif > structlint.sarif + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: structlint.sarif +``` + ### With JSON Report Artifact ```yaml @@ -54,9 +78,35 @@ structlint: when: always paths: - report.json - expire_in: 1 week + expire_in: 1 week ``` +## Baselines + +Baselines let legacy repositories adopt structlint without blocking every existing violation. First, create a report from the current state: + +```bash +structlint validate --json-output .structlint-baseline.json || true +``` + +Then fail only on new violations: + +```bash +structlint validate --baseline .structlint-baseline.json +``` + +The baseline matches typed violations by `code`, `path`, and `rule`. + +## Changed Files + +For fast pull-request checks, validate only changed files: + +```bash +structlint validate --changed-only +``` + +This uses `git diff --name-only --diff-filter=ACMRT HEAD`. Repository-wide requirements such as required paths still run, while file-oriented checks are limited to changed files. + ## Jenkins ```groovy @@ -135,7 +185,7 @@ ci: lint test lint-structure build 1. **Run early in the pipeline** - Structure validation is fast and catches issues before expensive builds -2. **Use strict mode in CI** - Add `--strict` flag to fail on warnings +2. **Use typed output in CI** - Prefer `--format github`, `--format sarif`, or `--json-output` 3. **Generate reports** - Always generate JSON reports for debugging diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index 921b281..c844753 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -14,20 +14,25 @@ ### validate -Validate directory structure and file naming patterns. +Validate directory structure, file naming patterns, placement rules, required groups, and import boundaries. ```bash -structlint validate [options] [path] +structlint validate [options] ``` **Options:** -| Option | Description | -|--------|-------------| -| `--config, -c` | Path to config file | -| `--json-output` | Path to write JSON report | -| `--silent` | Suppress output (exit code only) | -| `--strict` | Treat warnings as errors | +| Option | Default | Description | +|--------|---------|-------------| +| `--path` / `$STRUCTLINT_PATH` | `.` | Directory to validate | +| `--config` / `$STRUCTLINT_CONFIG` | `.structlint.yaml` | Path to config file | +| `--json-output` / `$STRUCTLINT_JSON_OUTPUT` | — | Path to write JSON report | +| `--format` / `$STRUCTLINT_FORMAT` | `text` | Output format: `text`, `json`, `sarif`, or `github` | +| `--baseline` / `$STRUCTLINT_BASELINE` | — | JSON report with known violations to suppress | +| `--changed-only` / `$STRUCTLINT_CHANGED_ONLY` | false | Validate only files changed in `git diff --name-only HEAD` | +| `--silent` / `$STRUCTLINT_SILENT` | false | Suppress text logging | +| `--group-violations` / `$STRUCTLINT_GROUP_VIOLATIONS` | true | Group text output by violation type | +| `--verbose` / `$STRUCTLINT_VERBOSE` | false | Show successful checks as well as violations | **Examples:** @@ -42,10 +47,19 @@ structlint validate --config .structlint.yaml structlint validate --json-output report.json # Validate specific directory -structlint validate /path/to/project +structlint validate --path /path/to/project # Silent mode (for scripts) structlint validate --silent && echo "Valid" + +# GitHub Actions annotations +structlint validate --format github + +# SARIF for code scanning +structlint validate --format sarif > structlint.sarif + +# Suppress known drift while failing on new drift +structlint validate --baseline .structlint-baseline.json ``` ### version @@ -102,17 +116,33 @@ When using `--json-output`, the report structure is: { "successes": 42, "failures": 2, + "total_violations": 2, "errors": [ "Directory not in allowed list: tmp", - "Disallowed file found: .env.local" + "Disallowed file naming pattern found: .env.local" ], - "summary": { - "directories_checked": 15, - "files_checked": 27, - "violations_by_type": { - "dir_not_allowed": 1, - "file_disallowed": 1 + "violations": [ + { + "code": "unallowed_directory", + "severity": "error", + "path": "tmp", + "rule": "dir_structure.allowedPaths", + "message": "Directory not in allowed list: tmp" + }, + { + "code": "disallowed_file_pattern", + "severity": "error", + "path": ".env.local", + "rule": "*.env*", + "message": "Disallowed file naming pattern found: .env.local" } + ], + "summary": { + "total_successes": 42, + "total_failures": 2, + "violations": [] } } ``` + +The `violations` array is the stable CI contract. Human-readable `errors` are kept for backward compatibility. diff --git a/docs/user/configuration.md b/docs/user/configuration.md index 33b94a3..64a97fa 100644 --- a/docs/user/configuration.md +++ b/docs/user/configuration.md @@ -24,9 +24,14 @@ file_naming_pattern: disallowed: [] # Glob patterns for disallowed files required: [] # Files that must exist (supports globs) +placement: [] # Files that must live under specific directories +requiredGroups: [] # One-of and per-directory required files +boundaries: [] # Go import boundary rules ignore: [] # Paths to skip during validation ``` +Configuration loading is strict. Unknown keys such as `allowed_paths` fail before validation starts, which helps catch CI drift caused by typos. + ## Glob Pattern Syntax structlint supports standard glob patterns: @@ -151,6 +156,70 @@ ignore: - ".vscode" ``` +## Placement Rules + +Placement rules ensure files of a given kind live in the expected part of the repository. + +```yaml +placement: + - id: sql-in-migrations + files: ["*.sql"] + mustBeUnder: ["migrations/**"] + + - id: tests-near-code + files: ["*_test.go"] + mustBeUnder: ["internal/**", "pkg/**", "test/**"] +``` + +| Field | Description | +|-------|-------------| +| `id` | Stable rule identifier used in JSON, SARIF, and GitHub annotations | +| `files` | File name or path globs to match | +| `mustBeUnder` | Directory globs where matching files are allowed | +| `severity` | Optional severity, defaults to `error` | + +## Required Groups + +Required groups model repository contracts that are more expressive than a single required path. + +```yaml +requiredGroups: + - id: build-entrypoint + oneOf: ["Makefile", "Taskfile.yml", "justfile"] + + - id: commands-have-main + eachDirMatching: "cmd/*" + mustContain: ["main.go"] + requireMatch: true + + - id: packages-have-docs + eachDirMatching: "internal/*" + mustContainOneOf: ["README.md", "doc.go"] +``` + +| Field | Description | +|-------|-------------| +| `oneOf` | At least one listed path or glob must exist | +| `eachDirMatching` | Directory glob to apply per-directory checks to | +| `mustContain` | Every matching directory must contain each listed file | +| `mustContainOneOf` | Every matching directory must contain at least one listed file | +| `requireMatch` | Fail if `eachDirMatching` finds no directories | + +## Boundary Rules + +Boundary rules parse imports and block unwanted dependencies between layers. They are language-aware for Go, JavaScript, TypeScript, and Python source files. + +```yaml +boundaries: + - id: domain-no-db + from: "internal/domain/**" + cannotImport: + - "internal/db/**" + - "internal/http/**" +``` + +For Go module imports, structlint reads `go.mod` and converts imports like `example.com/app/internal/db` to `internal/db` before matching `cannotImport`. For JS/TS relative imports, paths like `../db/client` are resolved relative to the importing file. For Python, dotted imports like `app.db.client` are normalized to `app/db/client`. + ## Complete Example ```yaml @@ -206,6 +275,23 @@ file_naming_pattern: - "README.md" - ".gitignore" +placement: + - id: migrations-only + files: ["*.sql"] + mustBeUnder: ["migrations/**"] + +requiredGroups: + - id: build-entrypoint + oneOf: ["Makefile", "Taskfile.yml", "justfile"] + - id: commands-have-main + eachDirMatching: "cmd/*" + mustContain: ["main.go"] + +boundaries: + - id: domain-no-infrastructure + from: "internal/domain/**" + cannotImport: ["internal/db/**", "internal/http/**"] + ignore: - ".git" - "vendor" diff --git a/internal/cli/validate.go b/internal/cli/validate.go index 4bd3fae..17015c6 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -27,6 +27,22 @@ func NewValidateCmd() *cli.Command { Usage: "path to save the JSON report", Sources: cli.EnvVars("STRUCTLINT_JSON_OUTPUT"), }, + &cli.StringFlag{ + Name: "format", + Usage: "output format: text|json|sarif|github", + Value: "text", + Sources: cli.EnvVars("STRUCTLINT_FORMAT"), + }, + &cli.StringFlag{ + Name: "baseline", + Usage: "JSON report with known violations to suppress", + Sources: cli.EnvVars("STRUCTLINT_BASELINE"), + }, + &cli.BoolFlag{ + Name: "changed-only", + Usage: "only validate changed files from git diff against HEAD", + Sources: cli.EnvVars("STRUCTLINT_CHANGED_ONLY"), + }, &cli.BoolFlag{ Name: "silent", Usage: "suppress all output except for the JSON report", @@ -70,11 +86,22 @@ func NewValidateCmd() *cli.Command { if path == "" { path = "." } + if cmd.Bool("changed-only") { + v.LoadChangedPaths(path) + } v.ValidateDirStructure(path) v.ValidateFileNaming(path) v.ValidateRequiredPaths(path) v.ValidateRequiredFiles(path) - v.PrintSummary() + v.ValidatePlacement(path) + v.ValidateRequiredGroups(path) + v.ValidateBoundaries(path) + + if baseline := cmd.String("baseline"); baseline != "" { + if err := v.ApplyBaseline(baseline); err != nil { + return err + } + } // Save JSON report if requested jsonOutput := cmd.String("json-output") @@ -84,6 +111,23 @@ func NewValidateCmd() *cli.Command { } } + switch format := cmd.String("format"); format { + case "text", "": + v.PrintSummary() + case "json": + if err := v.PrintJSONReport(); err != nil { + return err + } + case "sarif": + if err := v.PrintSARIFReport(); err != nil { + return err + } + case "github": + v.PrintGitHubAnnotations() + default: + return fmt.Errorf("unknown output format: %s", format) + } + // Return error if validation failed if len(v.Errors) > 0 { return fmt.Errorf("validation failed with %d errors", len(v.Errors)) diff --git a/internal/config/config.go b/internal/config/config.go index 48562bb..c8d8d10 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,9 @@ package config import ( + "bytes" "encoding/json" + "fmt" "os" "path/filepath" @@ -13,6 +15,9 @@ type Config struct { DirStructure DirStructure `yaml:"dir_structure" json:"dir_structure"` FileNamingPattern FileNamingPattern `yaml:"file_naming_pattern" json:"file_naming_pattern"` Ignore []string `yaml:"ignore" json:"ignore"` + Placement []PlacementRule `yaml:"placement" json:"placement"` + RequiredGroups []RequiredGroup `yaml:"requiredGroups" json:"requiredGroups"` + Boundaries []BoundaryRule `yaml:"boundaries" json:"boundaries"` } // DirStructure represents the directory structure validation rules. @@ -29,6 +34,33 @@ type FileNamingPattern struct { Required []string `yaml:"required" json:"required"` } +// PlacementRule requires matching files to live under one of the allowed roots. +type PlacementRule struct { + ID string `yaml:"id" json:"id"` + Files []string `yaml:"files" json:"files"` + MustBeUnder []string `yaml:"mustBeUnder" json:"mustBeUnder"` + Severity string `yaml:"severity" json:"severity"` +} + +// RequiredGroup supports higher-level required-file contracts. +type RequiredGroup struct { + ID string `yaml:"id" json:"id"` + OneOf []string `yaml:"oneOf" json:"oneOf"` + EachDirMatching string `yaml:"eachDirMatching" json:"eachDirMatching"` + MustContain []string `yaml:"mustContain" json:"mustContain"` + MustContainOneOf []string `yaml:"mustContainOneOf" json:"mustContainOneOf"` + RequireMatch bool `yaml:"requireMatch" json:"requireMatch"` + Severity string `yaml:"severity" json:"severity"` +} + +// BoundaryRule blocks imports across configured source boundaries. +type BoundaryRule struct { + ID string `yaml:"id" json:"id"` + From string `yaml:"from" json:"from"` + CannotImport []string `yaml:"cannotImport" json:"cannotImport"` + Severity string `yaml:"severity" json:"severity"` +} + // LoadConfig loads the configuration from a file. func LoadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) @@ -39,14 +71,61 @@ func LoadConfig(path string) (*Config, error) { var config Config ext := filepath.Ext(path) if ext == ".yaml" || ext == ".yml" { - err = yaml.Unmarshal(data, &config) + err = yaml.UnmarshalStrict(data, &config) } else { - err = json.Unmarshal(data, &config) + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&config) } if err != nil { return nil, err } + if err := config.Validate(); err != nil { + return nil, err + } + return &config, nil } + +// Validate catches malformed rule definitions before repository walking starts. +func (c *Config) Validate() error { + for i, rule := range c.Placement { + if rule.ID == "" { + return fmt.Errorf("placement[%d] missing id", i) + } + if len(rule.Files) == 0 { + return fmt.Errorf("placement[%s] must define files", rule.ID) + } + if len(rule.MustBeUnder) == 0 { + return fmt.Errorf("placement[%s] must define mustBeUnder", rule.ID) + } + } + + for i, group := range c.RequiredGroups { + if group.ID == "" { + return fmt.Errorf("requiredGroups[%d] missing id", i) + } + if len(group.OneOf) == 0 && group.EachDirMatching == "" { + return fmt.Errorf("requiredGroups[%s] must define oneOf or eachDirMatching", group.ID) + } + if group.EachDirMatching != "" && len(group.MustContain) == 0 && len(group.MustContainOneOf) == 0 { + return fmt.Errorf("requiredGroups[%s] must define mustContain or mustContainOneOf", group.ID) + } + } + + for i, rule := range c.Boundaries { + if rule.ID == "" { + return fmt.Errorf("boundaries[%d] missing id", i) + } + if rule.From == "" { + return fmt.Errorf("boundaries[%s] must define from", rule.ID) + } + if len(rule.CannotImport) == 0 { + return fmt.Errorf("boundaries[%s] must define cannotImport", rule.ID) + } + } + + return nil +} diff --git a/internal/validator/reports.go b/internal/validator/reports.go new file mode 100644 index 0000000..f5ba945 --- /dev/null +++ b/internal/validator/reports.go @@ -0,0 +1,109 @@ +package validator + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// PrintJSONReport writes the same machine-readable report used by --json-output. +func (v *Validator) PrintJSONReport() error { + data, err := json.MarshalIndent(v.report(), "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// PrintGitHubAnnotations writes GitHub Actions workflow command annotations. +func (v *Validator) PrintGitHubAnnotations() { + for _, violation := range v.sortedViolations() { + level := "error" + if violation.Severity == "warning" { + level = "warning" + } + fmt.Printf("::%s file=%s,title=%s::%s\n", level, violation.Path, violation.Code, escapeGitHub(violation.Message)) + } +} + +// PrintSARIFReport writes a small SARIF 2.1.0 report for code scanning systems. +func (v *Validator) PrintSARIFReport() error { + rules := map[string]map[string]string{} + results := make([]map[string]any, 0, len(v.Violations)) + for _, violation := range v.sortedViolations() { + rules[violation.Code] = map[string]string{ + "id": violation.Code, + "name": violation.Code, + } + level := "error" + if violation.Severity == "warning" { + level = "warning" + } + results = append(results, map[string]any{ + "ruleId": violation.Code, + "level": level, + "message": map[string]string{"text": violation.Message}, + "locations": []map[string]any{ + { + "physicalLocation": map[string]any{ + "artifactLocation": map[string]string{"uri": violation.Path}, + }, + }, + }, + }) + } + + ruleList := make([]map[string]string, 0, len(rules)) + for _, rule := range rules { + ruleList = append(ruleList, rule) + } + report := map[string]any{ + "version": "2.1.0", + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "runs": []map[string]any{ + { + "tool": map[string]any{ + "driver": map[string]any{ + "name": "structlint", + "rules": ruleList, + }, + }, + "results": results, + }, + }, + } + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +func (v *Validator) report() JSONReport { + return JSONReport{ + Successes: v.Successes, + Failures: len(v.Errors), + TotalViolations: len(v.Errors), + Errors: append([]string(nil), v.Errors...), + Violations: v.sortedViolations(), + Summary: v.GetValidationSummary(false), + } +} + +func escapeGitHub(value string) string { + replacer := strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A") + return replacer.Replace(value) +} + +// SaveJSONReport saves the validation results to a JSON file. +func (v *Validator) SaveJSONReport(path string) error { + data, err := json.MarshalIndent(v.report(), "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0o644) +} diff --git a/internal/validator/rules.go b/internal/validator/rules.go new file mode 100644 index 0000000..b201898 --- /dev/null +++ b/internal/validator/rules.go @@ -0,0 +1,287 @@ +package validator + +import ( + "bufio" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "strings" +) + +var ( + jsImportPattern = regexp.MustCompile(`^\s*import(?:\s+[^'"]+\s+from\s+)?['"]([^'"]+)['"]`) + jsRequirePattern = regexp.MustCompile(`require\(\s*['"]([^'"]+)['"]\s*\)`) + pythonImportPattern = regexp.MustCompile(`^\s*import\s+([A-Za-z0-9_./]+)`) + pythonFromPattern = regexp.MustCompile(`^\s*from\s+([A-Za-z0-9_./]+)\s+import\s+`) + supportedBoundaryExt = map[string]bool{ + ".go": true, + ".js": true, + ".jsx": true, + ".ts": true, + ".tsx": true, + ".mjs": true, + ".cjs": true, + ".py": true, + } +) + +func cleanRoot(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + return filepath.Clean(path) + } + return abs +} + +func relativePath(root, currentPath string) string { + abs, err := filepath.Abs(currentPath) + if err != nil { + return normalizePath(currentPath) + } + rel, err := filepath.Rel(root, abs) + if err != nil { + return normalizePath(currentPath) + } + return normalizePath(rel) +} + +func normalizePath(path string) string { + path = filepath.ToSlash(filepath.Clean(path)) + if path == "" || path == "." { + return "." + } + return strings.TrimPrefix(path, "./") +} + +func pathMatches(path, pattern string) bool { + path = normalizePath(path) + pattern = normalizePath(pattern) + if pattern == "." { + return path == "." + } + if strings.HasSuffix(pattern, "/**") { + base := strings.TrimSuffix(pattern, "/**") + if path == base || strings.HasPrefix(path, base+"/") { + return true + } + } + return matches(path, pattern) +} + +func isParentOfPattern(path, pattern string) bool { + path = normalizePath(path) + pattern = normalizePath(pattern) + if path == "." { + return true + } + base := patternRoot(pattern) + return base == path || (base != "" && strings.HasPrefix(base, path+"/")) +} + +func patternRoot(pattern string) string { + pattern = normalizePath(pattern) + cut := len(pattern) + for _, marker := range []string{"*", "?", "[", "{"} { + if idx := strings.Index(pattern, marker); idx >= 0 && idx < cut { + cut = idx + } + } + root := strings.TrimSuffix(pattern[:cut], "/") + if idx := strings.LastIndex(root, "/"); idx >= 0 { + root = root[:idx] + } + if root == "" { + return "." + } + return root +} + +func matchesAnyFile(relPath, fileName string, patterns []string) bool { + for _, pattern := range patterns { + if pathMatches(fileName, pattern) || pathMatches(relPath, pattern) { + return true + } + } + return false +} + +func underAny(relPath string, roots []string) bool { + for _, root := range roots { + if pathMatches(relPath, root) || pathMatches(filepath.ToSlash(filepath.Dir(relPath)), root) { + return true + } + } + return false +} + +func severity(value string) string { + if value == "" { + return "error" + } + return value +} + +func existsAny(root string, patterns []string, ignores []string) bool { + found := false + _ = filepath.Walk(root, func(currentPath string, info os.FileInfo, err error) error { + if err != nil || found { + return nil + } + rel := relativePath(root, currentPath) + for _, ignored := range ignores { + if pathMatches(rel, ignored) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + } + for _, pattern := range patterns { + if pathMatches(rel, pattern) || (!info.IsDir() && pathMatches(info.Name(), pattern)) { + found = true + return filepath.SkipAll + } + } + return nil + }) + return found +} + +func existsAt(root, relPath string) bool { + _, err := os.Stat(filepath.Join(root, filepath.FromSlash(relPath))) + return err == nil +} + +func matchingDirs(root, pattern string, ignores []string) []string { + var dirs []string + _ = filepath.Walk(root, func(currentPath string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + rel := relativePath(root, currentPath) + for _, ignored := range ignores { + if pathMatches(rel, ignored) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + } + if info.IsDir() && pathMatches(rel, pattern) { + dirs = append(dirs, rel) + } + return nil + }) + return dirs +} + +func readGoModule(root string) string { + file, err := os.Open(filepath.Join(root, "go.mod")) + if err != nil { + return "" + } + defer func() { _ = file.Close() }() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")) + } + } + return "" +} + +func sourceImports(path, relPath string) ([]string, error) { + switch filepath.Ext(relPath) { + case ".go": + return goImports(path) + case ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs": + return jsImports(path) + case ".py": + return pythonImports(path) + default: + return nil, nil + } +} + +func goImports(path string) ([]string, error) { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return nil, err + } + imports := make([]string, 0, len(file.Imports)) + for _, imp := range file.Imports { + imports = append(imports, strings.Trim(imp.Path.Value, `"`)) + } + return imports, nil +} + +func jsImports(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var imports []string + for _, line := range strings.Split(string(data), "\n") { + if match := jsImportPattern.FindStringSubmatch(line); len(match) == 2 { + imports = append(imports, match[1]) + } + for _, match := range jsRequirePattern.FindAllStringSubmatch(line, -1) { + if len(match) == 2 { + imports = append(imports, match[1]) + } + } + } + return imports, nil +} + +func pythonImports(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + var imports []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if match := pythonImportPattern.FindStringSubmatch(line); len(match) == 2 { + imports = append(imports, strings.ReplaceAll(match[1], ".", "/")) + } + if match := pythonFromPattern.FindStringSubmatch(line); len(match) == 2 { + imports = append(imports, strings.ReplaceAll(match[1], ".", "/")) + } + } + return imports, scanner.Err() +} + +func importToLocalPath(modulePath, importPath, fromPath string) string { + if modulePath == "" { + return resolveRelativeImport(importPath, fromPath) + } + if importPath == modulePath { + return "." + } + prefix := modulePath + "/" + if strings.HasPrefix(importPath, prefix) { + return strings.TrimPrefix(importPath, prefix) + } + return resolveRelativeImport(importPath, fromPath) +} + +func resolveRelativeImport(importPath, fromPath string) string { + if strings.HasPrefix(importPath, ".") { + return normalizePath(filepath.ToSlash(filepath.Join(filepath.Dir(fromPath), importPath))) + } + return strings.ReplaceAll(importPath, ".", "/") +} + +func isSupportedBoundaryFile(path string) bool { + return supportedBoundaryExt[filepath.Ext(path)] +} + +func violationKey(v Violation) string { + return v.Code + "\x00" + v.Path + "\x00" + v.Rule +} diff --git a/internal/validator/types.go b/internal/validator/types.go index ac93056..1f742f3 100644 --- a/internal/validator/types.go +++ b/internal/validator/types.go @@ -1,9 +1,20 @@ package validator +// Violation is a stable, machine-readable validation failure for CI systems. +type Violation struct { + Code string `json:"code"` + Severity string `json:"severity"` + Path string `json:"path"` + Rule string `json:"rule"` + Message string `json:"message"` +} + // JSONReport represents the structure of the JSON report. type JSONReport struct { - Successes int `json:"successes"` - Failures int `json:"failures"` - Errors []string `json:"errors"` - Summary ValidationSummary `json:"summary,omitempty"` + Successes int `json:"successes"` + Failures int `json:"failures"` + TotalViolations int `json:"total_violations"` + Errors []string `json:"errors"` + Violations []Violation `json:"violations"` + Summary ValidationSummary `json:"summary,omitempty"` } diff --git a/internal/validator/validator.go b/internal/validator/validator.go index fbc3ef9..9063a43 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -1,11 +1,14 @@ package validator import ( + "bufio" "encoding/json" "fmt" "log/slog" "os" + "os/exec" "path/filepath" + "sort" "strings" "github.com/AxeForging/structlint/internal/config" @@ -16,11 +19,14 @@ import ( type Validator struct { Config *config.Config Errors []string + Violations []Violation Successes int Logger *slog.Logger Silent bool GroupViolations bool Verbose bool // Show all allowed files, not just violations + ChangedOnly bool + changedPaths map[string]bool } // New creates a new Validator. @@ -28,6 +34,7 @@ func New(cfg *config.Config, logger *slog.Logger) *Validator { return &Validator{ Config: cfg, Errors: []string{}, + Violations: []Violation{}, Successes: 0, Logger: logger, Silent: false, @@ -38,14 +45,16 @@ func New(cfg *config.Config, logger *slog.Logger) *Validator { // ValidateDirStructure validates the directory structure. func (v *Validator) ValidateDirStructure(path string) { + root := cleanRoot(path) err := filepath.Walk(path, func(currentPath string, info os.FileInfo, err error) error { if err != nil { return err } + relPath := relativePath(root, currentPath) // Check if the path should be ignored for _, ignored := range v.Config.Ignore { - if matches(currentPath, ignored) { + if pathMatches(relPath, ignored) { if info.IsDir() { return filepath.SkipDir } @@ -56,10 +65,9 @@ func (v *Validator) ValidateDirStructure(path string) { if info.IsDir() { // Check against disallowed paths for _, disallowed := range v.Config.DirStructure.DisallowedPaths { - if matches(currentPath, disallowed) { - msg := fmt.Sprintf("Disallowed directory found: %s", currentPath) - v.printError(msg) - v.Errors = append(v.Errors, msg) + if pathMatches(relPath, disallowed) { + msg := fmt.Sprintf("Disallowed directory found: %s", relPath) + v.addViolation("disallowed_directory", "error", relPath, disallowed, msg) return filepath.SkipDir // Skip validating contents of disallowed directories } } @@ -67,36 +75,25 @@ func (v *Validator) ValidateDirStructure(path string) { // Check against allowed paths isAllowed := false for _, allowed := range v.Config.DirStructure.AllowedPaths { - if matches(currentPath, allowed) { + if pathMatches(relPath, allowed) || isParentOfPattern(relPath, allowed) { isAllowed = true break } } - // Also consider a directory allowed if it's a parent of an allowed path - if !isAllowed { - for _, allowed := range v.Config.DirStructure.AllowedPaths { - if strings.HasPrefix(allowed, currentPath) { - isAllowed = true - break - } - } - } - if isAllowed { - msg := fmt.Sprintf("Allowed directory found: %s", currentPath) + msg := fmt.Sprintf("Allowed directory found: %s", relPath) v.printSuccess(msg) v.Successes++ } else { - msg := fmt.Sprintf("Directory not in allowed list: %s", currentPath) - v.printError(msg) - v.Errors = append(v.Errors, msg) + msg := fmt.Sprintf("Directory not in allowed list: %s", relPath) + v.addViolation("unallowed_directory", "error", relPath, "dir_structure.allowedPaths", msg) } } return nil }) if err != nil { - v.Errors = append(v.Errors, fmt.Sprintf("Error walking directory: %s", err)) + v.addViolation("walk_error", "error", path, "filesystem", fmt.Sprintf("Error walking directory: %s", err)) } } @@ -111,14 +108,16 @@ func matches(path, pattern string) bool { // ValidateFileNaming validates the file naming conventions. func (v *Validator) ValidateFileNaming(path string) { + root := cleanRoot(path) err := filepath.Walk(path, func(currentPath string, info os.FileInfo, err error) error { if err != nil { return err } + relPath := relativePath(root, currentPath) // Check if the path should be ignored for _, ignored := range v.Config.Ignore { - if matches(currentPath, ignored) { + if pathMatches(relPath, ignored) { if info.IsDir() { return filepath.SkipDir } @@ -127,14 +126,16 @@ func (v *Validator) ValidateFileNaming(path string) { } if !info.IsDir() { + if v.shouldSkipChanged(relPath) { + return nil + } fileName := info.Name() // Check against disallowed patterns for _, disallowed := range v.Config.FileNamingPattern.Disallowed { - if matches(fileName, disallowed) { - msg := fmt.Sprintf("Disallowed file naming pattern found: %s", currentPath) - v.printError(msg) - v.Errors = append(v.Errors, msg) + if pathMatches(fileName, disallowed) || pathMatches(relPath, disallowed) { + msg := fmt.Sprintf("Disallowed file naming pattern found: %s", relPath) + v.addViolation("disallowed_file_pattern", "error", relPath, disallowed, msg) return nil } } @@ -142,25 +143,24 @@ func (v *Validator) ValidateFileNaming(path string) { // Check against allowed patterns isAllowed := false for _, allowed := range v.Config.FileNamingPattern.Allowed { - if matches(fileName, allowed) { + if pathMatches(fileName, allowed) || pathMatches(relPath, allowed) { isAllowed = true break } } if isAllowed { - msg := fmt.Sprintf("Allowed file naming pattern found: %s", currentPath) + msg := fmt.Sprintf("Allowed file naming pattern found: %s", relPath) v.printSuccess(msg) v.Successes++ } else { - msg := fmt.Sprintf("File not in allowed naming pattern: %s", currentPath) - v.printError(msg) - v.Errors = append(v.Errors, msg) + msg := fmt.Sprintf("File not in allowed naming pattern: %s", relPath) + v.addViolation("unallowed_file_pattern", "error", relPath, "file_naming_pattern.allowed", msg) } } return nil }) if err != nil { - v.Errors = append(v.Errors, fmt.Sprintf("Error walking directory: %s", err)) + v.addViolation("walk_error", "error", path, "filesystem", fmt.Sprintf("Error walking directory: %s", err)) } } @@ -193,23 +193,6 @@ func (v *Validator) PrintSummary() { } } -// SaveJSONReport saves the validation results to a JSON file. -func (v *Validator) SaveJSONReport(path string) error { - report := JSONReport{ - Successes: v.Successes, - Failures: len(v.Errors), - Errors: v.Errors, - Summary: v.GetValidationSummary(false), // Don't include all errors in summary to avoid duplication - } - - data, err := json.MarshalIndent(report, "", " ") - if err != nil { - return err - } - - return os.WriteFile(path, data, 0o644) -} - // ValidateRequiredPaths validates that all required directories exist. func (v *Validator) ValidateRequiredPaths(path string) { for _, requiredPath := range v.Config.DirStructure.RequiredPaths { @@ -217,8 +200,8 @@ func (v *Validator) ValidateRequiredPaths(path string) { // Check if the required path exists if _, err := os.Stat(fullPath); os.IsNotExist(err) { - v.printError(fmt.Sprintf("Required directory missing: %s", requiredPath)) - v.Errors = append(v.Errors, fmt.Sprintf("Required directory missing: %s", requiredPath)) + msg := fmt.Sprintf("Required directory missing: %s", requiredPath) + v.addViolation("missing_required_directory", "error", requiredPath, "dir_structure.requiredPaths", msg) } else { v.printSuccess(fmt.Sprintf("Required directory found: %s", requiredPath)) v.Successes++ @@ -228,6 +211,7 @@ func (v *Validator) ValidateRequiredPaths(path string) { // ValidateRequiredFiles validates that all required files exist. func (v *Validator) ValidateRequiredFiles(path string) { + root := cleanRoot(path) for _, requiredFile := range v.Config.FileNamingPattern.Required { // Check if any file matching the pattern exists found := false @@ -235,10 +219,11 @@ func (v *Validator) ValidateRequiredFiles(path string) { if err != nil { return err } + relPath := relativePath(root, currentPath) // Check if the path should be ignored for _, ignored := range v.Config.Ignore { - if matches(currentPath, ignored) { + if pathMatches(relPath, ignored) { if info.IsDir() { return filepath.SkipDir } @@ -249,13 +234,8 @@ func (v *Validator) ValidateRequiredFiles(path string) { if !info.IsDir() { // For required file patterns, we need to check both the filename and the relative path fileName := info.Name() - relPath, err := filepath.Rel(path, currentPath) - if err != nil { - relPath = currentPath // Fallback to full path if relative path fails - } - // Check if either the filename or the relative path matches the pattern - if matches(fileName, requiredFile) || matches(relPath, requiredFile) { + if pathMatches(fileName, requiredFile) || pathMatches(relPath, requiredFile) { found = true return filepath.SkipAll // Stop walking once we find a match } @@ -263,7 +243,7 @@ func (v *Validator) ValidateRequiredFiles(path string) { return nil }) if err != nil { - v.Errors = append(v.Errors, fmt.Sprintf("Error checking for required file %s: %s", requiredFile, err)) + v.addViolation("walk_error", "error", requiredFile, "file_naming_pattern.required", fmt.Sprintf("Error checking for required file %s: %s", requiredFile, err)) continue } @@ -271,10 +251,233 @@ func (v *Validator) ValidateRequiredFiles(path string) { v.printSuccess(fmt.Sprintf("Required file pattern found: %s", requiredFile)) v.Successes++ } else { - v.printError(fmt.Sprintf("Required file pattern missing: %s", requiredFile)) - v.Errors = append(v.Errors, fmt.Sprintf("Required file pattern missing: %s", requiredFile)) + msg := fmt.Sprintf("Required file pattern missing: %s", requiredFile) + v.addViolation("missing_required_file", "error", requiredFile, requiredFile, msg) + } + } +} + +// ValidatePlacement validates file placement rules. +func (v *Validator) ValidatePlacement(path string) { + root := cleanRoot(path) + _ = filepath.Walk(path, func(currentPath string, info os.FileInfo, err error) error { + if err != nil { + v.addViolation("walk_error", "error", currentPath, "placement", fmt.Sprintf("Error walking directory: %s", err)) + return nil } + relPath := relativePath(root, currentPath) + for _, ignored := range v.Config.Ignore { + if pathMatches(relPath, ignored) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + } + if info.IsDir() || v.shouldSkipChanged(relPath) { + return nil + } + for _, rule := range v.Config.Placement { + if !matchesAnyFile(relPath, info.Name(), rule.Files) { + continue + } + if underAny(relPath, rule.MustBeUnder) { + v.Successes++ + continue + } + msg := fmt.Sprintf("File placement violation: %s must be under %s", relPath, strings.Join(rule.MustBeUnder, ", ")) + v.addViolation("placement_violation", severity(rule.Severity), relPath, rule.ID, msg) + } + return nil + }) +} + +// ValidateRequiredGroups validates one-of and per-directory requirements. +func (v *Validator) ValidateRequiredGroups(path string) { + root := cleanRoot(path) + for _, group := range v.Config.RequiredGroups { + if len(group.OneOf) > 0 { + if existsAny(root, group.OneOf, v.Config.Ignore) { + v.Successes++ + } else { + msg := fmt.Sprintf("Required group missing one of: %s", strings.Join(group.OneOf, ", ")) + v.addViolation("missing_required_group", severity(group.Severity), group.ID, group.ID, msg) + } + } + if group.EachDirMatching == "" { + continue + } + matches := matchingDirs(root, group.EachDirMatching, v.Config.Ignore) + if len(matches) == 0 && group.RequireMatch { + msg := fmt.Sprintf("Required group matched no directories: %s", group.EachDirMatching) + v.addViolation("missing_required_group_match", severity(group.Severity), group.EachDirMatching, group.ID, msg) + } + for _, dir := range matches { + for _, required := range group.MustContain { + if !existsAt(root, filepath.ToSlash(filepath.Join(dir, required))) { + msg := fmt.Sprintf("Directory %s missing required file: %s", dir, required) + v.addViolation("missing_group_file", severity(group.Severity), filepath.ToSlash(filepath.Join(dir, required)), group.ID, msg) + } else { + v.Successes++ + } + } + if len(group.MustContainOneOf) > 0 { + found := false + for _, required := range group.MustContainOneOf { + if existsAt(root, filepath.ToSlash(filepath.Join(dir, required))) { + found = true + break + } + } + if found { + v.Successes++ + } else { + msg := fmt.Sprintf("Directory %s missing one of: %s", dir, strings.Join(group.MustContainOneOf, ", ")) + v.addViolation("missing_group_file", severity(group.Severity), dir, group.ID, msg) + } + } + } + } +} + +// ValidateBoundaries validates import boundaries for supported source files. +func (v *Validator) ValidateBoundaries(path string) { + root := cleanRoot(path) + modulePath := readGoModule(root) + _ = filepath.Walk(path, func(currentPath string, info os.FileInfo, err error) error { + if err != nil { + v.addViolation("walk_error", "error", currentPath, "boundaries", fmt.Sprintf("Error walking directory: %s", err)) + return nil + } + relPath := relativePath(root, currentPath) + for _, ignored := range v.Config.Ignore { + if pathMatches(relPath, ignored) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + } + if info.IsDir() || !isSupportedBoundaryFile(relPath) || v.shouldSkipChanged(relPath) { + return nil + } + for _, rule := range v.Config.Boundaries { + if !pathMatches(relPath, rule.From) { + continue + } + imports, err := sourceImports(currentPath, relPath) + if err != nil { + v.addViolation("parse_error", "error", relPath, rule.ID, fmt.Sprintf("Failed to parse imports: %s", err)) + continue + } + for _, imp := range imports { + localImport := importToLocalPath(modulePath, imp, relPath) + for _, forbidden := range rule.CannotImport { + if pathMatches(imp, forbidden) || pathMatches(localImport, forbidden) { + msg := fmt.Sprintf("Boundary violation: %s imports %s", relPath, imp) + v.addViolation("boundary_violation", severity(rule.Severity), relPath, rule.ID, msg) + } + } + } + v.Successes++ + } + return nil + }) +} + +// LoadChangedPaths populates the changed-file set used by --changed-only. +func (v *Validator) LoadChangedPaths(path string) { + v.ChangedOnly = true + root := cleanRoot(path) + changed := map[string]bool{} + cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMRT", "HEAD") + cmd.Dir = root + out, err := cmd.Output() + if err != nil { + v.changedPaths = changed + return + } + scanner := bufio.NewScanner(strings.NewReader(string(out))) + for scanner.Scan() { + p := normalizePath(scanner.Text()) + if p != "" { + changed[p] = true + } + } + v.changedPaths = changed +} + +// ApplyBaseline suppresses violations already recorded in a previous JSON report. +func (v *Validator) ApplyBaseline(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + var report JSONReport + if err := json.Unmarshal(data, &report); err != nil { + return err + } + known := map[string]bool{} + for _, violation := range report.Violations { + known[violationKey(violation)] = true + } + if len(known) == 0 { + for _, errText := range report.Errors { + known[errText] = true + } + } + var keptViolations []Violation + var keptErrors []string + for _, violation := range v.Violations { + if known[violationKey(violation)] || known[violation.Message] { + continue + } + keptViolations = append(keptViolations, violation) + keptErrors = append(keptErrors, violation.Message) + } + v.Violations = keptViolations + v.Errors = keptErrors + return nil +} + +func (v *Validator) addViolation(code, sev, path, rule, message string) { + if sev == "" { + sev = "error" + } + violation := Violation{ + Code: code, + Severity: sev, + Path: normalizePath(path), + Rule: rule, + Message: message, + } + v.printError(message) + v.Violations = append(v.Violations, violation) + v.Errors = append(v.Errors, message) +} + +func (v *Validator) sortedViolations() []Violation { + violations := append([]Violation(nil), v.Violations...) + sort.SliceStable(violations, func(i, j int) bool { + if violations[i].Path == violations[j].Path { + if violations[i].Code == violations[j].Code { + return violations[i].Rule < violations[j].Rule + } + return violations[i].Code < violations[j].Code + } + return violations[i].Path < violations[j].Path + }) + return violations +} + +func (v *Validator) shouldSkipChanged(relPath string) bool { + if !v.ChangedOnly { + return false + } + if len(v.changedPaths) == 0 { + return true } + return !v.changedPaths[normalizePath(relPath)] } func (v *Validator) printSuccess(message string) { diff --git a/test/performance_test.go b/test/performance_test.go index 64c1edc..6b2fd4e 100644 --- a/test/performance_test.go +++ b/test/performance_test.go @@ -111,7 +111,7 @@ ignore: ["vendor"] t.Logf("Results: %d successes, %d failures", v.Successes, len(v.Errors)) // Check if we have the expected number of violations - expectedViolations := 302 // 100 .env + 100 .log + 100 .tmp + 1 root dir + 1 config file + expectedViolations := 301 // 100 .env + 100 .log + 100 .tmp + 1 config file if len(v.Errors) != expectedViolations { t.Errorf("Expected %d violations, got %d", expectedViolations, len(v.Errors)) } diff --git a/test/pipeline_rules_test.go b/test/pipeline_rules_test.go new file mode 100644 index 0000000..f35041a --- /dev/null +++ b/test/pipeline_rules_test.go @@ -0,0 +1,203 @@ +package test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/AxeForging/structlint/internal/config" + "github.com/AxeForging/structlint/internal/logging" + "github.com/AxeForging/structlint/internal/validator" +) + +func TestPlacementRequiredGroupsAndBoundaries(t *testing.T) { + project := createPipelineRuleProject(t, map[string]string{ + "go.mod": "module example.com/app\n\ngo 1.24\n", + "README.md": "# app", + "Makefile": "test:\n\tgo test ./...", + "cmd/api/main.go": "package main", + "internal/domain/user.go": "package domain\n\nimport _ \"example.com/app/internal/db\"\n", + "internal/db/db.go": "package db", + "migrations/001_init.sql": "create table users(id int);", + "internal/service/orphan_test.go": "package service", + }) + + cfg, err := config.LoadConfig(filepath.Join(project, ".structlint.yaml")) + if err != nil { + t.Fatalf("load config: %v", err) + } + logger, _ := logging.New("error", true) + v := validator.New(cfg, logger) + v.Silent = true + v.ValidatePlacement(project) + v.ValidateRequiredGroups(project) + v.ValidateBoundaries(project) + + if len(v.Violations) != 1 { + t.Fatalf("expected one boundary violation, got %d: %#v", len(v.Violations), v.Violations) + } + got := v.Violations[0] + if got.Code != "boundary_violation" || got.Rule != "domain-no-db" || got.Path != "internal/domain/user.go" { + t.Fatalf("unexpected violation: %#v", got) + } +} + +func TestBoundaryRulesSupportJavaScriptAndPython(t *testing.T) { + project := createTestProject(t, map[string]string{ + "src/domain/user.ts": "import db from '../db/client'\nexport const user = db\n", + "src/db/client.ts": "export default {}\n", + "app/domain/user.py": "from app.db.client import connect\n", + "app/db/client.py": "def connect(): pass\n", + "package.json": `{"type":"module"}`, + "pyproject.toml": "[project]\nname = \"app\"\n", + "README.md": "# mixed", + }, `dir_structure: + allowedPaths: [".", "src/**", "app/**"] +file_naming_pattern: + allowed: ["*.ts", "*.py", "*.json", "*.toml", "*.md"] +boundaries: + - id: ts-domain-no-db + from: "src/domain/**" + cannotImport: ["src/db/**"] + - id: py-domain-no-db + from: "app/domain/**" + cannotImport: ["app/db/**"] +ignore: [".git"] +`) + + cfg, err := config.LoadConfig(filepath.Join(project, ".structlint.yaml")) + if err != nil { + t.Fatalf("load config: %v", err) + } + logger, _ := logging.New("error", true) + v := validator.New(cfg, logger) + v.Silent = true + v.ValidateBoundaries(project) + + seen := map[string]bool{} + for _, violation := range v.Violations { + seen[violation.Rule] = true + } + for _, rule := range []string{"ts-domain-no-db", "py-domain-no-db"} { + if !seen[rule] { + t.Fatalf("expected %s boundary violation, got %#v", rule, v.Violations) + } + } +} + +func TestPlacementAndRequiredGroupViolationsAreStructured(t *testing.T) { + project := createPipelineRuleProject(t, map[string]string{ + "go.mod": "module example.com/app\n\ngo 1.24\n", + "cmd/api/handler.go": "package main", + "schema.sql": "create table users(id int);", + "internal/app/app.go": "package app", + }) + + cfg, err := config.LoadConfig(filepath.Join(project, ".structlint.yaml")) + if err != nil { + t.Fatalf("load config: %v", err) + } + logger, _ := logging.New("error", true) + v := validator.New(cfg, logger) + v.Silent = true + v.ValidatePlacement(project) + v.ValidateRequiredGroups(project) + + codes := map[string]bool{} + for _, violation := range v.Violations { + codes[violation.Code] = true + if violation.Path == "" || violation.Rule == "" || violation.Message == "" { + t.Fatalf("violation is not fully structured: %#v", violation) + } + } + for _, code := range []string{"placement_violation", "missing_required_group", "missing_group_file"} { + if !codes[code] { + t.Fatalf("missing violation code %s in %#v", code, v.Violations) + } + } +} + +func createPipelineRuleProject(t *testing.T, files map[string]string) string { + t.Helper() + return createTestProject(t, files, `dir_structure: + allowedPaths: [".", "cmd/**", "internal/**", "migrations/**"] +file_naming_pattern: + allowed: ["*.go", "*.mod", "*.md", "Makefile", "*.sql"] +placement: + - id: sql-in-migrations + files: ["*.sql"] + mustBeUnder: ["migrations/**"] + - id: tests-in-test-roots + files: ["*_test.go"] + mustBeUnder: ["test/**", "internal/**"] +requiredGroups: + - id: build-entrypoint + oneOf: ["Makefile", "Taskfile.yml", "justfile"] + - id: commands-have-main + eachDirMatching: "cmd/*" + mustContain: ["main.go"] + requireMatch: true +boundaries: + - id: domain-no-db + from: "internal/domain/**" + cannotImport: ["internal/db/**"] +ignore: [".git"] +`) +} + +func TestStrictConfigRejectsUnknownKeys(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".structlint.yaml") + if err := os.WriteFile(path, []byte(`dir_structure: + allowed_paths: ["cmd/**"] +file_naming_pattern: + allowed: ["*.go"] +`), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + _, err := config.LoadConfig(path) + if err == nil || !strings.Contains(err.Error(), "allowed_paths") { + t.Fatalf("expected unknown key error, got %v", err) + } +} + +func TestJSONReportIncludesTypedViolations(t *testing.T) { + project := createTestProject(t, map[string]string{ + "main.go": "package main", + ".env": "SECRET=1", + }, `dir_structure: + allowedPaths: ["."] +file_naming_pattern: + allowed: ["*.go"] + disallowed: ["*.env*"] +ignore: [] +`) + + cfg, err := config.LoadConfig(filepath.Join(project, ".structlint.yaml")) + if err != nil { + t.Fatalf("load config: %v", err) + } + logger, _ := logging.New("error", true) + v := validator.New(cfg, logger) + v.Silent = true + v.ValidateFileNaming(project) + + reportPath := filepath.Join(t.TempDir(), "report.json") + if err := v.SaveJSONReport(reportPath); err != nil { + t.Fatalf("save report: %v", err) + } + data, err := os.ReadFile(reportPath) + if err != nil { + t.Fatalf("read report: %v", err) + } + var report validator.JSONReport + if err := json.Unmarshal(data, &report); err != nil { + t.Fatalf("unmarshal report: %v", err) + } + if report.TotalViolations != 2 || len(report.Violations) != 2 { + t.Fatalf("expected typed violations in report, got %#v", report) + } +}