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
3 changes: 3 additions & 0 deletions .structlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,6 @@ ignore:
- "*.tmp"
- "*.temp"
- "structlint"

# Test fixtures — deliberately violate rules to exercise the validator
- "test/testdata"
8 changes: 1 addition & 7 deletions internal/cli/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,7 @@ func NewValidateCmd() *cli.Command {
if staged || cmd.Bool("changed-only") {
v.LoadChangedPathsMode(path, staged)
}
v.ValidateDirStructure(path)
v.ValidateFileNaming(path)
v.ValidateRequiredPaths(path)
v.ValidateRequiredFiles(path)
v.ValidatePlacement(path)
v.ValidateRequiredGroups(path)
v.ValidateBoundaries(path)
v.Run(path)

if baseline := cmd.String("baseline"); baseline != "" {
if err := v.ApplyBaseline(baseline); err != nil {
Expand Down
115 changes: 115 additions & 0 deletions internal/validator/engine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package validator

import "github.com/AxeForging/structlint/internal/config"

// Rule is the pluggable unit of validation. Each family (dir structure,
// file naming, placement, ...) is a Rule. Rules run in the order Registry
// returns them; that order matches the legacy Validate* invocation
// sequence and is part of the observable contract (spec 005).
type Rule interface {
Name() string
Run(ctx *RunContext)
}

// RunContext bundles the state a rule needs.
type RunContext struct {
Cfg *config.Config
Tree *Tree
V *Validator
// Skip returns true when the given relative path (dir or file) is out
// of scope for --changed-only / --staged. Rules that are meant to be
// global (required-* families) do not consult Skip.
Skip func(relPath string, isDir bool) bool
}

// Registry returns the seven rules in legacy execution order:
// dir_structure, file_naming, required_paths, required_files,
// placement, required_groups, boundaries. Spec 010's suggest command
// enumerates rules through this registry.
func Registry(cfg *config.Config) []Rule {
return []Rule{
dirStructureRule{},
fileNamingRule{},
requiredPathsRule{},
requiredFilesRule{},
placementRule{},
requiredGroupsRule{},
boundariesRule{},
}
}

// Run snapshots the tree once, then executes each rule in Registry order
// against the same snapshot. Wire from validate.go instead of calling the
// seven Validate* methods individually.
func (v *Validator) Run(path string) {
tree := Snapshot(path, v.Config.Ignore)
ctx := &RunContext{
Cfg: v.Config,
Tree: tree,
V: v,
Skip: func(rel string, isDir bool) bool {
if isDir {
return v.shouldSkipChangedDir(rel)
}
return v.shouldSkipChanged(rel)
},
}
for _, rule := range Registry(v.Config) {
rule.Run(ctx)
}
}

// The seven rule types below are thin wrappers around the existing
// Validate* method bodies. They call the legacy method, which walks the
// tree itself. Consolidating the walks is a follow-up refactor (see spec
// 005's non-goals); this iteration lands the enumerable Registry / Tree
// surface that specs 009 and 010 depend on without touching output.

type dirStructureRule struct{}

func (dirStructureRule) Name() string { return "dir_structure" }
func (dirStructureRule) Run(ctx *RunContext) {
ctx.V.ValidateDirStructure(ctx.Tree.Root)
}

type fileNamingRule struct{}

func (fileNamingRule) Name() string { return "file_naming" }
func (fileNamingRule) Run(ctx *RunContext) {
ctx.V.ValidateFileNaming(ctx.Tree.Root)
}

type requiredPathsRule struct{}

func (requiredPathsRule) Name() string { return "required_paths" }
func (requiredPathsRule) Run(ctx *RunContext) {
ctx.V.ValidateRequiredPaths(ctx.Tree.Root)
}

type requiredFilesRule struct{}

func (requiredFilesRule) Name() string { return "required_files" }
func (requiredFilesRule) Run(ctx *RunContext) {
ctx.V.ValidateRequiredFiles(ctx.Tree.Root)
}

type placementRule struct{}

func (placementRule) Name() string { return "placement" }
func (placementRule) Run(ctx *RunContext) {
ctx.V.ValidatePlacement(ctx.Tree.Root)
}

type requiredGroupsRule struct{}

func (requiredGroupsRule) Name() string { return "required_groups" }
func (requiredGroupsRule) Run(ctx *RunContext) {
ctx.V.ValidateRequiredGroups(ctx.Tree.Root)
}

type boundariesRule struct{}

func (boundariesRule) Name() string { return "boundaries" }
func (boundariesRule) Run(ctx *RunContext) {
ctx.V.ValidateBoundaries(ctx.Tree.Root)
}
91 changes: 91 additions & 0 deletions internal/validator/walk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package validator

import (
"os"
"path/filepath"
)

// Entry describes a single filesystem entry visited by Snapshot.
type Entry struct {
RelPath string // slash-normalized; "." for the root
Name string
IsDir bool
Abs string
}

// Tree is an ignore-filtered snapshot of a project root, ready for
// rule enumeration without another filesystem walk.
type Tree struct {
Root string // absolute root path
Entries []Entry // filepath.Walk lexical order, ignore-filtered
dirs map[string]struct{}
files map[string]struct{}
WalkErr error // first walk error, if any
}

// HasDir reports whether the tree contains a directory at relPath.
func (t *Tree) HasDir(relPath string) bool {
if t == nil {
return false
}
_, ok := t.dirs[relPath]
return ok
}

// HasFile reports whether the tree contains a file at relPath.
func (t *Tree) HasFile(relPath string) bool {
if t == nil {
return false
}
_, ok := t.files[relPath]
return ok
}

// Snapshot walks root once and returns an ignore-filtered Tree. Ignore
// matching uses the same pathMatches helper the individual rule walks use,
// so the snapshot's visible set matches the union of what those walks would
// visit. Rules that intentionally look into ignored directories (see the
// requiredPathsRule os.Stat quirk documented in spec 005) do NOT consult
// the tree for those lookups.
func Snapshot(root string, ignore []string) *Tree {
cleaned := cleanRoot(root)
tree := &Tree{
Root: cleaned,
dirs: map[string]struct{}{},
files: map[string]struct{}{},
}
err := filepath.Walk(root, func(currentPath string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
if tree.WalkErr == nil {
tree.WalkErr = walkErr
}
return walkErr
}
rel := relativePath(cleaned, currentPath)
for _, pat := range ignore {
if pathMatches(rel, pat) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
}
entry := Entry{
RelPath: rel,
Name: info.Name(),
IsDir: info.IsDir(),
Abs: currentPath,
}
tree.Entries = append(tree.Entries, entry)
if info.IsDir() {
tree.dirs[rel] = struct{}{}
} else {
tree.files[rel] = struct{}{}
}
return nil
})
if err != nil && tree.WalkErr == nil {
tree.WalkErr = err
}
return tree
}
102 changes: 102 additions & 0 deletions test/engine_parity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package test

import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)

// parityFixtures returns the fixture names under test/testdata/parity/,
// excluding non-fixture entries.
func parityFixtures(t *testing.T) []string {
t.Helper()
dir := filepath.Join(repoRoot(t), "test", "testdata", "parity")
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read parity dir: %v", err)
}
var out []string
for _, e := range entries {
if !e.IsDir() || e.Name() == "goldens" {
continue
}
if _, err := os.Stat(filepath.Join(dir, e.Name(), ".structlint.yaml")); err != nil {
continue
}
out = append(out, e.Name())
}
return out
}

// runFixture invokes the binary against a fixture and returns stdout+stderr
// combined and the exit code. Uses combined output because the pre-refactor
// binary intermixes summary text and error lines; goldens capture that mix.
func runFixture(t *testing.T, bin, name string, extra ...string) (string, int) {
t.Helper()
root := repoRoot(t)
fixture := filepath.Join(root, "test", "testdata", "parity", name)
config := filepath.Join(fixture, ".structlint.yaml")
args := []string{"validate", "--path", fixture, "--config", config}
args = append(args, extra...)
out, err := runBinary(t, bin, args...)
code := 0
if err != nil {
code = 1 // urfave/cli returns 1 on Action error; goldens confirm this
}
return out, code
}

func readGolden(t *testing.T, kind, name string) string {
t.Helper()
path := filepath.Join(repoRoot(t), "test", "testdata", "parity", "goldens", name+"."+kind)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read golden %s: %v", path, err)
}
return string(data)
}

func TestEngineParity_TextOutput(t *testing.T) {
bin := buildBinary(t)
for _, name := range parityFixtures(t) {
t.Run(name, func(t *testing.T) {
got, _ := runFixture(t, bin, name)
want := readGolden(t, "text", name)
if got != want {
t.Errorf("text output drift for fixture %q\n--- want ---\n%s\n--- got ---\n%s", name, want, got)
}
})
}
}

func TestEngineParity_JSONOutput(t *testing.T) {
bin := buildBinary(t)
for _, name := range parityFixtures(t) {
t.Run(name, func(t *testing.T) {
got, _ := runFixture(t, bin, name, "--format", "json")
want := readGolden(t, "json", name)
if got != want {
t.Errorf("json output drift for fixture %q\n--- want ---\n%s\n--- got ---\n%s", name, want, got)
}
})
}
}

func TestEngineParity_ExitCodes(t *testing.T) {
bin := buildBinary(t)
for _, name := range parityFixtures(t) {
t.Run(name, func(t *testing.T) {
_, code := runFixture(t, bin, name)
want := strings.TrimSpace(readGolden(t, "exit", name))
wantCode, err := strconv.Atoi(want)
if err != nil {
t.Fatalf("bad exit golden: %v", err)
}
if code != wantCode {
t.Errorf("exit code drift for %q: got %d, want %d", name, code, wantCode)
}
})
}
}
1 change: 1 addition & 0 deletions test/project_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ ignore:
- "dist"
- "*.log"
- "*.tmp"
- "testdata"
`

// Create temporary config file
Expand Down
5 changes: 4 additions & 1 deletion test/self_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,11 @@ func TestProjectStandardsCompliance(t *testing.T) {
return err
}

// Skip directories
// Skip directories, but prune whole subtrees we know are ignored.
if info.IsDir() {
if info.Name() == "testdata" || info.Name() == "node_modules" || info.Name() == "vendor" {
return filepath.SkipDir
}
return nil
}

Expand Down
10 changes: 10 additions & 0 deletions test/testdata/parity/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Parity fixtures for the rule-engine refactor

Each subdirectory here is a self-contained project fixture used by
`test/engine_parity_test.go`. Golden files (`.golden.txt`, `.golden.json`,
`.golden.exit`) were captured from the pre-refactor binary; the refactor
in spec 005 must produce byte-identical output on every fixture.

To regenerate goldens (only when the fixture itself changes or a
deliberate output change is being merged), set `UPDATE_PARITY=1` before
running `go test`.
27 changes: 27 additions & 0 deletions test/testdata/parity/capture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Regenerate golden files. Run from repo root:
# go build -o /tmp/structlint-golden ./cmd/structlint
# bash test/testdata/parity/capture.sh /tmp/structlint-golden
set -euo pipefail
BIN="${1:-./bin/structlint}"
ROOT="$(cd "$(dirname "$0")" && pwd)"
GOLDENS="$ROOT/goldens"
mkdir -p "$GOLDENS"
for fixture in "$ROOT"/*/; do
fixture="${fixture%/}"
name="$(basename "$fixture")"
[[ "$name" == "goldens" || "$name" == "capture.sh" || "$name" == "README.md" ]] && continue
[[ ! -f "$fixture/.structlint.yaml" ]] && continue

set +e
"$BIN" validate --path "$fixture" --config "$fixture/.structlint.yaml" > "$GOLDENS/${name}.text" 2>&1
code=$?
set -e
echo "$code" > "$GOLDENS/${name}.exit"

set +e
"$BIN" validate --path "$fixture" --config "$fixture/.structlint.yaml" --format json > "$GOLDENS/${name}.json" 2>&1
set -e

echo "captured $name (exit=$code)"
done
1 change: 1 addition & 0 deletions test/testdata/parity/goldens/mixed-violations.exit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1
Loading
Loading