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
13 changes: 13 additions & 0 deletions .gauntlet.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
custom_gates:
# Structure is a whole-repository invariant; many findings identify paths
# rather than changed source lines.
structlint:
command: ["structlint", "validate", "--format", "github"]
parser: github-annotations
line_scoped: false

# Clone annotations include source lines and can be scoped to the PR diff.
dupehound:
command: ["dupehound", "scan", "--format", "github", "--quiet"]
parser: github-annotations
line_scoped: true
54 changes: 47 additions & 7 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,57 @@ jobs:
INCREMENTAL: false
REVIEW_RULES: concise

validate:
gauntlet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '1.25.x'
cache: true
- name: Verify modules are tidy
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
- name: Install candidate and custom gates
run: |
go install .
go install mvdan.cc/gofumpt@v0.9.2
go install github.com/AxeForging/dupehound@v0.1.0
- uses: AxeForging/gauntlet@v0.1.0
with:
since: origin/${{ github.base_ref }}

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: '1.25.x'
cache: true
- uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
args: --timeout=5m

release-snapshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '1.25.x'
cache: true
# Dogfood the exact source under review instead of downloading the latest
# published release through AxeForging/structlint@main.
- name: Build candidate binary
run: make build
- name: Validate repository structure
run: ./bin/structlint validate --config .structlint.yaml
- uses: goreleaser/goreleaser-action@v7
with:
version: v2.13.3
args: check
- uses: goreleaser/goreleaser-action@v7
with:
version: v2.13.3
args: release --snapshot --clean
93 changes: 0 additions & 93 deletions .github/workflows/test.yml

This file was deleted.

1 change: 1 addition & 0 deletions .structlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ file_naming_pattern:

# Documentation
- "*.md"
- "*.sh"
- "*.txt"
- "*.png"
- "*.jpg"
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A CLI tool for validating and enforcing directory structure and file naming patt
## Installation

```bash
go install github.com/AxeForging/structlint@latest
curl -fsSL https://raw.githubusercontent.com/AxeForging/structlint/main/install.sh | sh
```

<details>
Expand Down Expand Up @@ -46,6 +46,29 @@ make build

</details>

### Quality gates with Gauntlet

Gauntlet orchestrates the repository's normal Go checks plus Structlint and Dupehound.
Structlint remains whole-run because structural violations often identify paths rather
than lines; Dupehound is diff-scoped through its source annotations.

```yaml
custom_gates:
structlint:
command: ["structlint", "validate", "--format", "github"]
parser: github-annotations
line_scoped: false
dupehound:
command: ["dupehound", "scan", "--format", "github", "--quiet"]
parser: github-annotations
line_scoped: true
```

```sh
gauntlet check
gauntlet check --staged --format agent
```

## Quick Start

```bash
Expand Down
52 changes: 52 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/bin/sh
set -eu

REPO="${STRUCTLINT_REPO:-AxeForging/structlint}"
VERSION="${STRUCTLINT_VERSION:-latest}"
if [ -n "${STRUCTLINT_INSTALL_DIR:-}" ]; then
INSTALL_DIR="$STRUCTLINT_INSTALL_DIR"
elif [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then
INSTALL_DIR=/usr/local/bin
else
INSTALL_DIR="${HOME:?HOME is required when /usr/local/bin is not writable}/.local/bin"
fi

die() { printf 'structlint installer: %s\n' "$*" >&2; exit 1; }
command -v curl >/dev/null 2>&1 || die "curl is required"
command -v tar >/dev/null 2>&1 || die "tar is required"

case "$(uname -s)" in Linux) os=linux ;; Darwin) os=darwin ;; *) die "unsupported operating system: $(uname -s)" ;; esac
case "$(uname -m)" in
x86_64 | amd64) arch=amd64 ;;
aarch64 | arm64) arch=arm64 ;;
i386 | i486 | i586 | i686) arch=386 ;;
armv6l | armv7l) arch=arm ;;
*) die "unsupported architecture: $(uname -m)" ;;
esac

if [ "$VERSION" = latest ]; then
VERSION="$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest" | awk -F/ '{print $NF}')"
[ -n "$VERSION" ] || die "could not resolve latest release version"
fi
asset="structlint-${os}-${arch}.tar.gz"
base_url="https://github.com/${REPO}/releases/download/${VERSION}"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM
curl -fsSL "${base_url}/${asset}" -o "${tmp_dir}/${asset}"
curl -fsSL "${base_url}/checksums.txt" -o "${tmp_dir}/checksums.txt"
expected="$(awk -v asset="$asset" '$2 == asset || $2 == "*" asset { print $1; exit }' "${tmp_dir}/checksums.txt")"
[ -n "$expected" ] || die "checksum for ${asset} not found"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "${tmp_dir}/${asset}" | awk '{print $1}')"
elif command -v shasum >/dev/null 2>&1; then
actual="$(shasum -a 256 "${tmp_dir}/${asset}" | awk '{print $1}')"
else
die "sha256sum or shasum is required"
fi
[ "$actual" = "$expected" ] || die "checksum verification failed for ${asset}"
tar -xzf "${tmp_dir}/${asset}" -C "$tmp_dir"
[ -f "${tmp_dir}/structlint" ] || die "release archive does not contain structlint"
mkdir -p "$INSTALL_DIR"
install -m 0755 "${tmp_dir}/structlint" "${INSTALL_DIR}/structlint"
printf 'structlint %s installed to %s/structlint\n' "$VERSION" "$INSTALL_DIR"
case ":${PATH}:" in *":${INSTALL_DIR}:"*) ;; *) printf 'Add %s to PATH to run structlint from any directory.\n' "$INSTALL_DIR" ;; esac
73 changes: 73 additions & 0 deletions test/install_script_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package test

import (
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)

func TestInstallScript_InstallsVerifiedPinnedRelease(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX installer")
}
osName, archName := runtime.GOOS, runtime.GOARCH
if (osName != "linux" && osName != "darwin") || (archName != "amd64" && archName != "arm64" && archName != "386" && archName != "arm") {
t.Skip("unsupported fixture platform")
}
tmp := t.TempDir()
payload := filepath.Join(tmp, "payload")
if err := os.Mkdir(payload, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(payload, "structlint"), []byte("#!/bin/sh\nprintf 'installed structlint\\n'\n"), 0o755); err != nil {
t.Fatal(err)
}
asset := "structlint-" + osName + "-" + archName + ".tar.gz"
archive := filepath.Join(tmp, asset)
if output, err := exec.Command("tar", "-czf", archive, "-C", payload, "structlint").CombinedOutput(); err != nil {
t.Fatalf("archive: %v\n%s", err, output)
}
data, err := os.ReadFile(archive)
if err != nil {
t.Fatal(err)
}
checksums := filepath.Join(tmp, "checksums.txt")
if err := os.WriteFile(checksums, []byte(fmt.Sprintf("%x %s\n", sha256.Sum256(data), asset)), 0o644); err != nil {
t.Fatal(err)
}
fakeBin := filepath.Join(tmp, "bin")
if err := os.Mkdir(fakeBin, 0o755); err != nil {
t.Fatal(err)
}
fakeCurl := `#!/bin/sh
set -eu
while [ "$#" -gt 0 ]; do case "$1" in -o) out="$2"; shift 2 ;; -*) shift ;; *) url="$1"; shift ;; esac; done
printf '%s\n' "$url" >> "$CURL_LOG"
case "$url" in */checksums.txt) cp "$CHECKSUMS" "$out" ;; *) cp "$ARCHIVE" "$out" ;; esac
`
if err := os.WriteFile(filepath.Join(fakeBin, "curl"), []byte(fakeCurl), 0o755); err != nil {
t.Fatal(err)
}
installDir, logPath := filepath.Join(tmp, "install"), filepath.Join(tmp, "curl.log")
cmd := exec.Command("sh", filepath.Join("..", "install.sh"))
cmd.Env = append(os.Environ(), "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"), "STRUCTLINT_VERSION=v0.6.0", "STRUCTLINT_INSTALL_DIR="+installDir, "ARCHIVE="+archive, "CHECKSUMS="+checksums, "CURL_LOG="+logPath)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("install: %v\n%s", err, output)
}
output, err := exec.Command(filepath.Join(installDir, "structlint")).CombinedOutput()
if err != nil || strings.TrimSpace(string(output)) != "installed structlint" {
t.Fatalf("binary: %v %q", err, output)
}
log, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(log), "/releases/download/v0.6.0/"+asset) {
t.Fatalf("wrong URL: %s", log)
}
}
Loading