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