diff --git a/.buildkite/common/scripts/upload_pipeline.py b/.buildkite/common/scripts/upload_pipeline.py index 805ed12e..f8dfa92d 100644 --- a/.buildkite/common/scripts/upload_pipeline.py +++ b/.buildkite/common/scripts/upload_pipeline.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the AFD plugin project """Render and optionally upload Buildkite pipeline YAML with diff-aware logic. Bootstrap mode (``bootstrap-upload-steps.yml``): @@ -322,7 +323,7 @@ def _select_e2e_group_steps(steps: list[Any]) -> list[Any]: return selected -def _render_test_pipeline( +def render_test_pipeline( doc: dict[str, Any], changed_files: list[str] | None, *, @@ -364,7 +365,7 @@ def _render_pipeline( if not isinstance(doc, dict): raise ValueError(f"invalid pipeline YAML: {path}") - doc = _render_test_pipeline(doc, changed_files, e2e_only=e2e_only) + doc = render_test_pipeline(doc, changed_files, e2e_only=e2e_only) return yaml.safe_dump(doc, sort_keys=False) diff --git a/.github/workflows/cpu-only-ci.yml b/.github/workflows/cpu-only-ci.yml deleted file mode 100644 index ec7567fc..00000000 --- a/.github/workflows/cpu-only-ci.yml +++ /dev/null @@ -1,49 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -name: CPU-only CI - -on: - push: - branches: [main, master] - pull_request: - branches: [main, master] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - lint: - runs-on: ubuntu-latest - env: - AFD_BUILD_ASCEND_OPS: "0" - steps: - - name: Check out repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - - - name: Sync environment - run: uv sync --locked --group dev - - - name: Ruff lint - run: uv run ruff check . - - - name: Ruff format - run: uv run ruff format --check . - - - name: Validate GitHub issue templates - run: | - ruby -e 'require "yaml"; Dir[".github/ISSUE_TEMPLATE/*.yml"].each { |f| YAML.load_file(f); puts "ok #{f}" }' diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 00000000..d110a4a1 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: Apache-2.0 +name: pre-commit + +on: + pull_request: + branches: [main, master] + push: + branches: [main, master] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: Resolve changed-files range + id: range + shell: bash + run: | + zero=0000000000000000000000000000000000000000 + if [ "${{ github.event_name }}" = "pull_request" ]; then + from_ref="${{ github.event.pull_request.base.sha }}" + to_ref="${{ github.event.pull_request.head.sha }}" + elif [ -n "${{ github.event.before }}" ] && [ "${{ github.event.before }}" != "$zero" ]; then + from_ref="${{ github.event.before }}" + to_ref="${{ github.sha }}" + else + from_ref="$(git rev-parse HEAD~1)" + to_ref="${{ github.sha }}" + fi + echo "from_ref=${from_ref}" >> "$GITHUB_OUTPUT" + echo "to_ref=${to_ref}" >> "$GITHUB_OUTPUT" + # Only check files changed in this push/PR; do not scan the whole tree. + - uses: pre-commit/action@v3.0.1 + with: + extra_args: --from-ref ${{ steps.range.outputs.from_ref }} --to-ref ${{ steps.range.outputs.to_ref }} diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 00000000..0a096583 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,17 @@ +# Markdown lint rules for docs and READMEs. +MD007: + indent: 4 +MD013: false +# GitHub READMEs start with a centered logo, not an H1. +MD041: false +MD024: + siblings_only: true +MD025: + # Allow front matter title to be different from the first heading in the document. + front_matter_title: "" +MD031: + list_items: false +MD033: false +MD046: false +MD052: false +MD059: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3076dcee..fc83665f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,130 @@ # SPDX-License-Identifier: Apache-2.0 +default_install_hook_types: + - pre-commit + - commit-msg +exclude: ^(afd_plugin/connectors/npu/bin/.*\.(run|whl))$ repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-yaml + args: [--allow-multiple-documents] + - id: check-merge-conflict + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - repo: https://github.com/astral-sh/ruff-pre-commit # Keep in sync with the ruff version pinned in uv.lock rev: v0.15.13 hooks: - id: ruff-check - args: [--fix] + args: [--output-format, github, --fix] - id: ruff-format - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + + - repo: https://github.com/crate-ci/typos + rev: v1.49.0 hooks: - - id: check-yaml - args: [--allow-multiple-documents] - - id: end-of-file-fixer - - id: trailing-whitespace - - id: check-merge-conflict + - id: typos + + - repo: https://github.com/rhysd/actionlint + # v1.7.8+ sets `go 1.24.0` in go.mod, which older Go toolchains (and most + # current CI images) cannot parse. Pin to v1.7.7 until actionlint fixes the + # go.mod directive. + rev: v1.7.7 + hooks: + - id: actionlint + files: ^\.github/workflows/.*\.ya?ml$ + + - repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: v0.23.2 + hooks: + - id: markdownlint-cli2 + language_version: lts + args: [--fix] + files: (^docs/|^recipe/|(^|/)README\.md$) + # Agent skill docs and CLAUDE.md are not user-facing docs/READMEs. + exclude: (^|/)CLAUDE\.md$|^\.claude/|^\.cursor/|^\.agents/ + + # Keep `local` last so `suggestion` stays the final hook. Autofix repos + # (ruff, typos, markdownlint, trailing-whitespace, etc.) must run first; + # otherwise their "files were modified" output displaces the SKIP tip. + - repo: local + hooks: + - id: signoff-commit + name: Sign-off Commit + entry: bash + args: + - -c + - | + if ! grep -q "^Signed-off-by: $(git config user.name) <$(git config user.email)>" "$(git rev-parse --git-path COMMIT_EDITMSG)"; then + printf "\nSigned-off-by: $(git config user.name) <$(git config user.email)>\n" >> "$(git rev-parse --git-path COMMIT_EDITMSG)" + fi + language: system + verbose: true + stages: [commit-msg] + + # mypy-3.10 runs on git commit / CI for changed files only. + # Extra versions are manual-stage only (not in default `pre-commit run`): + # pre-commit run --hook-stage manual mypy-3.12 + - id: mypy-3.10 + name: Run mypy for Python 3.10 + entry: python tools/pre_commit/mypy.py "3.10" + <<: &mypy_common + language: python + types_or: [python, pyi] + require_serial: true + additional_dependencies: + - mypy==1.11.1 + - regex + - types-PyYAML + - types-requests + - types-setuptools + - id: mypy-3.11 + name: Run mypy for Python 3.11 + entry: python tools/pre_commit/mypy.py "3.11" + <<: *mypy_common + stages: [manual] + - id: mypy-3.12 + name: Run mypy for Python 3.12 + entry: python tools/pre_commit/mypy.py "3.12" + <<: *mypy_common + stages: [manual] + - id: mypy-3.13 + name: Run mypy for Python 3.13 + entry: python tools/pre_commit/mypy.py "3.13" + <<: *mypy_common + stages: [manual] + + - id: shellcheck + name: Lint shell scripts + # language: script breaks on Windows (shebang path loses slashes). + # Invoke the wrapper with bash so Linux CI and Win/Git Bash work. + entry: bash + args: [tools/pre_commit/shellcheck.sh] + language: system + types: [shell] + + - id: check-spdx-header + name: Check SPDX headers + entry: python tools/pre_commit/check_spdx_header.py + language: python + types_or: [python, pyi, rust, proto, shell] + files: \.(py|pyi|rs|proto|sh)$ + + - id: check-buildkite + name: Validate Buildkite Pipelines + entry: python tools/pre_commit/check_buildkite.py + language: python + files: ^\.buildkite/.*\.ya?ml$ + additional_dependencies: [pyyaml, check-jsonschema==0.38.0] + + - id: suggestion + name: Suggestion + entry: bash -c 'echo "To bypass all the pre-commit hooks, add --no-verify to git commit. To skip a specific hook, prefix the commit command with SKIP=."' + language: system + verbose: true + pass_filenames: false diff --git a/pyproject.toml b/pyproject.toml index 6ca7cbdc..6dfa1cff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,15 @@ quote-style = "double" indent-style = "space" docstring-code-format = true +[tool.mypy] +python_version = "3.10" +# Silent follow for library code; missing third-party stubs are ignored +# everywhere (including tests). tests/ still get --follow-imports skip from +# tools/pre_commit/mypy.py. +ignore_missing_imports = true +check_untyped_defs = true +follow_imports = "silent" + [tool.ty] [tool.ty.analysis] diff --git a/tools/pre_commit/check_buildkite.py b/tools/pre_commit/check_buildkite.py new file mode 100644 index 00000000..8f17b437 --- /dev/null +++ b/tools/pre_commit/check_buildkite.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the AFD plugin project +"""Validate .buildkite YAML against the official Buildkite schema. + +Test pipelines (test-ready.yml, etc.) use AFD-only keys such as +``mirror_hardwares`` and ``source_file_dependencies``. Those are expanded or +stripped the same way ``upload_pipeline.py`` does before upload, then the +rendered YAML is checked with check-jsonschema. +""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / ".buildkite" / "common" / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from upload_pipeline import render_test_pipeline # noqa: E402 + +SKIP_FILES = { + ".buildkite/common/ci_mirror_hardwares.yml", +} + + +def _normalize(path: str) -> str: + return path.replace("\\", "/") + + +def _prepare_pipeline(path: Path) -> dict[str, Any]: + rel = _normalize(str(path.relative_to(ROOT) if path.is_absolute() else path)) + doc = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(doc, dict): + raise ValueError( + f"{rel}: expected a mapping (pipeline YAML), got {type(doc).__name__}" + ) + + return render_test_pipeline(doc, changed_files=None) + + +def _validate(path: Path, doc: dict[str, Any]) -> int: + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".yml", + encoding="utf-8", + delete=False, + ) as handle: + yaml.safe_dump(doc, handle, sort_keys=False) + tmp = Path(handle.name) + + try: + result = subprocess.run( + [ + sys.executable, + "-m", + "check_jsonschema", + "--builtin-schema", + "vendor.buildkite", + str(tmp), + ], + capture_output=True, + text=True, + ) + finally: + tmp.unlink(missing_ok=True) + + if result.returncode == 0: + return 0 + + rel = _normalize(str(path)) + sys.stderr.write(f"{rel}: Buildkite schema validation failed\n") + sys.stderr.write(result.stdout or "") + sys.stderr.write(result.stderr or "") + return 1 + + +def main(argv: list[str]) -> int: + rc = 0 + for raw in argv: + path = Path(raw) + rel = _normalize(str(path)) + if rel in SKIP_FILES: + continue + if not path.is_file(): + path = ROOT / path + try: + doc = _prepare_pipeline(path) + except Exception as exc: # noqa: BLE001 - surface expander errors + sys.stderr.write(f"{rel}: {exc}\n") + rc = 1 + continue + rc |= _validate(path, doc) + return rc + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/pre_commit/check_spdx_header.py b/tools/pre_commit/check_spdx_header.py new file mode 100644 index 00000000..01dd89b0 --- /dev/null +++ b/tools/pre_commit/check_spdx_header.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the AFD plugin project +"""Require SPDX license headers on source files. + +The copyright line uses the AFD plugin project name. Shell scripts (``.sh``) +and ``.pyi`` stubs use the same ``#`` header style as ``.py``. +""" + +import sys +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + + +@dataclass(frozen=True) +class HeaderStyle: + """Comment syntax and preamble handling for an SPDX header.""" + + comment_prefix: str + preserve_shebang: bool = False + + +class SPDXStatus(Enum): + """SPDX header status enumeration""" + + EMPTY = "empty" # empty __init__.py + COMPLETE = "complete" + MISSING_LICENSE = "missing_license" # Only has copyright line + MISSING_COPYRIGHT = "missing_copyright" # Only has license line + MISSING_BOTH = "missing_both" # Completely missing + + +LICENSE_TEXT = "SPDX-License-Identifier: Apache-2.0" +COPYRIGHT_TEXT = ( + "SPDX-FileCopyrightText: Copyright contributors to the AFD plugin project" +) +FILE_STYLES = { + ".py": HeaderStyle("#", preserve_shebang=True), + ".pyi": HeaderStyle("#"), + ".sh": HeaderStyle("#", preserve_shebang=True), + ".rs": HeaderStyle("//"), + ".proto": HeaderStyle("//"), +} + + +def file_style(file_path): + """Return the declared header style for a file.""" + suffix = Path(file_path).suffix + try: + return FILE_STYLES[suffix] + except KeyError: + raise ValueError(f"Unsupported file type: {file_path}") from None + + +def spdx_header(style): + """Return the SPDX header for a file style.""" + license_line = f"{style.comment_prefix} {LICENSE_TEXT}" + copyright_line = f"{style.comment_prefix} {COPYRIGHT_TEXT}" + return license_line, copyright_line + + +def header_insertion_index(style, lines): + """Return the line index where a missing header should be inserted.""" + if style.preserve_shebang and lines and lines[0].startswith("#!"): + return 1 + return 0 + + +def check_spdx_header_status(file_path): + """Check SPDX header status of the file""" + style = file_style(file_path) + license_line, copyright_line = spdx_header(style) + with open(file_path, encoding="UTF-8") as file: + lines = file.readlines() + if not lines: + # Empty file + return SPDXStatus.EMPTY + + has_license = False + has_copyright = False + + # Check all lines for SPDX headers (not just the first two) + for raw_line in lines: + line = raw_line.strip() + if line == license_line: + has_license = True + elif line == copyright_line: + has_copyright = True + + # Determine status based on what we found + if has_license and has_copyright: + return SPDXStatus.COMPLETE + elif has_license and not has_copyright: + # Only has license line + return SPDXStatus.MISSING_COPYRIGHT + elif not has_license and has_copyright: + # Only has copyright line + return SPDXStatus.MISSING_LICENSE + else: + # Completely missing both lines + return SPDXStatus.MISSING_BOTH + + +def add_header(file_path, status): + """Add or supplement SPDX header based on status""" + style = file_style(file_path) + license_line, copyright_line = spdx_header(style) + full_spdx_header = f"{license_line}\n{copyright_line}" + with open(file_path, "r+", encoding="UTF-8") as file: + lines = file.readlines() + file.seek(0, 0) + file.truncate() + + if status == SPDXStatus.MISSING_BOTH: + # Completely missing, add complete header + insertion_index = header_insertion_index(style, lines) + file.writelines(lines[:insertion_index]) + file.write(full_spdx_header + "\n") + remaining_lines = lines[insertion_index:] + if remaining_lines and remaining_lines[0].strip(): + file.write("\n") + file.writelines(remaining_lines) + + elif status == SPDXStatus.MISSING_COPYRIGHT: + # Only has license line, need to add copyright line + for i, line in enumerate(lines): + if line.strip() == license_line: + lines.insert(i + 1, f"{copyright_line}\n") + break + file.writelines(lines) + + elif status == SPDXStatus.MISSING_LICENSE: + # Only has copyright line, need to add license line + # Find the copyright line and add license line before it + for i, line in enumerate(lines): + if line.strip() == copyright_line: + # Insert license line before copyright line + lines.insert(i, f"{license_line}\n") + break + file.writelines(lines) + + +def main(): + """Main function""" + files_missing_both = [] + files_missing_copyright = [] + files_missing_license = [] + + for file_path in sys.argv[1:]: + if Path(file_path).suffix not in FILE_STYLES: + continue + status = check_spdx_header_status(file_path) + + if status == SPDXStatus.MISSING_BOTH: + files_missing_both.append(file_path) + elif status == SPDXStatus.MISSING_COPYRIGHT: + files_missing_copyright.append(file_path) + elif status == SPDXStatus.MISSING_LICENSE: + files_missing_license.append(file_path) + else: + continue + + # Collect all files that need fixing + all_files_to_fix = ( + files_missing_both + files_missing_copyright + files_missing_license + ) + if all_files_to_fix: + print("The following files are missing the SPDX header:") + if files_missing_both: + for file_path in files_missing_both: + print(f" {file_path}") + add_header(file_path, SPDXStatus.MISSING_BOTH) + + if files_missing_copyright: + for file_path in files_missing_copyright: + print(f" {file_path}") + add_header(file_path, SPDXStatus.MISSING_COPYRIGHT) + if files_missing_license: + for file_path in files_missing_license: + print(f" {file_path}") + add_header(file_path, SPDXStatus.MISSING_LICENSE) + + sys.exit(1 if all_files_to_fix else 0) + + +if __name__ == "__main__": + main() diff --git a/tools/pre_commit/mypy.py b/tools/pre_commit/mypy.py new file mode 100644 index 00000000..097697d8 --- /dev/null +++ b/tools/pre_commit/mypy.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the AFD plugin project +"""Run mypy on changed files. + +Group files so tests can use ``follow_imports=skip`` without pulling that +setting into library code. + +Usage: + python tools/pre_commit/mypy.py + +Args: + python_version: Python version to use (e.g. "3.10") or "local" to use + the interpreter running this script. + changed_files: List of changed files to check. +""" + +from __future__ import annotations + +import subprocess +import sys + +import regex as re + +# tests/ uses follow_imports=skip so third-party and library errors do not +# leak into test type-checking. After a directory is clean under skip, it +# can join the default group (follow_imports from pyproject.toml). +SEPARATE_GROUPS = [ + "tests", +] + +# Copied upstream patch bodies are large and intentionally keep upstream +# shapes; drop a prefix from this list when that tree is ready for mypy. +EXCLUDE = [ + r"afd_plugin/compat/patches/", +] + + +def _normalize_path(path: str) -> str: + return path.replace("\\", "/") + + +def group_files(changed_files: list[str]) -> dict[str, list[str]]: + """Group changed files into different mypy calls.""" + exclude_pattern = re.compile(f"^{'|'.join(EXCLUDE)}.*") + file_groups: dict[str, list[str]] = {"": []} + file_groups.update({k: [] for k in SEPARATE_GROUPS}) + # Longest path first so a sub-directory is not shadowed by its parent. + separate_groups = sorted(SEPARATE_GROUPS, key=len, reverse=True) + for changed_file in changed_files: + changed_file = _normalize_path(changed_file) + if exclude_pattern.match(changed_file): + continue + for directory in separate_groups: + if re.match(f"^{directory}.*", changed_file): + file_groups[directory].append(changed_file) + break + else: + if changed_file.startswith(("afd_plugin/", "tests/", "tools/")): + file_groups[""].append(changed_file) + return file_groups + + +def mypy( + targets: list[str], + python_version: str | None, + follow_imports: str | None, + file_group: str, +) -> int: + """Run mypy on the given targets.""" + args = ["mypy"] + if python_version is not None: + args += ["--python-version", python_version] + if follow_imports is not None: + args += ["--follow-imports", follow_imports] + print(f"$ {' '.join(args)} {file_group}") + return subprocess.run(args + targets, check=False).returncode + + +def main() -> int: + python_version = sys.argv[1] + file_groups = group_files(sys.argv[2:]) + + if python_version == "local": + python_version = f"{sys.version_info.major}.{sys.version_info.minor}" + + returncode = 0 + for file_group, changed_files in file_groups.items(): + follow_imports = None if file_group == "" else "skip" + if changed_files: + returncode |= mypy( + changed_files, python_version, follow_imports, file_group + ) + return returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/pre_commit/shellcheck.sh b/tools/pre_commit/shellcheck.sh new file mode 100755 index 00000000..4b8ac09f --- /dev/null +++ b/tools/pre_commit/shellcheck.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the AFD plugin project +# +# Lint bash scripts for undefined vars, quoting, and similar bugs. +# Do not download a binary: use a distro/package-manager install so the +# artifact is signed (apt/dnf/brew) or at least an explicit local install. +set -euo pipefail + +is_windows_exe() { + [[ "$1" == *.exe ]] +} + +is_git_bash() { + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; + *) return 1 ;; + esac +} + +find_native_shellcheck() { + local cand + cand="$(command -v shellcheck 2>/dev/null || true)" + if [ -n "$cand" ] && ! is_windows_exe "$cand"; then + echo "$cand" + return 0 + fi + return 1 +} + +install_hint() { + echo "Please install shellcheck with your package manager, then re-run:" + echo " Debian/Ubuntu/WSL: sudo apt-get install shellcheck" + echo " Fedora: sudo dnf install ShellCheck" + echo " macOS: brew install shellcheck" + echo " Git Bash: scoop install shellcheck" + echo " https://github.com/koalaman/shellcheck?tab=readme-ov-file#installing" +} + +SHELLCHECK_BIN="" +if SHELLCHECK_BIN="$(find_native_shellcheck)"; then + : +elif is_git_bash && SHELLCHECK_BIN="$(command -v shellcheck.exe 2>/dev/null || true)" && [ -n "$SHELLCHECK_BIN" ]; then + : +else + install_hint + exit 1 +fi + +should_lint() { + local f="${1//\\//}" + f="${f#./}" + case "$f" in + *.sh) ;; + *) return 1 ;; + esac + git check-ignore -q "$f" && return 1 + return 0 +} + +run_shellcheck() { + local f + for f in "$@"; do + if should_lint "$f"; then + "$SHELLCHECK_BIN" -s bash "$f" + fi + done +} + +if [ "$#" -gt 0 ]; then + run_shellcheck "$@" + exit 0 +fi + +# Direct invocation with no args: lint every tracked *.sh. +while IFS= read -r -d '' f || [ -n "$f" ]; do + git check-ignore -q "$f" && continue + "$SHELLCHECK_BIN" -s bash "$f" +done < <(find . -path ./.git -prune -o -name "*.sh" -print0)