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
16 changes: 16 additions & 0 deletions docs/user/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ structlint validate --staged --silent

Both flags filter file-level rules (naming, placement, boundaries) to the changed set and prune directory-structure walks so pre-existing drift elsewhere in the repo doesn't block the commit. Existence-based rules (`requiredPaths`, `required` files, `requiredGroups`) are always checked in full — otherwise a commit that deletes `README.md` would silently pass.

### init

Generate a starter `.structlint.yaml`.

```bash
structlint init [options]
```

| Option | Default | Description |
|--------|---------|-------------|
| `--infer` | false | Walk the tree and generate config that mirrors what actually exists — `validate` passes as-is. Mutually exclusive with `--type`. |
| `--type` | auto | Template: `go`, `node`, `python`, or `generic`. Auto-detected if omitted. |
| `--force` | false | Overwrite an existing config file. |

Use `--infer` when adopting structlint on an existing codebase; you get a working baseline immediately and tighten rules later.

### hook install

Merge a `structlint validate --staged --silent` invocation into the repository's pre-commit hook chain. Auto-detects lefthook, pre-commit, or a raw git hook; every merge is idempotent and never overwrites content it did not put there.
Expand Down
8 changes: 8 additions & 0 deletions docs/user/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ make build

### 1. Create a Configuration File

For an existing codebase, the fastest path is:

```bash
structlint init --infer
```

`init --infer` walks the actual tree and generates a `.structlint.yaml` that describes what is really there — `validate` passes on the same tree out of the box. Then tighten rules incrementally instead of drowning in violations on day one. Or start from a canned template with `structlint init --type go|node|python|generic`, or hand-write:

```yaml
# .structlint.yaml
dir_structure:
Expand Down
27 changes: 24 additions & 3 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package cli

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"

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

Expand All @@ -19,23 +21,42 @@ func NewInitCmd() *cli.Command {
Name: "type",
Usage: "project type: go, node, python, generic (auto-detected if omitted)",
},
&cli.BoolFlag{
Name: "infer",
Usage: "generate config by inspecting the current tree instead of a template",
},
&cli.BoolFlag{
Name: "force",
Usage: "overwrite existing configuration file",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
Action: func(_ context.Context, cmd *cli.Command) error {
if cmd.Bool("infer") && cmd.IsSet("type") {
return errors.New("--infer and --type are mutually exclusive")
}

configPath := cmd.Root().String("config")
if configPath == "" {
configPath = ".structlint.yaml"
}

// Check if config already exists
if _, err := os.Stat(configPath); err == nil && !cmd.Bool("force") {
return fmt.Errorf("configuration file already exists: %s (use --force to overwrite)", configPath)
}

// Determine project type
if cmd.Bool("infer") {
data, err := infer.Generate(".")
if err != nil {
return fmt.Errorf("infer config: %w", err)
}
if err := os.WriteFile(configPath, data, 0o644); err != nil {
return fmt.Errorf("failed to write configuration: %w", err)
}
fmt.Printf("Created %s from tree inspection\n", configPath)
fmt.Println("Run 'structlint validate' to check your project structure.")
return nil
}

projectType := cmd.String("type")
if projectType == "" {
projectType = detectProjectType(".")
Expand Down
118 changes: 118 additions & 0 deletions internal/infer/heuristics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Package infer generates a baseline .structlint.yaml from the actual
// tree instead of a canned template. Rule of thumb: what we emit must
// make `validate` pass on the same tree. Tightening — removing entries,
// adding `disallowed` — is a later intentional act by the user.
package infer

import (
"sort"
"strings"

"github.com/AxeForging/structlint/internal/validator"
)

// DefaultIgnore is the ignore set applied during the walk. It is also
// emitted in the generated config so what we skipped stays skipped at
// validate time.
var DefaultIgnore = []string{
".git",
"node_modules",
"vendor",
"dist",
"build",
"bin",
}

// AllowedPaths returns "." plus one entry per depth-1 directory —
// `name/**` when the dir has children, bare `name` when empty.
// Files at the root do not produce entries (they're covered by
// file_naming_pattern). Sorted ascending for determinism.
func AllowedPaths(t *validator.Tree) []string {
depth1Dirs := map[string]bool{}
nonEmpty := map[string]bool{}
for _, e := range t.Entries {
if e.RelPath == "." || e.RelPath == "" {
continue
}
parts := strings.Split(e.RelPath, "/")
top := parts[0]
if len(parts) == 1 {
// Depth-1 entry: only directories count as top-level path entries.
if e.IsDir {
depth1Dirs[top] = true
}
continue
}
// Nested under a depth-1 dir → mark it non-empty.
depth1Dirs[top] = true
nonEmpty[top] = true
}
out := []string{"."}
names := make([]string, 0, len(depth1Dirs))
for name := range depth1Dirs {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if nonEmpty[name] {
out = append(out, name+"/**")
} else {
out = append(out, name)
}
}
return out
}

// AllowedFilePatterns returns:
// - one "*.ext" per unique extension seen anywhere
// - one exact name per extensionless file (Makefile, LICENSE, .gitignore …)
//
// Both categories are sorted, deduped. Extensions come first, then names.
func AllowedFilePatterns(t *validator.Tree) []string {
exts := map[string]bool{}
names := map[string]bool{}
for _, e := range t.Entries {
if e.IsDir {
continue
}
// Split on the last dot. A leading dot with no other dots (e.g.
// ".gitignore") is treated as an extensionless exact name — that
// matches the user intuition for dotfiles.
name := e.Name
lastDot := strings.LastIndex(name, ".")
if lastDot <= 0 { // 0 → leading dot (dotfile); -1 → no dot at all
names[name] = true
continue
}
exts["*"+name[lastDot:]] = true
}
out := make([]string, 0, len(exts)+len(names))
extList := mapKeys(exts)
sort.Strings(extList)
out = append(out, extList...)
nameList := mapKeys(names)
sort.Strings(nameList)
out = append(out, nameList...)
return out
}

// RequiredFiles seeds `required` only from certainties present at the
// tree root: go.mod and README.md. Nothing speculative — a wrong entry
// makes the primary "validate passes on the same tree" property fail.
func RequiredFiles(t *validator.Tree) []string {
var out []string
for _, candidate := range []string{"go.mod", "README.md"} {
if t.HasFile(candidate) {
out = append(out, candidate)
}
}
return out
}

func mapKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
83 changes: 83 additions & 0 deletions internal/infer/infer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package infer

import (
"bytes"
"fmt"
"sort"

"github.com/AxeForging/structlint/internal/validator"
)

// Generate produces YAML bytes for a .structlint.yaml derived from the
// actual tree at root. The result is a faithful baseline: running
// `structlint validate` on the same tree with this config must exit 0.
//
// The config file itself is about to be written to disk, so *.yaml is
// force-added to allowed patterns even if no yaml files existed before —
// otherwise the very first `validate` would reject the config we wrote.
func Generate(root string) ([]byte, error) {
tree := validator.Snapshot(root, DefaultIgnore)
if tree == nil {
return nil, fmt.Errorf("failed to snapshot %s", root)
}
allowedFiles := AllowedFilePatterns(tree)
allowedFiles = ensurePattern(allowedFiles, "*.yaml")
return render(
AllowedPaths(tree),
allowedFiles,
RequiredFiles(tree),
DefaultIgnore,
), nil
}

// ensurePattern inserts pat into list at the correct sorted position
// (extension patterns before exact names) if it isn't already present.
func ensurePattern(list []string, pat string) []string {
for _, s := range list {
if s == pat {
return list
}
}
// Extension patterns (start with "*.") sort before exact names in our
// output, so slot pat into the extensions block.
i := sort.SearchStrings(list, pat)
out := make([]string, 0, len(list)+1)
out = append(out, list[:i]...)
out = append(out, pat)
out = append(out, list[i:]...)
return out
}

func render(allowedPaths, allowed, required, ignore []string) []byte {
var b bytes.Buffer
fmt.Fprintln(&b, "# generated by `structlint init --infer` — a baseline of the current tree.")
fmt.Fprintln(&b, "# `validate` passes on this file as-is; tighten incrementally.")
fmt.Fprintln(&b)
fmt.Fprintln(&b, "dir_structure:")
fmt.Fprintln(&b, " allowedPaths:")
writeStringList(&b, allowedPaths, " ")

fmt.Fprintln(&b)
fmt.Fprintln(&b, "file_naming_pattern:")
fmt.Fprintln(&b, " allowed:")
writeStringList(&b, allowed, " ")
if len(required) > 0 {
fmt.Fprintln(&b, " required:")
writeStringList(&b, required, " ")
}

fmt.Fprintln(&b)
fmt.Fprintln(&b, "ignore:")
sortedIgnore := append([]string(nil), ignore...)
sort.Strings(sortedIgnore)
writeStringList(&b, sortedIgnore, " ")
return b.Bytes()
}

// writeStringList emits each entry as `- "value"` at the given indent. Empty
// input writes nothing (callers guard on len > 0 where necessary).
func writeStringList(b *bytes.Buffer, list []string, indent string) {
for _, item := range list {
fmt.Fprintf(b, "%s- %q\n", indent, item)
}
}
Loading
Loading