From 675382e6898361fc4b741d00dd828b356b17101d Mon Sep 17 00:00:00 2001 From: Emerson Knapp Date: Thu, 10 Sep 2026 10:21:06 -0700 Subject: [PATCH] feat: add go checker group One hook covers Go formatting, linting, and module tidiness: `golangci-lint fmt`, `golangci-lint run`, and `go mod tidy -diff`. golangci-lint has no PyPI wrapper and upstream discourages `go install`, so the hook downloads the pinned 2.13.2 release tarball on first use into `sys.prefix/polymath-go`. That directory belongs to the pre-commit-managed virtualenv, so the download is scoped to one hook revision, shared across every consuming repo on the machine, and never lands in a consuming repo's tree. The tarball is verified against a sha256 pinned in source for each of the four supported platforms, taken from the release's own checksums file, so trusting the download costs no second network fetch. An exclusive flock serializes the install, since pre-commit runs one hook over several batches of files in parallel. Downloading a binary at first use has precedent here: hadolint-py and shellcheck-py do the same at install time. The Go toolchain itself stays a prerequisite. golangci-lint type-checks by compiling, so bundling a binary would not remove the requirement, and any repo with a go.mod already has developers with Go installed. When `go` is absent the group returns a single failed result pointing at go.dev/dl, ahead of the pile of downstream noise a missing compiler produces. The linter set is golangci-lint's `standard` default (errcheck, govet, ineffassign, staticcheck, unused) plus errorlint, misspell, revive, and unconvert, with gofumpt and goimports as formatters. Polymath has no internal Go precedent to inherit, so this follows community practice. Adding linters later is a minor version bump. golangci-lint works on packages and must run from a module root, so staged files are grouped by their nearest ancestor go.mod and collapsed to package directories, each group run with cwd at its module root. `run()` gains an optional `cwd` for this. The config sets `run.relative-path-mode: wd`, since golangci-lint's default renders reported paths relative to the config file, and this config ships inside the hook's virtualenv. Vendored sources are dropped from the grouping. `run` on a vendored package dir exits 7 with "not a package listed in vendor/modules.txt", and `fmt` would rewrite third-party code the consumer cannot fix. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Emerson Knapp --- .github/workflows/test.yml | 7 + .pre-commit-config.yaml | 1 + .pre-commit-hooks.yaml | 9 + README.md | 25 ++ polymath_code_standard/checker.py | 7 +- .../checkers/go/__init__.py | 113 ++++++ .../checkers/go/_golangci.py | 137 ++++++++ .../checkers/go/golangci.yml | 16 + pyproject.toml | 3 +- test_files/go/go.mod | 3 + test_files/go/main.go | 11 + tests/test_go.py | 321 ++++++++++++++++++ tests/test_runner.py | 11 + 13 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 polymath_code_standard/checkers/go/__init__.py create mode 100644 polymath_code_standard/checkers/go/_golangci.py create mode 100644 polymath_code_standard/checkers/go/golangci.yml create mode 100644 test_files/go/go.mod create mode 100644 test_files/go/main.go create mode 100644 tests/test_go.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b008588..0900897 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7316fa1..38cc9fb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index e969eab..6c21f48 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -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] diff --git a/README.md b/README.md index 3fc2256..235909e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/polymath_code_standard/checker.py b/polymath_code_standard/checker.py index af039e3..fd60dd1 100644 --- a/polymath_code_standard/checker.py +++ b/polymath_code_standard/checker.py @@ -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) diff --git a/polymath_code_standard/checkers/go/__init__.py b/polymath_code_standard/checkers/go/__init__.py new file mode 100644 index 0000000..4f6f5db --- /dev/null +++ b/polymath_code_standard/checkers/go/__init__.py @@ -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 diff --git a/polymath_code_standard/checkers/go/_golangci.py b/polymath_code_standard/checkers/go/_golangci.py new file mode 100644 index 0000000..d40db1a --- /dev/null +++ b/polymath_code_standard/checkers/go/_golangci.py @@ -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--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 diff --git a/polymath_code_standard/checkers/go/golangci.yml b/polymath_code_standard/checkers/go/golangci.yml new file mode 100644 index 0000000..92d2c5b --- /dev/null +++ b/polymath_code_standard/checkers/go/golangci.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 838d305..956ee31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] @@ -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", ] diff --git a/test_files/go/go.mod b/test_files/go/go.mod new file mode 100644 index 0000000..09646f0 --- /dev/null +++ b/test_files/go/go.mod @@ -0,0 +1,3 @@ +module github.com/polymathrobotics/polymath_code_standard/test_files/go + +go 1.23 diff --git a/test_files/go/main.go b/test_files/go/main.go new file mode 100644 index 0000000..d5e8193 --- /dev/null +++ b/test_files/go/main.go @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package main is an example Go module to validate our linter settings. +package main + +import "fmt" + +func main() { + fmt.Println("polymath") +} diff --git a/tests/test_go.py b/tests/test_go.py new file mode 100644 index 0000000..50f09bf --- /dev/null +++ b/tests/test_go.py @@ -0,0 +1,321 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the go checker group. + +golangci-lint works on packages and must run from a module root. +Most of this group is the bookkeeping that gets there: +find each file's module, make its paths relative, and collapse them to package directories. +""" + +import argparse +import hashlib +import io +import shutil +import tarfile +from pathlib import Path + +import pytest + +from polymath_code_standard import runner +from polymath_code_standard.checker import Result +from polymath_code_standard.checkers import go as go_checker +from polymath_code_standard.checkers.go import _golangci + +_PROJECT_ROOT = Path(__file__).parent.parent + +_HAS_GO = shutil.which('go') is not None +needs_go = pytest.mark.skipif(not _HAS_GO, reason='Go toolchain not on PATH') + +CLEAN_MAIN = '// Package main is a fixture.\npackage main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("x")\n}\n' + + +def _module(root: Path, name: str) -> Path: + root.mkdir(parents=True, exist_ok=True) + (root / 'go.mod').write_text(f'module example.com/{name}\n\ngo 1.23\n') + return root + + +def _go_args(*files: Path) -> argparse.Namespace: + return argparse.Namespace(files=[str(f) for f in files]) + + +# --- module grouping --- + + +def test_module_root_finds_nearest_go_mod(tmp_path): + outer = _module(tmp_path / 'outer', 'outer') + inner = _module(outer / 'inner', 'inner') + nested = inner / 'pkg' / 'deep.go' + nested.parent.mkdir(parents=True) + nested.write_text(CLEAN_MAIN) + assert go_checker.module_root(str(nested)) == inner + + +def test_group_by_module_splits_modules_and_nested_packages(tmp_path): + first = _module(tmp_path / 'first', 'first') + second = _module(tmp_path / 'second', 'second') + files = [first / 'main.go', first / 'pkg' / 'a.go', second / 'main.go'] + for f in files: + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(CLEAN_MAIN) + + modules, orphans = go_checker.group_by_module([str(f) for f in files]) + assert orphans == [] + assert modules == { + first: [Path('main.go'), Path('pkg/a.go')], + second: [Path('main.go')], + } + + +def test_group_by_module_drops_vendored_sources(tmp_path): + root = _module(tmp_path, 'app') + vendored = root / 'vendor' / 'example.com' / 'dep' / 'dep.go' + vendored.parent.mkdir(parents=True) + vendored.write_text(CLEAN_MAIN) + (root / 'main.go').write_text(CLEAN_MAIN) + + modules, orphans = go_checker.group_by_module([str(root / 'main.go'), str(vendored)]) + assert orphans == [] + assert modules == {root: [Path('main.go')]} + + +def test_group_by_module_reports_files_outside_any_module(tmp_path): + stray = tmp_path / 'stray.go' + stray.write_text(CLEAN_MAIN) + modules, orphans = go_checker.group_by_module([str(stray)]) + assert modules == {} + assert orphans == [str(stray)] + + +def test_package_dirs_collapses_to_distinct_directories(): + relative = [Path('main.go'), Path('doc.go'), Path('pkg/a.go'), Path('pkg/b.go'), Path('pkg/sub/c.go')] + assert go_checker.package_dirs(relative) == ['.', './pkg', './pkg/sub'] + + +def test_file_without_go_mod_fails(tmp_path, monkeypatch): + monkeypatch.setattr(go_checker.shutil, 'which', lambda _: '/usr/bin/go') + stray = tmp_path / 'stray.go' + stray.write_text(CLEAN_MAIN) + results = go_checker.GoGroup().run(_go_args(stray)) + assert [r.passed for r in results] == [False] + assert 'no go.mod' in results[0].output + + +def test_no_go_files_skips(tmp_path, monkeypatch): + monkeypatch.setattr(go_checker.shutil, 'which', lambda _: '/usr/bin/go') + unrelated = tmp_path / 'notes.txt' + unrelated.write_text('hello\n') + results = go_checker.GoGroup().run(_go_args(unrelated)) + assert [(r.passed, r.skipped) for r in results] == [(True, True)] + + +def test_missing_go_toolchain_fails_without_running_anything(tmp_path, monkeypatch): + monkeypatch.setattr(go_checker.shutil, 'which', lambda _: None) + monkeypatch.setattr( + go_checker, 'ensure_golangci_lint', lambda *a, **k: pytest.fail('golangci-lint must not be installed') + ) + source = _module(tmp_path, 'x') / 'main.go' + source.write_text(CLEAN_MAIN) + + results = go_checker.GoGroup().run(_go_args(source)) + assert [(r.name, r.passed) for r in results] == [('go', False)] + assert results[0].output == go_checker.GO_MISSING + + +# --- golangci-lint install --- + + +def test_target_platform_maps_machine_names(monkeypatch): + for machine, arch in [('x86_64', 'amd64'), ('amd64', 'amd64'), ('aarch64', 'arm64'), ('arm64', 'arm64')]: + monkeypatch.setattr(_golangci.platform, 'machine', lambda m=machine: m) + monkeypatch.setattr(_golangci.platform, 'system', lambda: 'Linux') + assert _golangci.target_platform() == ('linux', arch) + + monkeypatch.setattr(_golangci.platform, 'system', lambda: 'Darwin') + monkeypatch.setattr(_golangci.platform, 'machine', lambda: 'arm64') + assert _golangci.target_platform() == ('darwin', 'arm64') + + +def test_target_platform_unknown_is_none(monkeypatch): + monkeypatch.setattr(_golangci.platform, 'system', lambda: 'Windows') + monkeypatch.setattr(_golangci.platform, 'machine', lambda: 'AMD64') + assert _golangci.target_platform() is None + + +def test_every_supported_platform_has_a_checksum(): + assert set(_golangci.CHECKSUMS) == { + (os_name, arch) for os_name in ('linux', 'darwin') for arch in ('amd64', 'arm64') + } + + +def test_unsupported_platform_returns_failed_result(tmp_path, monkeypatch): + monkeypatch.setattr(_golangci.platform, 'system', lambda: 'Windows') + monkeypatch.setattr(_golangci.platform, 'machine', lambda: 'AMD64') + result = _golangci.ensure_golangci_lint(install_root=tmp_path / 'install') + assert isinstance(result, Result) + assert not result.passed + assert 'Windows' in result.output + + +def _fake_tarball(path: Path, member_name: str = 'golangci-lint-2.13.2-linux-amd64/golangci-lint') -> Path: + payload = b'#!/bin/sh\necho fake\n' + with tarfile.open(path, 'w:gz') as archive: + info = tarfile.TarInfo(member_name) + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + return path + + +def _serve(monkeypatch, tarball: Path) -> None: + """Answer any urlopen with the bytes of tarball.""" + + class _Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + monkeypatch.setattr(_golangci.urllib.request, 'urlopen', lambda *a, **k: _Response(tarball.read_bytes())) + + +def test_install_extracts_only_the_binary(tmp_path, monkeypatch): + tarball = _fake_tarball(tmp_path / 'src.tar.gz') + monkeypatch.setitem(_golangci.CHECKSUMS, ('linux', 'amd64'), hashlib.sha256(tarball.read_bytes()).hexdigest()) + monkeypatch.setattr(_golangci, 'target_platform', lambda: ('linux', 'amd64')) + _serve(monkeypatch, tarball) + + install_root = tmp_path / 'install' + binary = _golangci.ensure_golangci_lint(install_root=install_root) + assert isinstance(binary, Path) + assert binary.read_bytes() == b'#!/bin/sh\necho fake\n' + assert binary.stat().st_mode & 0o111 + install_dir = install_root / f'golangci-lint-{_golangci.VERSION}' + assert sorted(p.name for p in install_dir.iterdir()) == ['asset', 'golangci-lint'] + + # The stamp makes a second call a no-op, so the download is never repeated. + monkeypatch.setattr( + _golangci.urllib.request, 'urlopen', lambda *a, **k: pytest.fail('install must not re-download') + ) + assert _golangci.ensure_golangci_lint(install_root=install_root) == binary + + +def test_install_rejects_a_checksum_mismatch(tmp_path, monkeypatch): + tarball = _fake_tarball(tmp_path / 'src.tar.gz') + monkeypatch.setitem(_golangci.CHECKSUMS, ('linux', 'amd64'), '00' * 32) + monkeypatch.setattr(_golangci, 'target_platform', lambda: ('linux', 'amd64')) + _serve(monkeypatch, tarball) + + install_root = tmp_path / 'install' + result = _golangci.ensure_golangci_lint(install_root=install_root) + assert isinstance(result, Result) + assert not result.passed + assert 'sha256 mismatch' in result.output + assert not (install_root / f'golangci-lint-{_golangci.VERSION}' / 'golangci-lint').exists() + + +def test_install_reports_a_tarball_without_the_binary(tmp_path, monkeypatch): + tarball = _fake_tarball(tmp_path / 'src.tar.gz', member_name='somewhere/README.md') + monkeypatch.setitem(_golangci.CHECKSUMS, ('linux', 'amd64'), hashlib.sha256(tarball.read_bytes()).hexdigest()) + monkeypatch.setattr(_golangci, 'target_platform', lambda: ('linux', 'amd64')) + _serve(monkeypatch, tarball) + + result = _golangci.ensure_golangci_lint(install_root=tmp_path / 'install') + assert isinstance(result, Result) + assert 'no golangci-lint executable' in result.output + + +def test_install_failure_short_circuits_linting(tmp_path, monkeypatch): + monkeypatch.setattr(go_checker.shutil, 'which', lambda _: '/usr/bin/go') + failure = Result(name='golangci-lint', passed=False, output='no release for this platform') + monkeypatch.setattr(go_checker, 'ensure_golangci_lint', lambda *a, **k: failure) + monkeypatch.setattr(go_checker, 'format_module', lambda *a: pytest.fail('must not format')) + monkeypatch.setattr(go_checker, 'lint_module', lambda *a: pytest.fail('must not lint')) + source = _module(tmp_path, 'x') / 'main.go' + source.write_text(CLEAN_MAIN) + + assert go_checker.GoGroup().run(_go_args(source)) == [failure] + + +# --- end to end --- + + +@pytest.mark.network +@needs_go +def test_clean_module_passes(tmp_path): + source = _module(tmp_path, 'clean') / 'main.go' + source.write_text(CLEAN_MAIN) + assert runner.main(['go', str(source)]) == 0 + + +@pytest.mark.network +@needs_go +def test_bundled_test_files_module_is_clean(): + module = _PROJECT_ROOT / 'test_files' / 'go' + assert runner.main(['go', str(module / 'main.go'), str(module / 'go.mod')]) == 0 + + +@pytest.mark.network +@needs_go +def test_missing_import_is_added_and_reported(tmp_path): + """goimports resolves a standard-library reference, so the file is rewritten for re-staging.""" + source = _module(tmp_path, 'noimport') / 'main.go' + source.write_text('// Package main is a fixture.\npackage main\n\nfunc main() {\n\tfmt.Println("x")\n}\n') + + assert runner.main(['go', str(source)]) == 1 + assert 'import "fmt"' in source.read_text() + + +@pytest.mark.network +@needs_go +def test_undefined_symbol_fails_lint(tmp_path): + source = _module(tmp_path, 'broken') / 'main.go' + source.write_text('// Package main is a fixture.\npackage main\n\nfunc main() {\n\tmissing()\n}\n') + results = go_checker.GoGroup().run(_go_args(source)) + lint = next(r for r in results if r.name == 'golangci-lint run') + assert not lint.passed + assert 'undefined: missing' in lint.output + # Paths are relative to the module root. + assert '../' not in lint.output + + +@pytest.mark.network +@needs_go +def test_unformatted_file_is_rewritten_and_reported(tmp_path): + source = _module(tmp_path, 'unformatted') / 'main.go' + source.write_text('// Package main is a fixture.\npackage main\n\nfunc main() {\n\n\tx := 1\n\t_ = x\n}\n') + results = go_checker.GoGroup().run(_go_args(source)) + fmt_result = next(r for r in results if r.name == 'golangci-lint fmt') + assert not fmt_result.passed + assert 'please re-stage and recommit' in fmt_result.output + assert '\n\n\tx := 1' not in source.read_text() + + +@pytest.mark.network +@needs_go +def test_unparseable_file_is_left_to_the_linter(tmp_path): + """A file gofumpt cannot parse passes fmt untouched and fails run with the syntax error.""" + source = _module(tmp_path, 'syntax') / 'main.go' + source.write_text('// Package main is a fixture.\npackage main\n\nfunc main() {\n') + results = go_checker.GoGroup().run(_go_args(source)) + + fmt_result = next(r for r in results if r.name == 'golangci-lint fmt') + assert fmt_result.passed + assert source.read_text() == '// Package main is a fixture.\npackage main\n\nfunc main() {\n' + + lint = next(r for r in results if r.name == 'golangci-lint run') + assert not lint.passed + assert 'syntax error' in lint.output + + +@pytest.mark.network +@needs_go +def test_untidy_go_mod_fails(tmp_path): + root = _module(tmp_path, 'untidy') + (root / 'main.go').write_text(CLEAN_MAIN) + (root / 'go.mod').write_text( + 'module example.com/untidy\n\ngo 1.23\n\nrequire github.com/pkg/errors v0.9.1\n', + ) + result = go_checker.tidy_module(root) + assert not result.passed + assert 'errors' in result.output diff --git a/tests/test_runner.py b/tests/test_runner.py index 524ec34..d9c835d 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -52,6 +52,7 @@ def test_all_groups_registered(): 'python', 'cpp', 'ros', + 'go', 'shell', 'cmake', 'docker', @@ -89,6 +90,16 @@ def test_ros(make_file): assert runner.main(['ros', f]) == 0 +@pytest.mark.network +@pytest.mark.skipif(shutil.which('go') is None, reason='Go toolchain not on PATH') +def test_go(tmp_path): + (tmp_path / 'go.mod').write_text('module example.com/clean\n\ngo 1.23\n') + (tmp_path / 'main.go').write_text( + '// Package main is a fixture.\npackage main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("x")\n}\n' + ) + assert runner.main(['go', str(tmp_path / 'main.go'), str(tmp_path / 'go.mod')]) == 0 + + def test_shell(make_file): f = make_file('example.sh', '#!/bin/bash\necho "hello"\n') assert runner.main(['shell', f]) == 0