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)
+ }
+}