diff --git a/.gauntlet.yml b/.gauntlet.yml new file mode 100644 index 0000000..849483f --- /dev/null +++ b/.gauntlet.yml @@ -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 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index aab6f8a..54e9e53 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index a2eacad..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Test - -on: - push: - branches: - - main - - master - pull_request: - branches: - - main - - master - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version: '1.25.x' - cache: true - - - name: Download dependencies - run: go mod download - - - name: Verify modules are tidy - run: | - go mod tidy - git diff --exit-code -- go.mod go.sum - - - name: Verify formatting - run: | - UNFORMATTED="$(gofmt -l .)" - if [ -n "$UNFORMATTED" ]; then - echo "$UNFORMATTED" - exit 1 - fi - - - name: Run race tests - run: go test -race ./... -v - - - name: Build binary - run: make build - - - name: Self-validate - run: ./bin/structlint validate --config .structlint.yaml - - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version: '1.25.x' - cache: true - - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.12.2 - args: --timeout=5m - - release-snapshot: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version: '1.25.x' - cache: true - - - name: Validate release configuration - uses: goreleaser/goreleaser-action@v7 - with: - version: v2.13.3 - args: check - - - name: Build release snapshot - uses: goreleaser/goreleaser-action@v7 - with: - version: v2.13.3 - args: release --snapshot --clean diff --git a/.structlint.yaml b/.structlint.yaml index b945459..7d077da 100644 --- a/.structlint.yaml +++ b/.structlint.yaml @@ -46,6 +46,7 @@ file_naming_pattern: # Documentation - "*.md" + - "*.sh" - "*.txt" - "*.png" - "*.jpg" diff --git a/README.md b/README.md index de8cb8f..1fd16ce 100644 --- a/README.md +++ b/README.md @@ -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 ```
@@ -46,6 +46,29 @@ make build
+### 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 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..843b6f5 --- /dev/null +++ b/install.sh @@ -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 diff --git a/test/install_script_test.go b/test/install_script_test.go new file mode 100644 index 0000000..db0fce1 --- /dev/null +++ b/test/install_script_test.go @@ -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) + } +}