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
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: '3.10'
# The polymath-go hook runs over test_files/go, which needs the Go toolchain.
- uses: actions/setup-go@v5
with:
go-version: stable
- uses: pre-commit/action@v3.0.1

pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v5
with:
go-version: stable
- uses: astral-sh/setup-uv@v7
- run: uv sync
- run: uv run pytest
Expand Down
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ repos:
- id: polymath-python
- id: polymath-cpp
- id: polymath-ros
- id: polymath-go
- id: polymath-shell
- id: polymath-cmake
- id: polymath-docker
Expand Down
9 changes: 9 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@
entry: polymath_code_standard ros
types_or: [c, c++]

- <<: *python-hook
id: polymath-go
name: Polymath Code Standard [go]
description: >
Go checks: golangci-lint fmt, golangci-lint run, and go mod tidy.
Requires Go 1.23 or newer on PATH.
entry: polymath_code_standard go
types_or: [go, go-mod, go-sum]

- <<: *python-hook
id: polymath-shell
name: Polymath Code Standard [shell]
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ repos:
- id: polymath-python
- id: polymath-cpp
- id: polymath-ros
- id: polymath-go
- id: polymath-shell
- id: polymath-cmake
- id: polymath-docker
Expand Down Expand Up @@ -180,6 +181,30 @@ No arguments.

---

### `polymath-go`

Runs `golangci-lint` on Go files using Polymath's bundled configuration, and `go mod tidy -diff` on staged `go.mod` and `go.sum` files.

- Formatting with `gofumpt` and `goimports`.
Files that need it are rewritten in place and the hook fails so you re-stage them.
- Linting with `errcheck`, `govet`, `ineffassign`, `staticcheck`, `unused`, `errorlint`, `misspell`, `revive`, and `unconvert`.
- Module tidiness: staged module files must match what `go mod tidy` would produce.

Go files are grouped by their nearest ancestor `go.mod`, and each group is checked from that module root.
A `.go` file with no `go.mod` above it fails the hook.
Files under `vendor/` are skipped.

> [!NOTE]
> Requires Go 1.23 or newer on `PATH`.
> Install it from [go.dev/dl](https://go.dev/dl).

On its first run the hook downloads a pinned `golangci-lint` release, verified against a checksum pinned in this repo, into its own pre-commit virtualenv.
Nothing is written to your repository, and later runs reuse the download.

No arguments.

---

### `polymath-shell`

Runs `shellcheck` on shell scripts.
Expand Down
7 changes: 5 additions & 2 deletions polymath_code_standard/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,21 @@ def tool(name: str) -> str:
return str(Path(sys.executable).parent / name)


def run(name: str, cmd: list[str], files: list[str] | None = None, env: dict | None = None) -> Result:
def run(
name: str, cmd: list[str], files: list[str] | None = None, env: dict | None = None, cwd: str | None = None
) -> Result:
"""Run a check as a subprocess.

files=[] → skipped (no applicable files for this type)
files=None → run with no extra arguments
env → merged on top of os.environ when provided
cwd → working directory for the subprocess
"""
if files is not None and not files:
return Result(name=name, passed=True, skipped=True)
full_cmd = cmd + (files or [])
merged_env = {**os.environ, **env} if env else None
proc = subprocess.run(full_cmd, capture_output=True, text=True, env=merged_env)
proc = subprocess.run(full_cmd, capture_output=True, text=True, env=merged_env, cwd=cwd)
output = (proc.stdout + proc.stderr).strip()
return Result(name=name, passed=proc.returncode == 0, output=output, cmd=full_cmd)

Expand Down
113 changes: 113 additions & 0 deletions polymath_code_standard/checkers/go/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc.
# SPDX-License-Identifier: Apache-2.0
import argparse
import importlib.resources
import shutil
from collections import defaultdict
from pathlib import Path

from polymath_code_standard.checker import CheckerGroup, Result, check_group, filter_files, run

from ._golangci import ensure_golangci_lint

# Config files bundled alongside this checker
CONFIG_DIR = importlib.resources.files(__package__)

CONFIG = Path(str(CONFIG_DIR / 'golangci.yml'))

GO_MISSING = 'Go toolchain not found on PATH. Install Go from https://go.dev/dl and re-run.'


def module_root(path: str) -> Path | None:
"""Return the nearest ancestor directory of path holding a go.mod."""
for directory in Path(path).resolve().parents:
if (directory / 'go.mod').is_file():
return directory
return None


def group_by_module(go_files: list[str]) -> tuple[dict[Path, list[Path]], list[str]]:
"""Split Go sources into per-module-root paths relative to that root, plus the files outside any module.

Vendored sources are dropped.
"""
modules: dict[Path, list[Path]] = defaultdict(list)
orphans = []
for filepath in go_files:
root = module_root(filepath)
if root is None:
orphans.append(filepath)
continue
relative = Path(filepath).resolve().relative_to(root)
if 'vendor' not in relative.parts:
modules[root].append(relative)
return dict(modules), orphans


def package_dirs(relative_files: list[Path]) -> list[str]:
"""Return the distinct package directories of relative_files as golangci-lint package patterns."""
parents = {f.parent for f in relative_files}
return sorted('.' if p == Path('.') else f'./{p.as_posix()}' for p in parents)


def format_module(binary: Path, config: Path, root: Path, relative_files: list[Path]) -> Result:
"""Check gofumpt and goimports formatting under root, then rewrite the files that need it."""
args = [str(binary), 'fmt', '--config', str(config)]
paths = [f.as_posix() for f in relative_files]
# `fmt --diff` exits 1 only when it prints a diff.
# An unparseable file is a warning with exit 0 and is left for `run` to report.
check = run('golangci-lint fmt', args + ['--diff'], paths, cwd=str(root))
if check.passed:
return check
run('golangci-lint fmt', args, paths, cwd=str(root))
return Result(
name='golangci-lint fmt',
passed=False,
output=check.output + '\n(files have been reformatted — please re-stage and recommit)',
cmd=check.cmd,
)


def lint_module(binary: Path, config: Path, root: Path, relative_files: list[Path]) -> Result:
"""Lint the packages under root that contain relative_files."""
return run(
'golangci-lint run',
[str(binary), 'run', '--config', str(config)],
package_dirs(relative_files),
cwd=str(root),
)


def tidy_module(root: Path) -> Result:
"""Report the go.mod and go.sum edits `go mod tidy` would make in root."""
return run('go mod tidy', ['go', 'mod', 'tidy', '-diff'], None, cwd=str(root))


@check_group
class GoGroup(CheckerGroup):
name = 'go'

def run(self, args: argparse.Namespace) -> list[Result]:
if shutil.which('go') is None:
return [Result(name='go', passed=False, output=GO_MISSING)]

modules, orphans = group_by_module(filter_files(args.files, frozenset({'go'})))
module_files = filter_files(args.files, frozenset({'go-mod', 'go-sum'}))

results = [
Result(name='go', passed=False, output=f'{path}: no go.mod in any parent directory.') for path in orphans
]

if modules:
binary = ensure_golangci_lint()
if isinstance(binary, Result):
return results + [binary]
for root, relative_files in sorted(modules.items()):
results.append(format_module(binary, CONFIG, root, relative_files))
results.append(lint_module(binary, CONFIG, root, relative_files))

results.extend(tidy_module(d) for d in sorted({Path(f).resolve().parent for f in module_files}))

if not results:
return [Result(name='go', passed=True, skipped=True)]
return results
137 changes: 137 additions & 0 deletions polymath_code_standard/checkers/go/_golangci.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc.
# SPDX-License-Identifier: Apache-2.0
"""Install a pinned golangci-lint release into the hook's virtualenv.

`ensure_golangci_lint()` returns the path to the binary, or a failed `Result`
describing what went wrong.
"""

import fcntl
import hashlib
import platform
import sys
import tarfile
import urllib.error
import urllib.request
from pathlib import Path

from polymath_code_standard.checker import Result

VERSION = '2.13.2'

# sha256 of each release tarball, from golangci-lint-<VERSION>-checksums.txt.
# Bump these together with VERSION.
CHECKSUMS = {
('darwin', 'amd64'): '8a13aaf9cbbb1dee52824e862cf0d0720e5bb97c1f4260d1e51623a09492b57b',
('darwin', 'arm64'): 'f4bf83f0b64f055c42b28fc9a38861839f69c096e61c788e72dfaae412011789',
('linux', 'amd64'): '2277d43b98ec0054280f2ac26b53268bae97682444678a59a657dd565da021d6',
('linux', 'arm64'): 'a2a4e0065aa41be71f7c5ac90f271b61751331e5d04314e62afe4027855f0893',
}

RELEASE_URL = 'https://github.com/golangci/golangci-lint/releases/download/v{version}/{asset}.tar.gz'

_SYSTEMS = {'Linux': 'linux', 'Darwin': 'darwin'}
_MACHINES = {'x86_64': 'amd64', 'amd64': 'amd64', 'aarch64': 'arm64', 'arm64': 'arm64'}

INSTALL_ROOT = Path(sys.prefix) / 'polymath-go'

NAME = 'golangci-lint'


def target_platform() -> tuple[str, str] | None:
"""Return the (os, arch) pair naming this machine's release asset."""
system = _SYSTEMS.get(platform.system())
machine = _MACHINES.get(platform.machine().lower())
return (system, machine) if system and machine else None


def asset_name(version: str, os_name: str, arch: str) -> str:
return f'golangci-lint-{version}-{os_name}-{arch}'


def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open('rb') as handle:
for chunk in iter(lambda: handle.read(1 << 20), b''):
digest.update(chunk)
return digest.hexdigest()


def extract_binary(tarball: Path, dest: Path) -> Result | None:
"""Extract the golangci-lint executable from a release tarball to dest.

Returns a failed Result when the tarball holds no such member.
"""
with tarfile.open(tarball, 'r:gz') as archive:
member = next((m for m in archive.getmembers() if m.isfile() and Path(m.name).name == NAME), None)
if member is None:
return Result(name=NAME, passed=False, output=f'{tarball.name} contains no {NAME} executable.')
source = archive.extractfile(member)
dest.parent.mkdir(parents=True, exist_ok=True)
with dest.open('wb') as handle:
handle.write(source.read())
dest.chmod(0o755)
return None


def _download_and_verify(url: str, expected_sha256: str, dest_dir: Path) -> Path | Result:
tarball = dest_dir / 'download.tar.gz'
try:
with urllib.request.urlopen(url, timeout=60) as response, tarball.open('wb') as handle:
handle.write(response.read())
except (urllib.error.URLError, OSError) as exc:
return Result(name=NAME, passed=False, output=f'Failed to download {url}: {exc}')

actual = _sha256(tarball)
if actual != expected_sha256:
tarball.unlink(missing_ok=True)
return Result(
name=NAME,
passed=False,
output=f'sha256 mismatch for {url}\n expected {expected_sha256}\n got {actual}',
)
return tarball


def ensure_golangci_lint(version: str = VERSION, install_root: Path = INSTALL_ROOT) -> Path | Result:
"""Return the path to the pinned golangci-lint binary, downloading it on first use."""
target = target_platform()
if target is None or target not in CHECKSUMS:
return Result(
name=NAME,
passed=False,
output=(
f'No golangci-lint release for {platform.system()} {platform.machine()}. '
f'Supported: {", ".join(f"{o}/{a}" for o, a in sorted(CHECKSUMS))}.'
),
)
os_name, arch = target

asset = asset_name(version, os_name, arch)
install_dir = install_root / f'golangci-lint-{version}'
binary = install_dir / NAME
stamp = install_dir / 'asset'
if binary.is_file() and stamp.is_file() and stamp.read_text().strip() == asset:
return binary

# pre-commit runs one hook in parallel batches, so the install is serialized across processes.
install_root.mkdir(parents=True, exist_ok=True)
lock_path = install_root.with_suffix('.lock')
with lock_path.open('w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
if binary.is_file() and stamp.is_file() and stamp.read_text().strip() == asset:
return binary

install_dir.mkdir(parents=True, exist_ok=True)
tarball = _download_and_verify(RELEASE_URL.format(version=version, asset=asset), CHECKSUMS[target], install_dir)
if isinstance(tarball, Result):
return tarball
try:
failure = extract_binary(tarball, binary)
finally:
tarball.unlink(missing_ok=True)
if failure is not None:
return failure

stamp.write_text(f'{asset}\n')
return binary
16 changes: 16 additions & 0 deletions polymath_code_standard/checkers/go/golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
version: '2'
run:
# Report paths relative to the module root the hook runs from.
relative-path-mode: wd
formatters:
enable:
- gofumpt
- goimports
linters:
default: standard
enable:
- errorlint
- misspell
- revive
- unconvert
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies = [
"polymath_code_standard.checkers.ansible" = ["ansible-lint.yml"]
"polymath_code_standard.checkers.copyright" = ["*.txt"]
"polymath_code_standard.checkers.cpp" = [".cpplint.cfg", "clang-format"]
"polymath_code_standard.checkers.go" = ["golangci.yml"]
"polymath_code_standard.checkers.python" = ["ruff.toml"]
"polymath_code_standard.checkers.xml" = ["package_format3.xsd"]

Expand All @@ -43,7 +44,7 @@ dev = [

[tool.pytest.ini_options]
markers = [
# Reaches Ansible Galaxy to install real requirements. Deselect with -m 'not network'.
# Downloads real tooling, from Ansible Galaxy or a GitHub release. Deselect with -m 'not network'.
"network: test requires network access",
]

Expand Down
3 changes: 3 additions & 0 deletions test_files/go/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/polymathrobotics/polymath_code_standard/test_files/go

go 1.23
Loading
Loading