Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/user/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,23 @@ dir_structure:
- `placement`, `requiredGroups`, `boundaries` — keyed by `id`. Same ID → child rule replaces the parent's wholesale. New IDs append.
- Parents resolve depth-first. Chains are cycle-checked and capped at depth 10.

**Compatibility warning:** the `extends` key requires structlint **v0.6.0 or newer**. Older binaries strict-parse the file and reject it with `field extends not found in type config.Config` — pin your CI action and pre-commit rev to a version that supports it, and add a `# requires structlint >= vX.Y` comment at the top of configs that use `extends`.
**Compatibility warning:** the `extends` key requires structlint **v0.6.0 or newer**. Older binaries would strict-parse the file and reject it with `field extends not found in type config.Config` — a confusing error for the actual problem.

To make old-binary failures actionable, add a `# requires structlint >= vX.Y` comment at the top of any config that uses `extends`:

```yaml
# requires structlint >= v0.6.0
extends: go-standard
```

When a binary older than the pragma sees this comment, it stops **before** strict parsing and prints:

```
.structlint.yaml requires structlint >= v0.6.0, but running version is v0.5.0.
Upgrade the binary (go install github.com/AxeForging/structlint/cmd/structlint@latest) …
```

The pragma is checked before strict parsing, so it works even on binaries too old to know about `extends`. Dev builds (unstamped `go run`) skip the check.

### Editor autocomplete via JSON Schema

Expand Down
290 changes: 64 additions & 226 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,34 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/AxeForging/structlint/internal/config"
"github.com/AxeForging/structlint/internal/infer"
"github.com/urfave/cli/v3"
)

// projectTypeToPreset maps `init --type` values to preset names in
// internal/config/presets/. Kept as a distinct map (instead of using the
// preset names directly as --type values) so the shorter --type flag is
// still ergonomic and the preset names remain the source of truth.
var projectTypeToPreset = map[string]string{
"go": "go-standard",
"node": "node-standard",
"python": "python-standard",
"generic": "generic",
}

// projectTypeHeaders is the single-line header prepended to the preset
// content so the generated config file reads like a starter template
// instead of a bare preset dump.
var projectTypeHeaders = map[string]string{
"go": "# structlint configuration for Go projects\n",
"node": "# structlint configuration for Node.js projects\n",
"python": "# structlint configuration for Python projects\n",
"generic": "# structlint configuration\n",
}

// NewInitCmd creates the init command for generating starter configs.
func NewInitCmd() *cli.Command {
return &cli.Command{
Expand Down Expand Up @@ -62,12 +85,11 @@ func NewInitCmd() *cli.Command {
projectType = detectProjectType(".")
}

template, ok := projectTemplates[projectType]
if !ok {
return fmt.Errorf("unknown project type: %s (available: go, node, python, generic)", projectType)
data, err := renderProjectTemplate(projectType)
if err != nil {
return err
}

if err := os.WriteFile(configPath, []byte(template), 0o644); err != nil {
if err := os.WriteFile(configPath, data, 0o644); err != nil {
return fmt.Errorf("failed to write configuration: %w", err)
}

Expand All @@ -78,6 +100,43 @@ func NewInitCmd() *cli.Command {
}
}

// renderProjectTemplate builds a starter config for the requested project
// type by reading the corresponding preset (source of truth for the
// baseline rules) and prepending a project-typed comment header. This
// keeps init's output and `extends:` presets from drifting.
func renderProjectTemplate(projectType string) ([]byte, error) {
presetName, ok := projectTypeToPreset[projectType]
if !ok {
return nil, fmt.Errorf("unknown project type: %s (available: %s)",
projectType, strings.Join(projectTypeList(), ", "))
}
body, err := config.ReadPreset(presetName)
if err != nil {
return nil, fmt.Errorf("read preset for %s: %w", projectType, err)
}
header := projectTypeHeaders[projectType]
buf := make([]byte, 0, len(header)+len(body))
buf = append(buf, header...)
buf = append(buf, body...)
return buf, nil
}

func projectTypeList() []string {
out := make([]string, 0, len(projectTypeToPreset))
for k := range projectTypeToPreset {
out = append(out, k)
}
// Alphabetical for stable error messages.
for i := 1; i < len(out); i++ {
j := i
for j > 0 && out[j-1] > out[j] {
out[j-1], out[j] = out[j], out[j-1]
j--
}
}
return out
}

// detectProjectType guesses the project type from files in the directory.
func detectProjectType(dir string) string {
checks := []struct {
Expand All @@ -99,224 +158,3 @@ func detectProjectType(dir string) string {

return "generic"
}

var projectTemplates = map[string]string{
"go": `# structlint configuration for Go projects
dir_structure:
allowedPaths:
- "."
- "cmd/**"
- "internal/**"
- "pkg/**"
- "api/**"
- "test/**"
- "docs/**"
- "scripts/**"
- ".github/**"
disallowedPaths:
- "vendor/**"
- "node_modules/**"
- "tmp/**"
requiredPaths:
- "cmd"

file_naming_pattern:
allowed:
- "*.go"
- "*.mod"
- "*.sum"
- "*.yaml"
- "*.yml"
- "*.json"
- "*.md"
- "*.txt"
- "Makefile"
- "Dockerfile*"
- "*.sh"
- ".gitignore"
- ".goreleaser.yaml"
- "LICENSE*"
disallowed:
- "*.env*"
- "*.key"
- "*.pem"
- ".DS_Store"
- "*.log"
- "*.tmp"
- "*~"
- "*.swp"
required:
- "go.mod"
- "README.md"
- ".gitignore"

ignore:
- ".git"
- "vendor"
- "bin"
- "dist"
`,

"node": `# structlint configuration for Node.js projects
dir_structure:
allowedPaths:
- "."
- "src/**"
- "lib/**"
- "test/**"
- "tests/**"
- "__tests__/**"
- "docs/**"
- "scripts/**"
- "public/**"
- "config/**"
- ".github/**"
disallowedPaths:
- "node_modules/**"
- "tmp/**"
- "temp/**"
requiredPaths:
- "src"

file_naming_pattern:
allowed:
- "*.js"
- "*.ts"
- "*.jsx"
- "*.tsx"
- "*.json"
- "*.yaml"
- "*.yml"
- "*.md"
- "*.css"
- "*.scss"
- "*.html"
- "*.svg"
- "*.png"
- "*.jpg"
- ".gitignore"
- ".eslintrc*"
- ".prettierrc*"
- "*.config.*"
- "Dockerfile*"
- "LICENSE*"
disallowed:
- "*.env*"
- "*.key"
- "*.pem"
- ".DS_Store"
- "*.log"
- "*~"
- "*.swp"
required:
- "package.json"
- "README.md"

ignore:
- ".git"
- "node_modules"
- "dist"
- "build"
- "coverage"
`,

"python": `# structlint configuration for Python projects
dir_structure:
allowedPaths:
- "."
- "src/**"
- "tests/**"
- "test/**"
- "docs/**"
- "scripts/**"
- ".github/**"
disallowedPaths:
- "__pycache__/**"
- ".tox/**"
- "*.egg-info/**"
- "node_modules/**"
requiredPaths: []

file_naming_pattern:
allowed:
- "*.py"
- "*.pyi"
- "*.toml"
- "*.cfg"
- "*.ini"
- "*.txt"
- "*.yaml"
- "*.yml"
- "*.json"
- "*.md"
- "*.rst"
- "Makefile"
- "Dockerfile*"
- "*.sh"
- ".gitignore"
- ".flake8"
- "LICENSE*"
- "MANIFEST.in"
disallowed:
- "*.env*"
- "*.key"
- "*.pem"
- ".DS_Store"
- "*.log"
- "*~"
- "*.swp"
- "*.pyc"
required:
- "README.md"

ignore:
- ".git"
- "__pycache__"
- ".tox"
- ".venv"
- "venv"
- "dist"
- "build"
- "*.egg-info"
`,

"generic": `# structlint configuration
dir_structure:
allowedPaths:
- "."
- "src/**"
- "lib/**"
- "test/**"
- "tests/**"
- "docs/**"
- "scripts/**"
- ".github/**"
disallowedPaths:
- "tmp/**"
- "temp/**"
- "node_modules/**"
requiredPaths: []

file_naming_pattern:
allowed:
- "*.*"
disallowed:
- "*.env*"
- "*.key"
- "*.pem"
- ".DS_Store"
- "*.log"
- "*.tmp"
- "*~"
- "*.swp"
required:
- "README.md"

ignore:
- ".git"
- "node_modules"
- "vendor"
- "dist"
- "build"
`,
}
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ func parseConfigFile(path string) (*Config, error) {
if err != nil {
return nil, err
}
// Version pragma is checked BEFORE strict parsing so users of newer
// features (e.g. `extends`) get an actionable "upgrade required"
// error instead of a raw yaml `field ... not found` message.
if err := enforceRequiresComment(path, data); err != nil {
return nil, err
}
return parseConfigBytes(data, filepath.Ext(path))
}

Expand Down
23 changes: 23 additions & 0 deletions internal/config/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,29 @@ var presetNames = map[string]string{
"generic": "presets/generic.yaml",
}

// ReadPreset returns the raw YAML bytes of a preset by name (as it would
// appear in `extends:`). Exposed for `structlint init` so the four
// project templates are stored in one place instead of duplicated as
// Go string literals in internal/cli/init.go.
func ReadPreset(name string) ([]byte, error) {
path, ok := presetNames[name]
if !ok {
return nil, fmt.Errorf("unknown preset %q (valid: %s)", name, presetsList())
}
return presetFS.ReadFile(path)
}

// PresetNames returns the sorted list of built-in preset names for
// error messages and completion.
func PresetNames() []string {
out := make([]string, 0, len(presetNames))
for name := range presetNames {
out = append(out, name)
}
sort.Strings(out)
return out
}

const maxExtendsDepth = 10

// loadResolved parses path, resolves its extends chain depth-first with
Expand Down
Loading
Loading