diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7ae19c4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,39 @@ +version: 2 + +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: UTC + open-pull-requests-limit: 5 + labels: + - dependencies + - P2 + commit-message: + prefix: chore + include: scope + groups: + test-deps-patch: + patterns: + - "github.com/stretchr/testify" + - "github.com/cucumber/godog" + - "go.uber.org/goleak" + update-types: + - patch + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: UTC + open-pull-requests-limit: 5 + labels: + - ci/cd + - P2 + commit-message: + prefix: ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f4ea92..855ae26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,185 +1,329 @@ -# syncmap CI workflow. -# -# Runs the quality gate (`make check`) plus coverage, govulncheck, and a -# cross-platform build matrix on every pull request and push to main. -# -# This workflow is deliberately read-only: no commit, no tag, no publish. -# Release automation lives in a separate workflow (see issue #4 for -# goreleaser + release.yml). Attempts to add release behaviour here must -# be rejected in review. -# -# Action pin policy: -# - First-party `actions/*` actions use major-version tags (e.g. @v5) because -# GitHub owns and signs them. -# - Third-party actions (if/when added) must be pinned by full commit SHA -# with a `# ` comment so dependabot can update them. There are -# currently no third-party actions in this workflow — golangci-lint and -# govulncheck are installed via `go install` with pinned module versions -# to keep the supply chain minimal. - -name: ci +name: CI on: - pull_request: - branches: [main] - types: [opened, synchronize, reopened] push: branches: [main] - workflow_dispatch: - -permissions: - contents: read + # Skip main-branch CI for commits that only touch the CLA signatures + # file or the auto-generated contributors list. Those pushes come + # from the CLA Assistant workflow and the contributors regenerator + # respectively; they cannot affect library behaviour. Normal PR + # events continue to run the full suite (no paths filter below). + paths-ignore: + - "signatures/**" + - "CONTRIBUTORS.md" + pull_request: + branches: [main] concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -env: - GO_VERSION: '1.26.x' - GOLANGCI_LINT_VERSION: v2.11.4 - GOVULNCHECK_VERSION: v1.2.0 - GOIMPORTS_VERSION: v0.44.0 +permissions: + contents: read jobs: - lint: - name: lint + makefile-targets-guard: + name: Makefile targets documented runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 + - name: Verify make help lists every required target + run: | + set -euo pipefail - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - cache: true + # Every target documented as part of the project contract must + # appear in `make help` output. This guard fails the build if + # a future Makefile edit silently drops a target. The expected + # list is pinned here and mirrors the "Makefile Targets" + # section of CLAUDE.md. If a new target is genuinely added to + # the contract, update this list AND the CLAUDE.md section + # together. + expected=( + check + test + test-bdd + lint + vet + fmt + fmt-check + bench + coverage + tidy + tidy-check + security + release-check + clean + help + ) + # Strip ANSI colour escapes so the grep matches the plain target + # name as it appears after Makefile rendering. + help_output=$(make help | sed $'s/\x1b\\[[0-9;]*m//g') + missing=() + for target in "${expected[@]}"; do + if ! grep -qE "^[[:space:]]*${target}[[:space:]]" <<<"$help_output"; then + missing+=("$target") + fi + done + if [ "${#missing[@]}" -gt 0 ]; then + echo "::error::make help is missing required target(s): ${missing[*]}" + echo "-- full output of 'make help' --" + echo "$help_output" + exit 1 + fi + echo "All ${#expected[@]} required Makefile targets are documented in 'make help'." - - name: Install goimports - run: go install golang.org/x/tools/cmd/goimports@${{ env.GOIMPORTS_VERSION }} + apache-header-guard: + name: Apache 2.0 header on every Go file + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + - name: Verify Apache 2.0 header + run: | + set -euo pipefail + # Every tracked *.go file MUST carry the Apache 2.0 header in its + # first ~14 lines. The tell-tale phrase is stable across every + # file in the repo; grep -L lists files missing it. + missing=$(git ls-files '*.go' \ + | xargs grep -L 'Licensed under the Apache License, Version 2.0' \ + || true) + if [ -n "$missing" ]; then + echo "::error::Go files missing Apache 2.0 header:" + printf '%s\n' "$missing" + exit 1 + fi + echo "All tracked .go files carry the Apache 2.0 header." - - name: Install golangci-lint - run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${{ env.GOLANGCI_LINT_VERSION }} + local-paths-guard: + name: No local paths or replace directives + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + - name: Scan for local-filesystem references and replace directives + run: | + set -euo pipefail - - name: make fmt-check - run: make fmt-check + # A Go library MUST NOT ship with references to the maintainer's + # local filesystem. Downstream consumers fetching the module via + # `go get` can't satisfy /Users/johnny/... paths — the module is + # broken on their machine. This guard catches two failure modes + # before they reach main: + # + # 1. `replace` directives in go.mod — commonly used during local + # development against a checkout of a sibling package, but + # fatal for downstream consumers when committed. We block ALL + # replace directives. If a legitimate need arises (a known + # upstream bug, a fork hosted elsewhere), add an exception + # here with a linked GitHub issue. + # + # 2. Absolute local paths anywhere in tracked files: Linux/macOS + # /home//..., macOS /Users/..., Windows C:\Users\...\. + # Covers source, docs, workflows, and anything else committed. - - name: make vet - run: make vet + failed=0 - - name: make lint - run: make lint + echo "::group::go.mod replace directives" + if grep -nE "^[[:space:]]*replace[[:space:]]" go.mod; then + echo "::error file=go.mod::replace directives are forbidden in a published library — see local-paths-guard." + failed=1 + else + echo "go.mod: no replace directives" + fi + echo "::endgroup::" - - name: make tidy-check - run: make tidy-check + echo "::group::Absolute local paths in tracked files" + # Exclusions: + # - CLAUDE.md and .claude/ are dev-local (gitignored, but paranoid). + # - This workflow file itself describes the forbidden patterns. + pattern='(/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/|[A-Z]:\\\\Users\\\\)' - test: - name: test (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - defaults: - run: - shell: bash - steps: - - name: Checkout - uses: actions/checkout@v4 + hits=$(git ls-files \ + | grep -Ev '^(CLAUDE\.md|\.claude/|\.github/workflows/ci\.yml)$' \ + | xargs -I{} sh -c 'grep -EHn "$1" "$2" 2>/dev/null || true' _ "$pattern" {} \ + || true) + if [ -n "$hits" ]; then + echo "::error::Absolute local paths found in tracked files:" + printf '%s\n' "$hits" + failed=1 + else + echo "no local paths found" + fi + echo "::endgroup::" + + exit "$failed" - - name: Setup Go - uses: actions/setup-go@v5 + attribution-guard: + name: No AI attribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 with: - go-version: ${{ env.GO_VERSION }} - cache: true + fetch-depth: 0 + - name: Scan tracked files for AI-tool references + run: | + set -euo pipefail - - name: make test - run: make test + # Forbidden tokens (case-insensitive). Exceptions: + # - CLAUDE.md as a literal filename reference (the dev-local + # project guide). + # - llms.txt / llms-full.txt (file names for the LLM tooling + # ecosystem — product names, not attribution). + # - The CONTRIBUTING.md rule block that enumerates the + # forbidden tokens as part of the policy itself. + # + # Commit messages are no longer scanned: they are transient and + # controlled by the commit-message-reviewer gate at author time. + # Scanning every historical commit message trips on any PR that + # discusses the guard itself, and this is a masking library, not + # a compliance audit trail. + PATTERN='claude|anthropic|copilot|[^a-z]gpt[^a-z]?|\bllm\b|ai-generated|ai-assisted|generated by ai|co-authored-by: claude' - - name: make test-bdd - run: make test-bdd + echo "::group::Tracked files" + # Exclusions: + # - CLAUDE.md and .claude/ — dev-local Claude Code config (gitignored). + # - CONTRIBUTING.md, SECURITY.md, this workflow, the requirements doc — + # contain the forbidden tokens as part of the policy statement itself. + # - llms.txt / llms-full.txt — the AI-assistant documentation bundle. + # llms-full.txt is a generated concatenation of the above policy- + # bearing files, so it inherits the same exclusion by construction; + # llms.txt names mistakes AI assistants should avoid and cites tool + # names in the "common mistakes" prose. + files=$(git ls-files \ + | grep -Ev '^(CLAUDE\.md|\.claude/|\.github/workflows/ci\.yml|CONTRIBUTING\.md|SECURITY\.md|docs/v1\.0\.0-requirements\.md|\.gitignore|llms\.txt|llms-full\.txt)$' \ + | grep -Ev '\.(png|jpg|jpeg|gif|ico|webp|svg|pdf|woff2?|ttf|otf|mp3|mp4|wav|zip|gz|tgz)$' \ + || true) + hits="" + # grep -I skips any file detected as binary — safety net in case the + # extension blocklist above misses something. + for f in $files; do + if grep -IiEn "$PATTERN" "$f" >/dev/null 2>&1; then + # Strip benign path references: llms.txt / llms-full.txt product + # filenames, the CLAUDE.md filename token, and .claude/ path + # segments in config files. + match=$(grep -IiEn "$PATTERN" "$f" | grep -viE 'llms(-full)?\.txt|CLAUDE\.md|\.claude/') + if [ -n "$match" ]; then + hits="${hits}${f}:\n${match}\n\n" + fi + fi + done + if [ -n "$hits" ]; then + printf '::error::AI-tool references found in tracked files:\n%b' "$hits" + exit 1 + fi + echo "No AI-tool references found." + echo "::endgroup::" - coverage: - name: coverage + lint: + name: Lint runs-on: ubuntu-latest - needs: test steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-go@v6.4.0 with: - go-version: ${{ env.GO_VERSION }} + go-version: "1.26" cache: true - - - name: make coverage - run: make coverage - - - name: Summarise coverage - run: | - { - echo '### Coverage' - echo '' - echo '```' - go tool cover -func=coverage.out | tail -1 - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - - - name: Upload coverage artefact - uses: actions/upload-artifact@v4 + - name: Install goimports + run: go install golang.org/x/tools/cmd/goimports@v0.28.0 + - name: Format check + run: make fmt-check + - name: Vet + run: make vet + - name: golangci-lint + uses: golangci/golangci-lint-action@v9.2.0 with: - name: coverage - path: | - coverage.out - coverage.html - if-no-files-found: error - retention-days: 14 + version: v2.11.4 + install-mode: goinstall + - name: Module tidy check + run: make tidy-check - security: - name: security - runs-on: ubuntu-latest - needs: test + test: + name: Test + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-go@v6.4.0 with: - go-version: ${{ env.GO_VERSION }} + go-version: "1.26" cache: true + - name: Unit tests + run: make test + - name: BDD tests + run: make test-bdd + - name: Coverage + if: matrix.os == 'ubuntu-latest' + run: make coverage + - name: Enforce coverage threshold + if: matrix.os == 'ubuntu-latest' + run: | + total=$(go tool cover -func=coverage.out | awk '/^total:/ {print $3}' | tr -d '%') + # TODO(#11): raise threshold to 95.0 once the test rewrite lands. + # Today the fork carries only the existing smoke test (~36%). + # The gate exists to prevent regressions below current coverage. + threshold=35.0 + echo "Total coverage: ${total}% (threshold ${threshold}%)" + awk -v t="$total" -v th="$threshold" 'BEGIN { if (t+0 < th+0) { exit 1 } }' \ + || { echo "::error::coverage ${total}% < threshold ${threshold}%"; exit 1; } + - name: Upload coverage + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v7.0.1 + with: + name: coverage + path: coverage.out - - name: Install govulncheck - run: go install golang.org/x/vuln/cmd/govulncheck@${{ env.GOVULNCHECK_VERSION }} - - - name: make security - run: make security - - build-matrix: - name: build (${{ matrix.goos }}/${{ matrix.goarch }}) + build: + name: Build ${{ matrix.goos }}/${{ matrix.goarch }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - - { goos: linux, goarch: amd64 } - - { goos: linux, goarch: arm64 } - - { goos: darwin, goarch: amd64 } - - { goos: darwin, goarch: arm64 } - - { goos: windows, goarch: amd64 } + - goos: linux + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-go@v6.4.0 with: - go-version: ${{ env.GO_VERSION }} + go-version: "1.26" cache: true - - - name: Cross-compile + - name: Build env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} - CGO_ENABLED: '0' + CGO_ENABLED: "0" run: go build ./... + + security: + name: Security scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-go@v6.4.0 + with: + go-version: "1.26" + cache: true + - name: govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + govulncheck ./... + + release-check: + name: GoReleaser config check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-go@v6.4.0 + with: + go-version: "1.26" + cache: true + - uses: goreleaser/goreleaser-action@v7.0.0 + with: + distribution: goreleaser + version: v2.7.0 + args: check diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..71d94c1 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,30 @@ +name: Dependabot auto-merge + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + name: Enable auto-merge for patch-level test-dependency updates + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - name: Inspect Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@v3.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge (squash) for test-dep patches only + if: >- + steps.meta.outputs.update-type == 'version-update:semver-patch' && + (steps.meta.outputs.dependency-group == 'test-deps-patch' || + steps.meta.outputs.package-ecosystem == 'github-actions') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..37523f9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,143 @@ +name: Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Tag to release (e.g. v1.0.0). Must not exist yet; workflow creates it." + required: true + type: string + dry_run: + description: "Dry run — build and validate without publishing or tagging." + required: false + default: false + type: boolean + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + name: Verify quality gate + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Disallow non-dry release from a non-main ref + if: github.ref != 'refs/heads/main' && inputs.dry_run != true + run: | + echo "::error::Non-dry releases must dispatch from main (got ${{ github.ref }}). Merge to main first, then re-dispatch." + exit 1 + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6.4.0 + with: + go-version: "1.26" + cache: true + - name: Install goimports + run: go install golang.org/x/tools/cmd/goimports@v0.28.0 + - name: Install golangci-lint + # Install directly so 'make check' can invoke it — the action + # tries to 'run' the linter itself which would run lint twice. + run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + - name: Run quality gate + run: make check + - name: Enforce 95% coverage threshold + run: | + set -euo pipefail + total=$(go tool cover -func=coverage.out | awk '/^total:/ {print $3}' | tr -d '%') + # v1.0.0 will not be cut until #11 raises the test suite past + # the 95% target — no TODO loosening needed here, the release + # gate stays strict and the release workflow is dispatched + # manually after coverage lands. + awk -v v="$total" 'BEGIN { exit !(v+0 >= 95) }' || { + echo "::error::Coverage ${total}% is below the 95% release threshold." + exit 1 + } + echo "Coverage ${total}% meets the 95% release threshold." + + tag: + name: Create tag + needs: verify + if: inputs.dry_run != true + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + - name: Validate tag format + run: | + if [[ ! "${{ inputs.tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then + echo "Tag must match vMAJOR.MINOR.PATCH[-prerelease]" + exit 1 + fi + - name: Create and push tag + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${{ inputs.tag }}" -m "Release ${{ inputs.tag }}" + git push origin "${{ inputs.tag }}" + + goreleaser: + name: GoReleaser + needs: [verify, tag] + if: always() && needs.verify.result == 'success' && (needs.tag.result == 'success' || needs.tag.result == 'skipped') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + # On a real release the tag has just been pushed by the `tag` + # job, so we check it out. On a dry run no tag exists yet; + # fall back to the workflow's invocation ref so the build runs + # against the commit a human operator just validated. + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + ref: ${{ inputs.dry_run && github.ref || inputs.tag }} + - uses: actions/setup-go@v6.4.0 + with: + go-version: "1.26" + cache: true + - name: Dry-run GoReleaser + if: inputs.dry_run == true + uses: goreleaser/goreleaser-action@v7.0.0 + with: + distribution: goreleaser + version: v2.7.0 + args: release --snapshot --clean + - name: Publish GoReleaser + if: inputs.dry_run != true + uses: goreleaser/goreleaser-action@v7.0.0 + with: + distribution: goreleaser + version: v2.7.0 + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + proxy-warm: + name: Warm Go module proxy + needs: goreleaser + if: inputs.dry_run != true + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Request module from proxy (with retry) + run: | + tag="${{ inputs.tag }}" + for attempt in 1 2 3 4 5 6; do + if curl -sSfL "https://proxy.golang.org/github.com/axonops/syncmap/@v/${tag}.info" | tee /dev/stderr; then + exit 0 + fi + echo "proxy not ready, retry ${attempt}/6 in 15s" + sleep 15 + done + echo "proxy did not index ${tag} after 90s" + exit 1 diff --git a/.gitignore b/.gitignore index 4355cb6..bc090ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,43 +1,51 @@ -# Local-only project files -CLAUDE.md -claude/ -.issue-drafts/ - -# Go build artefacts +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib + +# Test binary, built with `go test -c` *.test -# Coverage and profile output +# Code coverage profiles and other test artifacts *.out coverage.* *.coverprofile profile.cov -# Go workspace +# GoReleaser snapshot output +dist/ + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file go.work go.work.sum -# Environment +# env file .env -# Editor / IDE (commented out — uncomment locally if needed) +# Editor/IDE # .idea/ # .vscode/ -# Build output -/dist/ -/bin/ - -# OS: macOS +# General .DS_Store .localized -._* +__MACOSX/ .AppleDouble .LSOverride +Icon[] + +# Resource forks +._* + +# Files and directories that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 @@ -46,9 +54,44 @@ go.work.sum .VolumeIcon.icns .com.apple.timemachine.donotpresent .com.apple.timemachine.supported +.PKInstallSandboxManager +.PKInstallSandboxManager-SystemSoftware +.hotfiles.btree +.vol +.file +.disk_label* +lost+found +.HFS+ Private Directory Data[] + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items .apdisk -# OS: miscellaneous -__MACOSX/ -Thumbs.db -Desktop.ini +# Mac OS 6 to 9 +Desktop DB +Desktop DF +TheFindByContentFolder +TheVolumeSettingsFolder +.FBCIndex +.FBCSemaphoreFile +.FBCLockFolder + +# Quota system +.quota.group +.quota.user +.quota.ops.group +.quota.ops.user + +# TimeMachine +Backups.backupdb +.MobileBackups +.MobileBackups.trash +MobileBackups.trash +tmbootpicker.efi + +CLAUDE.md +.claude/ +.issue-drafts/ diff --git a/.golangci.yml b/.golangci.yml index 6df86f8..8f45174 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,93 +2,58 @@ version: "2" run: timeout: 5m - go: "1.26" + tests: true linters: - default: standard + default: none enable: - - asasalint - - bodyclose - - copyloopvar - - errorlint - - forcetypeassert - - gocritic - - gosec + - errcheck + - govet + - ineffassign + - staticcheck + - unused - misspell - - nilnil - - nolintlint - - prealloc + - gocyclo + - gocritic - revive - unconvert - disable: - - exhaustive - - exhaustruct + - unparam + - bodyclose + - goconst + - prealloc + - nolintlint + - copyloopvar settings: - errcheck: - check-type-assertions: false - check-blank: false - gocritic: - disabled-checks: - - ifElseChain - - singleCaseSwitch - govet: - enable-all: true - disable: - - fieldalignment - nolintlint: - require-explanation: true - require-specific: true - allow-unused: false + gocyclo: + min-complexity: 10 + misspell: + locale: UK revive: rules: - - name: blank-imports - - name: context-as-argument - - name: context-keys-type - - name: dot-imports - - name: error-return - - name: error-strings - - name: error-naming - name: exported - - name: increment-decrement + arguments: + - disableStutteringCheck - name: var-naming - - name: var-declaration - name: package-comments - - name: range - - name: receiver-naming - - name: time-naming - - name: unexported-return - - name: indent-error-flow + - name: if-return + - name: error-return + - name: error-naming + - name: error-strings - name: errorf - - name: empty-block - - name: superfluous-else - name: unused-parameter disabled: true - - name: unreachable-code - - name: redefines-builtin-id - staticcheck: - checks: - - all - gosec: - excludes: - - G404 exclusions: - presets: - - comments - - std-error-handling rules: - path: _test\.go linters: - - forcetypeassert - - gosec - - errcheck - - path: syncmap_smoke_test\.go - linters: + - gocyclo - gocritic - # TODO(#7): remove once syncmap.go type assertions are rewritten to - # comma-ok form as part of the godoc + implementation rewrite. - - path: ^syncmap\.go$ + - unparam + - goconst + - path: tests/bdd/ linters: - - forcetypeassert + - gocyclo + - gocritic formatters: enable: diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..6c37c98 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,67 @@ +version: 2 + +project_name: syncmap + +builds: + - skip: true + +archives: + - id: source + formats: + - tar.gz + files: + - LICENSE + - README.md + +checksum: + name_template: "checksums.txt" + +snapshot: + version_template: "{{ .Tag }}-SNAPSHOT-{{ .ShortCommit }}" + +changelog: + use: github + sort: asc + groups: + - title: Features + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: Bug fixes + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: Documentation + regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$' + order: 2 + - title: Performance + regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' + order: 3 + - title: Refactors + regexp: '^.*?refactor(\([[:word:]]+\))??!?:.+$' + order: 4 + - title: Other + order: 999 + filters: + exclude: + - '^test:' + - '^chore:' + - '^ci:' + - Merge pull request + - Merge branch + +release: + github: + owner: axonops + name: syncmap + prerelease: auto + draft: false + mode: replace + header: | + ## syncmap {{ .Tag }} + + Type-safe generic wrapper around Go's `sync.Map`. Zero runtime dependencies. + + See [CHANGELOG.md](./CHANGELOG.md) for full release notes. + footer: | + --- + + **Full diff:** [{{ .PreviousTag }}...{{ .Tag }}](https://github.com/axonops/syncmap/compare/{{ .PreviousTag }}...{{ .Tag }}) diff --git a/Makefile b/Makefile index d1c385b..91f1d1f 100644 --- a/Makefile +++ b/Makefile @@ -1,140 +1,93 @@ -SHELL := /bin/bash - -GO ?= go -GOFMT ?= gofmt -GOIMPORTS ?= goimports -GOLANGCI_LINT ?= golangci-lint -GOVULNCHECK ?= govulncheck -GORELEASER ?= goreleaser - -PKGS := ./... -COVERAGE_OUT ?= coverage.out -COVERAGE_HTML ?= coverage.html - -GOIMPORTS_INSTALL := $(GO) install golang.org/x/tools/cmd/goimports@latest -GOLANGCI_INSTALL := https://golangci-lint.run/usage/install/ -GOVULNCHECK_INSTALL := $(GO) install golang.org/x/vuln/cmd/govulncheck@latest -GORELEASER_INSTALL := $(GO) install github.com/goreleaser/goreleaser/v2@latest - .DEFAULT_GOAL := help +SHELL := bash +.SHELLFLAGS := -eu -o pipefail -c + +GO ?= go +GOBIN ?= $(shell $(GO) env GOPATH)/bin +GOLANGCI ?= $(GOBIN)/golangci-lint +GOIMPORTS ?= $(GOBIN)/goimports +GOVULNCHECK ?= $(GOBIN)/govulncheck +GORELEASER ?= goreleaser + +GO_FILES := $(shell find . -type f -name '*.go' -not -path './.git/*' 2>/dev/null) +PKG := ./... +BDD_PKG := ./tests/bdd/... +COVER_OUT := coverage.out +COVER_HTML := coverage.html .PHONY: help -help: ## Print available targets - @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ - | sort \ - | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage: make \033[36m\033[0m\n\nTargets:\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-18s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) .PHONY: check -check: fmt-check vet lint test tidy-check security ## Run the full quality gate +check: fmt-check vet lint tidy-check test test-bdd coverage security ## Run the full quality gate (mirrors CI) .PHONY: test -test: ## Run unit tests with -race - $(GO) test -race -count=1 $(PKGS) +test: ## Run unit tests with race detector + $(GO) test -race -count=1 $(PKG) .PHONY: test-bdd -test-bdd: ## Run BDD tests (no-op until tests/bdd/ exists) +test-bdd: ## Run BDD tests @if [ -d tests/bdd ]; then \ - $(GO) test -race -count=1 -tags bdd ./tests/bdd/...; \ + $(GO) test -race -count=1 -tags bdd $(BDD_PKG); \ else \ - echo "tests/bdd/ not present yet; skipping BDD run"; \ + echo "tests/bdd not present yet — skipping BDD run"; \ fi .PHONY: lint lint: ## Run golangci-lint - @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ - echo "golangci-lint not found on PATH."; \ - echo "Install: see $(GOLANGCI_INSTALL)"; \ - exit 1; \ - } - $(GOLANGCI_LINT) run $(PKGS) + $(GOLANGCI) run $(PKG) .PHONY: vet vet: ## Run go vet - $(GO) vet $(PKGS) + $(GO) vet $(PKG) .PHONY: fmt -fmt: ## Auto-fix gofmt and goimports - $(GOFMT) -s -w . - @command -v $(GOIMPORTS) >/dev/null 2>&1 || { \ - echo "goimports not found on PATH."; \ - echo "Install: $(GOIMPORTS_INSTALL)"; \ - exit 1; \ - } - $(GOIMPORTS) -w -local github.com/axonops/syncmap . +fmt: ## Auto-format Go source files + $(GO) fmt $(PKG) + @if command -v $(GOIMPORTS) >/dev/null 2>&1; then $(GOIMPORTS) -w $(GO_FILES); fi .PHONY: fmt-check -fmt-check: ## Fail on any gofmt/goimports diff - @out=$$( $(GOFMT) -s -l . ); \ - if [ -n "$$out" ]; then \ - echo "gofmt diff:"; echo "$$out"; exit 1; \ - fi - @if command -v $(GOIMPORTS) >/dev/null 2>&1; then \ - out=$$( $(GOIMPORTS) -l -local github.com/axonops/syncmap . ); \ - if [ -n "$$out" ]; then \ - echo "goimports diff:"; echo "$$out"; exit 1; \ - fi; \ - else \ - echo "goimports not found on PATH (skipping); install with: $(GOIMPORTS_INSTALL)"; \ - fi +fmt-check: ## Fail if any Go file is unformatted + @out=$$(gofmt -s -l .); if [ -n "$$out" ]; then echo "gofmt diff:"; echo "$$out"; exit 1; fi + @if command -v $(GOIMPORTS) >/dev/null 2>&1; then out=$$($(GOIMPORTS) -l .); if [ -n "$$out" ]; then echo "goimports diff:"; echo "$$out"; exit 1; fi; fi .PHONY: bench -bench: ## Run benchmarks with allocation reports - $(GO) test -race -bench=. -benchmem -run='^$$' $(PKGS) +bench: ## Run benchmarks + $(GO) test -bench=. -benchmem -run=^$$ $(PKG) .PHONY: coverage -coverage: ## Generate and summarise a coverage report - $(GO) test -race -covermode=atomic -coverprofile=$(COVERAGE_OUT) $(PKGS) - $(GO) tool cover -func=$(COVERAGE_OUT) | tail -1 - $(GO) tool cover -html=$(COVERAGE_OUT) -o $(COVERAGE_HTML) - @echo "HTML report: $(COVERAGE_HTML)" +coverage: ## Generate coverage profile and HTML report for the library + $(GO) test -race -coverprofile=$(COVER_OUT) -covermode=atomic . + $(GO) tool cover -func=$(COVER_OUT) | tail -1 + $(GO) tool cover -html=$(COVER_OUT) -o $(COVER_HTML) .PHONY: tidy tidy: ## Run go mod tidy $(GO) mod tidy .PHONY: tidy-check -tidy-check: ## Fail if go mod tidy would change go.mod or go.sum - @tmp=$$(mktemp -d); \ - cp go.mod $$tmp/go.mod; \ - if [ -f go.sum ]; then cp go.sum $$tmp/go.sum; fi; \ - $(GO) mod tidy; \ - status=0; \ - if ! cmp -s go.mod $$tmp/go.mod; then \ - echo "go mod tidy changed go.mod:"; diff -u $$tmp/go.mod go.mod || true; status=1; \ - fi; \ - if [ -f $$tmp/go.sum ] || [ -f go.sum ]; then \ - if ! cmp -s $$tmp/go.sum go.sum 2>/dev/null; then \ - echo "go mod tidy changed go.sum"; status=1; \ - fi; \ - fi; \ - cp $$tmp/go.mod go.mod; \ - if [ -f $$tmp/go.sum ]; then cp $$tmp/go.sum go.sum; fi; \ - rm -rf $$tmp; \ - exit $$status +tidy-check: ## Fail if go mod tidy would modify go.mod or go.sum + @cp go.mod go.mod.bak + @[ -f go.sum ] && cp go.sum go.sum.bak || true + @$(GO) mod tidy + @if ! diff -q go.mod go.mod.bak >/dev/null; then mv go.mod.bak go.mod; [ -f go.sum.bak ] && mv go.sum.bak go.sum || true; echo "go.mod drift — run 'make tidy'"; exit 1; fi + @if [ -f go.sum ] && [ -f go.sum.bak ] && ! diff -q go.sum go.sum.bak >/dev/null; then mv go.sum.bak go.sum; mv go.mod.bak go.mod; echo "go.sum drift — run 'make tidy'"; exit 1; fi + @rm -f go.mod.bak go.sum.bak .PHONY: security -security: ## Run govulncheck (skips cleanly if tool is absent) +security: ## Run govulncheck @if command -v $(GOVULNCHECK) >/dev/null 2>&1; then \ - $(GOVULNCHECK) $(PKGS); \ + $(GOVULNCHECK) $(PKG); \ else \ - echo "govulncheck not found on PATH (skipping)."; \ - echo "Install: $(GOVULNCHECK_INSTALL)"; \ + echo "govulncheck not installed — skipping (install: go install golang.org/x/vuln/cmd/govulncheck@latest)"; \ fi .PHONY: release-check -release-check: ## Validate GoReleaser config (skips cleanly if tool or config absent) - @if [ ! -f .goreleaser.yml ] && [ ! -f .goreleaser.yaml ]; then \ - echo ".goreleaser config not present yet (lands in #4); skipping"; \ - exit 0; \ - fi; \ - if command -v $(GORELEASER) >/dev/null 2>&1; then \ - $(GORELEASER) check; \ - else \ - echo "goreleaser not found on PATH (skipping)."; \ - echo "Install: $(GORELEASER_INSTALL)"; \ - fi +release-check: ## Validate GoReleaser config without releasing + $(GORELEASER) check .PHONY: clean -clean: ## Remove coverage artefacts and clear the Go test cache +clean: ## Remove generated test and coverage artefacts $(GO) clean -testcache - rm -f $(COVERAGE_OUT) $(COVERAGE_HTML) + rm -f $(COVER_OUT) $(COVER_HTML) diff --git a/syncmap.go b/syncmap.go index 0580eb2..fb2c695 100644 --- a/syncmap.go +++ b/syncmap.go @@ -1,3 +1,26 @@ +// Copyright 2026 AxonOps Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package syncmap is a type-safe generic wrapper around sync.Map. +// +// It mirrors the sync.Map API with compile-time type safety via Go +// generics, so callers no longer pay the cost of type assertions at +// every call site. Zero runtime dependencies. +// +// A full godoc package overview lands in issue #6 (doc.go); this +// inline comment exists only to satisfy the revive.package-comments +// lint rule until then. package syncmap import "sync" diff --git a/syncmap_smoke_test.go b/syncmap_smoke_test.go index dea082e..c4b9a12 100644 --- a/syncmap_smoke_test.go +++ b/syncmap_smoke_test.go @@ -1,3 +1,17 @@ +// Copyright 2026 AxonOps Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package syncmap import (