From 77e51efa3872c09877ddeb2deabadb26ebc4dae2 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 10 Jul 2026 09:27:48 +0200 Subject: [PATCH 1/2] feat(init): add --infer for tree-derived baseline configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `structlint init --infer` walks the actual tree (using the Snapshot API from spec 005) and generates a .structlint.yaml that describes what really exists. The primary property: `init --infer && validate` exits 0 on the same tree, so adopting structlint on a legacy codebase no longer means drowning in violations on day one. Spec: docs/specs/009-init-infer.md. Heuristics (deliberately shallow — a baseline, not a taxonomy): - allowedPaths: "." + depth-1 directories (name/** if it has children, bare name if empty). Root files never become path entries — they're covered by file_naming_pattern. - file_naming_pattern.allowed: one *.ext per unique extension seen + exact-name entries for extensionless files (Makefile, LICENSE, dot- files like .gitignore). *.yaml is force-included so the just-written config file itself passes on the next validate (chicken-and-egg fix). - required: only certainties (go.mod, README.md when present at root). A wrong required entry would break the primary property. - disallowed / disallowedPaths: empty; inferring prohibitions from absence is guesswork. - ignore: DefaultIgnore (.git, node_modules, vendor, dist, build, bin) applied during the walk and emitted verbatim. --infer is mutually exclusive with --type; --force guard unchanged. Output is rendered from a deterministic template (sorted sections) so the same tree always yields byte-identical bytes. Tests (test/init_infer_test.go, binary-based): - validate passes on the same tree (go/node/mixed fixtures) - depth-1 dirs: children → name/**, leaf → bare name - extensions + exact names both appear in allowed - required only seeded from certainties (both / neither) - --infer + --type errors - --force guard preserved - determinism: two runs, byte-identical output --- docs/user/cli-reference.md | 16 +++ docs/user/getting-started.md | 8 ++ internal/cli/init.go | 27 ++++- internal/infer/heuristics.go | 118 ++++++++++++++++++++++ internal/infer/infer.go | 83 ++++++++++++++++ test/init_infer_test.go | 187 +++++++++++++++++++++++++++++++++++ 6 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 internal/infer/heuristics.go create mode 100644 internal/infer/infer.go create mode 100644 test/init_infer_test.go diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index a226f09..16dd421 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -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. diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index 0ab233f..b824a68 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -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: diff --git a/internal/cli/init.go b/internal/cli/init.go index cfa49dc..7721888 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -2,10 +2,12 @@ package cli import ( "context" + "errors" "fmt" "os" "path/filepath" + "github.com/AxeForging/structlint/internal/infer" "github.com/urfave/cli/v3" ) @@ -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(".") diff --git a/internal/infer/heuristics.go b/internal/infer/heuristics.go new file mode 100644 index 0000000..88a5c9c --- /dev/null +++ b/internal/infer/heuristics.go @@ -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 +} diff --git a/internal/infer/infer.go b/internal/infer/infer.go new file mode 100644 index 0000000..ad874a8 --- /dev/null +++ b/internal/infer/infer.go @@ -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) + } +} diff --git a/test/init_infer_test.go b/test/init_infer_test.go new file mode 100644 index 0000000..42abc92 --- /dev/null +++ b/test/init_infer_test.go @@ -0,0 +1,187 @@ +package test + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// buildInferProject writes the given files into a tmp dir and returns the dir. +func buildInferProject(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for path, content := range files { + writeTestFile(t, dir, path, content) + } + return dir +} + +func TestInitInfer_ValidatePassesOnSameTree(t *testing.T) { + bin := buildBinary(t) + trees := map[string]map[string]string{ + "go-like": { + "go.mod": "module t\n", + "README.md": "# t\n", + "cmd/app/main.go": "package main\n", + "internal/foo/x.go": "package foo\n", + }, + "node-like": { + "package.json": `{"name":"t"}`, + "README.md": "# t\n", + "src/index.ts": "export {}\n", + "src/util/a.ts": "export {}\n", + "tests/index.test.ts": "test('x', () => {})\n", + }, + "mixed": { + "README.md": "# t\n", + "Makefile": "all:\n\t@echo\n", + "scripts/run.sh": "#!/bin/sh\n", + "data/samples.csv": "a,b\n", + "docs/intro.md": "# intro\n", + }, + } + for name, files := range trees { + t.Run(name, func(t *testing.T) { + dir := buildInferProject(t, files) + out, err := runBinaryInDir(t, bin, dir, "init", "--infer") + if err != nil { + t.Fatalf("init --infer failed: err=%v out:\n%s", err, out) + } + out, err = runBinaryInDir(t, bin, dir, "validate", "--silent") + if err != nil { + t.Fatalf("validate should pass on the inferred tree, err=%v out:\n%s", err, out) + } + }) + } +} + +func TestInitInfer_Depth1DirsBecomeGlobs(t *testing.T) { + bin := buildBinary(t) + dir := buildInferProject(t, map[string]string{ + "README.md": "# t\n", + "internal/x/x.go": "package x\n", + }) + // Create an empty leaf dir (mkdir only). + if err := writeEmptyDir(t, dir, "empty-leaf"); err != nil { + t.Fatal(err) + } + if out, err := runBinaryInDir(t, bin, dir, "init", "--infer"); err != nil { + t.Fatalf("init failed: %v\n%s", err, out) + } + got := readFile(t, filepath.Join(dir, ".structlint.yaml")) + if !strings.Contains(got, `"internal/**"`) { + t.Errorf("expected internal/** in generated config:\n%s", got) + } + if !strings.Contains(got, `"empty-leaf"`) { + t.Errorf("expected bare empty-leaf in generated config:\n%s", got) + } + if strings.Contains(got, `"empty-leaf/**"`) { + t.Errorf("empty-leaf should NOT have /**:\n%s", got) + } +} + +func TestInitInfer_ExtensionsAndExactNames(t *testing.T) { + bin := buildBinary(t) + dir := buildInferProject(t, map[string]string{ + "README.md": "# t\n", + "main.go": "package main\n", + "Makefile": "all:\n\t@echo\n", + ".gitignore": "bin/\n", + }) + if out, err := runBinaryInDir(t, bin, dir, "init", "--infer"); err != nil { + t.Fatalf("init: %v\n%s", err, out) + } + got := readFile(t, filepath.Join(dir, ".structlint.yaml")) + for _, want := range []string{`"*.go"`, `"*.md"`, `"Makefile"`, `".gitignore"`} { + if !strings.Contains(got, want) { + t.Errorf("missing %s in inferred config:\n%s", want, got) + } + } +} + +func TestInitInfer_RequiredOnlyFromCertainties(t *testing.T) { + bin := buildBinary(t) + t.Run("both present", func(t *testing.T) { + dir := buildInferProject(t, map[string]string{ + "README.md": "# t\n", + "go.mod": "module t\n", + }) + if out, err := runBinaryInDir(t, bin, dir, "init", "--infer"); err != nil { + t.Fatalf("init: %v\n%s", err, out) + } + got := readFile(t, filepath.Join(dir, ".structlint.yaml")) + if !strings.Contains(got, `"go.mod"`) || !strings.Contains(got, `"README.md"`) { + t.Errorf("expected go.mod and README.md required, got:\n%s", got) + } + }) + t.Run("neither present", func(t *testing.T) { + dir := buildInferProject(t, map[string]string{ + "main.go": "package main\n", + }) + if out, err := runBinaryInDir(t, bin, dir, "init", "--infer"); err != nil { + t.Fatalf("init: %v\n%s", err, out) + } + got := readFile(t, filepath.Join(dir, ".structlint.yaml")) + if strings.Contains(got, "required:") { + t.Errorf("did not expect a required: section with no certainties, got:\n%s", got) + } + }) +} + +func TestInitInfer_MutuallyExclusiveWithType(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + out, err := runBinaryInDir(t, bin, dir, "init", "--infer", "--type", "go") + if err == nil { + t.Fatalf("expected error when both --infer and --type used, out:\n%s", out) + } + if !strings.Contains(out, "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' in error, got:\n%s", out) + } +} + +func TestInitInfer_RespectsForceGuard(t *testing.T) { + bin := buildBinary(t) + dir := buildInferProject(t, map[string]string{ + ".structlint.yaml": "dir_structure:\n allowedPaths: [\".\"]\n", + "README.md": "# t\n", + }) + out, err := runBinaryInDir(t, bin, dir, "init", "--infer") + if err == nil { + t.Fatalf("expected error when config exists and --force not passed, out:\n%s", out) + } + if !strings.Contains(out, "already exists") { + t.Errorf("expected 'already exists' in error, got:\n%s", out) + } + out, err = runBinaryInDir(t, bin, dir, "init", "--infer", "--force") + if err != nil { + t.Fatalf("--force should overwrite: %v\n%s", err, out) + } +} + +func TestInitInfer_Deterministic(t *testing.T) { + bin := buildBinary(t) + dir := buildInferProject(t, map[string]string{ + "README.md": "# t\n", + "cmd/app/main.go": "package main\n", + "internal/x.go": "package internal\n", + "docs/intro.md": "# intro\n", + }) + if out, err := runBinaryInDir(t, bin, dir, "init", "--infer"); err != nil { + t.Fatalf("init: %v\n%s", err, out) + } + first := readFile(t, filepath.Join(dir, ".structlint.yaml")) + if out, err := runBinaryInDir(t, bin, dir, "init", "--infer", "--force"); err != nil { + t.Fatalf("init2: %v\n%s", err, out) + } + second := readFile(t, filepath.Join(dir, ".structlint.yaml")) + if first != second { + t.Errorf("expected byte-identical output across runs.\nfirst:\n%s\nsecond:\n%s", first, second) + } +} + +func writeEmptyDir(t *testing.T, root, name string) error { + t.Helper() + return os.MkdirAll(filepath.Join(root, name), 0o755) +} From 4032811d0970fb7ff797589e2c7fe862425db807 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 10 Jul 2026 09:29:19 +0200 Subject: [PATCH 2/2] style(test): apply gofumpt to init_infer_test.go alignment --- test/init_infer_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/init_infer_test.go b/test/init_infer_test.go index 42abc92..64db3b6 100644 --- a/test/init_infer_test.go +++ b/test/init_infer_test.go @@ -27,10 +27,10 @@ func TestInitInfer_ValidatePassesOnSameTree(t *testing.T) { "internal/foo/x.go": "package foo\n", }, "node-like": { - "package.json": `{"name":"t"}`, - "README.md": "# t\n", - "src/index.ts": "export {}\n", - "src/util/a.ts": "export {}\n", + "package.json": `{"name":"t"}`, + "README.md": "# t\n", + "src/index.ts": "export {}\n", + "src/util/a.ts": "export {}\n", "tests/index.test.ts": "test('x', () => {})\n", }, "mixed": { @@ -59,8 +59,8 @@ func TestInitInfer_ValidatePassesOnSameTree(t *testing.T) { func TestInitInfer_Depth1DirsBecomeGlobs(t *testing.T) { bin := buildBinary(t) dir := buildInferProject(t, map[string]string{ - "README.md": "# t\n", - "internal/x/x.go": "package x\n", + "README.md": "# t\n", + "internal/x/x.go": "package x\n", }) // Create an empty leaf dir (mkdir only). if err := writeEmptyDir(t, dir, "empty-leaf"); err != nil {