Coding standard guideline#

1. One command#

./checkpatch.sh                  # verify every tracked file -- what CI runs
./checkpatch.sh --staged         # verify staged files -- what the git hook runs
./checkpatch.sh --files a.c b.md # verify specific files
./format-coding.sh               # apply every autofix
./format-coding.sh --staged      # apply the autofixes to staged files only
./format-coding.sh --files a.c   # apply the autofixes to specific files
./format-coding.sh --check       # report, then restore -- same as --preview
./checkpatch.sh --install-hooks  # run the checks automatically on commit

Exit status is 0 clean, 1 findings, 2 a usage or environment problem. Verification runs the real fixers, so a failing run leaves their corrections in your working tree – git diff is the remediation. --preview is the mode that reports without mutating.

Nothing needs to be installed except pre-commit itself, which then installs a pinned copy of every linter into an isolated environment of its own. They do not need to be on your PATH, and the version you happen to have installed cannot change the result:

./checkpatch.sh --bootstrap      # tries pipx, then pip --user

§6.1 has the per-platform command if that fails.

2. One source of truth#

                       .pre-commit-config.yaml         <-- rules live here
                       .github/linters/*               <-- and their rule content
                                  |
             +--------------------+--------------------+
             |                    |                    |
      ./checkpatch.sh      git pre-commit hook   .github/workflows/linter.yml
             |                    |                    |
       ./format-coding.sh    (same hooks)       (same hooks, 3 OSes)

Every caller above runs the same hook list from the same file. None of them contains a rule, a version, a file filter or a tool invocation of its own:

File

Owns

Must never contain

.pre-commit-config.yaml

which tool, which version, which arguments, which files

nothing else does this

.github/linters/

rule content (.clang-format excepted, see below)

tool selection or versions

.editorconfig

encoding, line endings, and shfmt’s shell indent

indent widths the formatters already own

.gitattributes

line-ending normalization on checkout and check-in

anything else

checkpatch.sh

which files to feed the engine, how to report

any rule

format-coding.sh

thin write-mode wrapper over checkpatch.sh

any rule

.github/workflows/linter.yml

orchestration: OS matrix, caching, Python setup

any rule or config path

this document

prose rationale and the parity table

rules that are not in the config

Adding or changing a lint rule means editing .pre-commit-config.yaml (and the matching file in .github/linters/), and updating the parity table below. Nowhere else. A rule added to the workflow, to a script, or to a document is drift by construction, because only the config is what the hooks actually execute.

Bumping a tool version is its own commit, and its message quotes the blast radius from ./format-coding.sh --check. Never as a side effect of an unrelated change: that is how a wrong clang-format silently reformats hundreds of files. pre-commit autoupdate proposes the bumps.

2.1. Why .clang-format sits at the repository root#

Every other rule file lives in .github/linters/, because that is where super-linter looks. .clang-format cannot: clang-format itself searches upward from each source file, so the file has to be at the root, and it is a real file, not a symlink. A Windows checkout without symlink support materializes a symlink as a text file containing a path, at which point clang-format silently finds no configuration and falls back to plain LLVM style – a whole-tree reformat waiting to happen.

3. What is checked#

Language

Tool

Fixes

Rule file

any

pre-commit-hooks 6.0.0 – five structural guards

no

C, C++

clang-format 22.1.8

yes

.clang-format

Python

isort 8.0.1 (--profile black)

yes

Python

black 26.5.1 (--line-length 88)

yes

Python

flake8 7.3.0

no

.github/linters/.flake8

Python

ruff 0.16.3

partly

.github/linters/.ruff.toml

Shell

shfmt 3.13.1

yes

.editorconfig ([*.sh])

Shell

shellcheck 0.11.0

no

Markdown

markdownlint-cli 0.49.1

yes

.github/linters/.markdown-lint.yml

Markdown prose

textlint 15.8.0 + terminology 4.0.1

yes

.github/linters/.textlintrc

YAML

yamllint 1.38.0

no

.github/linters/.yaml-lint.yml

GitHub Actions

actionlint 1.7.12

no

.github/linters/actionlint.yaml

HTML

htmlhint 1.9.2

no

.github/linters/.htmlhintrc

staged diff

gitleaks 8.30.0

no

commit message

gitlint 0.19.1

no

.github/linters/.gitlint

The gitlint row is the one hook that does not run over files. It runs at the commit-msg stage against the message being written, so ./checkpatch.sh – which lints files and has no message to read – never triggers it; the git commit-msg hook fires it locally and CI runs the same hook over a pull request’s commits. §7 has the split between what its regular expression enforces and what still needs a reviewer.

Read the gitleaks row narrowly. Its hook is gitleaks git --pre-commit --staged, so it scans the staged diff and cannot scan a whole tree – in --all-files mode nothing is staged and it passes trivially. It catches a secret you are about to commit; whole-tree and pull-request scanning is CI’s, in the parity table below.

A rule file must name its rules, and an argument that matters must be written down. Both halves of that were learned the hard way here:

  • .github/linters/.ruff.toml carries an explicit select. A config that names no rules describes the pinned version rather than a check – ruff’s implicit default went from about 40 rules to 413 between 0.4 and 0.16, so bumping the pin turned one hook into 592 findings about blind excepts and datetime timezones, none of which anybody had chosen to enforce.

  • black’s --line-length 88 is black’s own default, spelled out anyway, because this document said the Python line length was 120 while the formatter had been wrapping at 88 all along. The two numbers are not in conflict – black wraps at 88, ruff only rejects beyond 120 – but only one of them was written down.

flake8 and ruff both run, and the overlap is deliberate rather than accidental. .ruff.toml selects E, W, F – flake8’s own rule set, since flake8 declares no select either – so the two are as close to interchangeable as they can be. They are not yet interchangeable in one direction: ruff 0.16 does not implement F824 (“dead global declaration”) at all; ruff rule F824 answers “unknown rule”. flake8 7.3 found two of them here, both genuinely dead. That single rule is the whole reason the flake8 hook still exists, which is also why its rule set is mirrored rather than extended – when ruff grows F824, retiring flake8 is a delete and not a re-measurement.

The five pre-commit-hooks entries are not style checks. Each one mechanically guards a promise made elsewhere in this document and otherwise enforced by nothing: destroyed-symlinks guards §2.1, check-illegal-windows-names and mixed-line-ending guard §6, check-merge-conflict is universal, and detect-private-key is the only secret scan that runs in a bare whole-tree ./checkpatch.sh – see the gitleaks note above for why that gap exists.

A version bump may not smuggle in a rule change, and three of the bumps in this set tried to:

  • markdownlint 0.49 ships MD059 and MD060, which did not exist when this config was vendored. MD060 alone reports 309 findings. Both are off.

  • textlint-rule-terminology 5.x rewrote prose in 15 files, including 18 lines of published CHANGELOG.md history, and replaced “blank line” with “empty line” – in a document describing git commit format, where “blank line” is git’s own wording. The engine is bumped to textlint 15.8.0; the word list stays at terminology 4.0.1, because the word list is rule content, not a tool version. It lives at a pinned version for the same reason .github/linters/ exists.

  • clang-format 18 and up reformat hand-laid-out data. See below.

pymtl_wrap.c (SWIG-generated) and vmlinux.h (kernel-generated) are excluded from clang-format.

patches/ is a vendored third-party patch series: reformatting a byte of it would break git apply. That started life as a global exclude: ^patches/, and a global exclude was the wrong shape twice over. It protected nothing – every formatter here is selected by language type, a *.patch file is none of those types, and deleting the exclude changed no hook’s result on the whole tree. And it silently disabled check-illegal-windows-names, which reads paths rather than content and so was the one hook the exclude could actually reach. With the hook blinded, patches/dpdk/26.03/0012-net-ice-e830:-...patch sat in the tree with a colon in its name; git checkout on Windows rejects that outright with error: invalid path and exit 128, so the repository could not be cloned there at all and the Windows CI job died during checkout, before any linter ran. The file is renamed and the exclusion now sits on mixed-line-ending – the only hook that reads every file regardless of type – and nowhere else.

C/C++ style is whatever .clang-format says – BasedOnStyle: Google with ColumnLimit: 90 today. Changing it rewrites the entire tree, so it is its own commit or it is a mistake.

Two things about clang-format 22 are worth knowing before the next bump, because both were paid for once already. It no longer reads (type)-1 as a cast, so ((mtl_iova_t)-1) is now written ((mtl_iova_t) - 1); that is whitespace only and the tokens are identical, and in the ((align)-1) case – a macro parameter, not a type – the new spacing is simply correct. And from 18 on, a braced initializer whose elements carry trailing comments is broken to one element per line. The six le10_to_be_* permute tables in lib/src/st2110/st_avx512_vbmi.c are laid out one pixel group per row precisely so the pattern can be read against the packing it implements, so they sit inside a /* clang-format off */ region. That is a pin on the layout, not an exemption from review: reformatting them cost 480 lines and all of their readability.

Rules the language tools cannot express – the two-world rule, prefixes, lock order, error-return conventions – are in .github/instructions/mtl-c-coding.instructions.md and enforced by review, not by a linter.

3.1. Deliberate omissions#

Not every available check is enabled. These were considered and rejected, so that “why isn’t there a hook for X” has an answer:

  • end-of-file-fixer, trailing-whitespace – would rewrite 152 and 34 tracked files respectively on first run. Pure churn against a tree nobody has complained about. .editorconfig deliberately does not declare insert_final_newline either: a rule no hook reads would just move the same 152 violations somewhere less visible.

  • check-shebang-scripts-are-executable – fails 17 pre-existing files. The narrower “*.sh must be executable” check that CI already had is kept instead (see the parity table).

  • cpplint – was configured but never enabled in CI. Its rule set overlaps clang-format and contradicts it on line length. Enabling it now would be a substantive style change, not a unification.

  • check-json – fails 20-plus tests/tools/RxTxApp/script/**/*.json, which use trailing commas. json-c, which RxTxApp actually parses them with, accepts those; strict JSON does not. Turning the check on means rewriting working fixtures, so it stays off and the fixtures stay as they are.

  • check-case-conflict – fails, and the failure is a real defect rather than a style preference: tests/acceptance/mtl_engine/RxTxApp.py and tests/acceptance/mtl_engine/rxtxapp.py are both tracked, so this tree cannot be checked out on a case-insensitive filesystem, which contradicts the macOS and Windows support claimed in §6. Renaming a module that mtl_engine imports is not a lint change; the hook is left off until that is fixed on its own terms.

  • pylint, hadolint – their config files were tracked in .github/linters/ while the matching validators were switched off in CI. The configs were deleted; the validators stay off. Re-enabling either is a rule change and needs its own discussion.

4. Parity table: local, hook and CI#

The three must agree. Anything CI enforces that checkpatch.sh cannot reproduce is listed here explicitly, and runs in the residual-linters job of .github/workflows/linter.yml.

CI check

Status

Why

clang-format, isort, black, flake8, ruff, shfmt, shellcheck, markdownlint, textlint, yamllint, actionlint, htmlhint

in checkpatch.sh

pinned by .pre-commit-config.yaml; each pin measured clean over the whole tree before adoption

VALIDATE_GITLEAKS

residual

the hook scans the staged diff, so it cannot scan a whole tree or a pull request. Both run the same scanner at different scopes. detect-private-key covers the whole tree, but only for PEM blocks

VALIDATE_BASH_EXEC

residual

*.sh must keep its executable bit. No upstream hook has these exact semantics; the nearest one is broader and fails 17 files

VALIDATE_ENV (dotenv-linter)

residual, known landmine

fails all 8 tracked *.env whole-tree; see the comment at that key in linter.yml

VALIDATE_TYPESCRIPT_ES

residual

135 *.ts files with no lintable project configuration from a clean clone: .gitignore’s blanket *.json leaves tests/tools/perf_debug_mcp/package.json untracked

VALIDATE_RUST_*, VALIDATE_RUST_CLIPPY

residual

rustfmt and clippy need a Rust toolchain per environment. Carried over unchanged rather than narrowed, since narrowing the edition list would be a rule change

VALIDATE_EDITORCONFIG

dropped

it was default-on before, and enforced nothing: no .editorconfig existed. The one that exists now declares only charset, end_of_line and shfmt’s shell indent, all of which the hooks or .gitattributes already cover

commit-message style (gitlint)

own commit-messages job

a message is not a file, so checkpatch.sh cannot reach it. The job runs the same commit-msg hook over each PR commit that the git commit-msg hook runs locally, so version and rules stay in one place. Not in residual-linters: that is the super-linter allow-list, and MTL’s capitalized, scope-free format is not one

That job was a deny-list of nine VALIDATE_*: false keys and is now an allow-list, so anything not named above is no longer enforced – the two real cases are in the table, and a file type census found no tracked *.js, *.css, *.xml, *.go, *.rb, *.java or *.sql for the rest to have applied to. super-linter aborts if true and false are mixed, so no VALIDATE_*: false line may be added back.

4.1. The build gate depends on these names#

.github/workflows/build.yml will not build until the file linters have passed. The Lint Code Base job aggregates the three checkpatch matrix jobs and Lint checks not yet in checkpatch; the build’s wait-for-linter job polls that one stable check-run name. A lint failure therefore skips the build rather than wasting a DPDK compile on it. Commit-message style remains an independent pull request check and does not block the compile.

The workflow coupling is by the aggregate job’s name:. Renaming Lint Code Base without updating build.yml makes the gate wait for a check that never arrives and report a timeout, which looks like infrastructure failure instead of a configuration error. The aggregate keeps the matrix details visible without requiring consumers to duplicate every platform-specific check name.

5. Git hooks#

./checkpatch.sh --install-hooks

Installs the pre-commit, pre-merge-commit and commit-msg hooks. The first two check staged files only, so they cost roughly the size of your change, not the size of the tree; the third runs gitlint over the message you are writing (§7).

To bypass in an emergency:

git commit --no-verify
git merge --no-verify

Bypassing is for a work-in-progress commit or a broken hook, not for merging unformatted code: CI runs the identical hook list and is the merge authority.

One surprise worth knowing before it costs you ten minutes: if .pre-commit-config.yaml itself has unstaged modifications, the hook refuses to run at all and every commit fails with Your pre-commit configuration is unstaged. Stage the config, or use --no-verify while you iterate on it.

6. Platforms#

Any Linux distribution, macOS and Windows (git-bash / MSYS2) are supported, and CI runs ./checkpatch.sh on Linux, macOS and Windows so the claim cannot rot. It had already rotted once before that job existed: a tracked filename containing a colon made the tree impossible to check out on Windows (see the patches/ note in §3). check-illegal-windows-names now fails locally on any path Windows cannot represent, which is a cheaper place to find out.

6.1. Installing the engine#

You need git, Python 3.10 or newer (what pre-commit 4.x requires), and network access on the first run. Nothing else – no distribution package of clang-format, shfmt, shellcheck, Node.js or Go.

./checkpatch.sh --bootstrap           # pipx if present, else pip --user

Most current distributions mark their system Python as externally managed (PEP 668), which makes pip install --user fail. That is not a broken repository – it means you should take the distribution’s own package, or pipx:

Platform

Command

Fedora, RHEL, CentOS Stream

sudo dnf install pre-commit

Arch, Manjaro

sudo pacman -S python-pre-commit

Debian, Ubuntu

sudo apt install pre-commit

openSUSE

sudo zypper install python3-pre-commit

macOS

brew install pre-commit

Windows, git-bash / MSYS2

py -m pip install pre-commit

anything with pipx

pipx install pre-commit

Then, once per clone:

./checkpatch.sh --install-hooks

The first run builds the pinned tools into ~/.cache/pre-commit – about 560 MB and a few minutes, once per .pre-commit-config.yaml change. PRE_COMMIT_HOME moves it, which is what CI does to keep the cache workspace-relative.

Ten hooks are Python wheels; the other four need no system toolchain either. gitleaks is a Go hook and pre-commit downloads Go itself when it is not on PATH. markdownlint, textlint and htmlhint are Node hooks, and the config pins default_language_version: node so pre-commit fetches that exact Node rather than using whatever the host has – markdownlint-cli 0.49 needs Node 20 or newer merely to parse its own source, so an older system Node was a crash and a newer one was an unpinned variable.

One thing genuinely not pinned is the engine: the config’s floor is minimum_pre_commit_version: 3.5.0, CI runs 4.6.2, and a distribution package may be older than either. If a hook behaves differently for you than in CI, check pre-commit --version first – CI is authoritative.

6.2. What the scripts avoid#

  • No GNU-only construct – no mapfile, no nproc, no grep -oP, no readlink -f, no in-place sed -i. macOS ships bash 3.2 and a BSD userland; MSYS2 ships neither GNU coreutils defaults nor a POSIX filesystem.

  • Paths are handed to pre-commit verbatim, which resolves them itself. Rewriting them in shell breaks MSYS2, where pwd is not a path native Python can resolve.

  • No linter configuration file may be a symlink, because a Windows checkout materializes it as a text file (§2.1). Non-linter configuration symlinks are knowingly exempt.

  • Line endings are governed by .gitattributes, which converts on check-in: what is already committed is left alone, so one tracked CRLF Markdown file remains and is fine. What must not happen is a mixed file, which would fail shfmt and clang-format alike – mixed-line-ending is the hook that checks it, with --fix=no, because normalizing endings is .gitattributes’ job.

7. Commit messages#

MTL follows Conventional Commits with two deviations: structural elements are capitalized, and Add is accepted for feat plus build(deps) for dependency bumps.

  • Build: changes to the build tooling or external dependencies

  • Ci: changes to CI configuration and scripts

  • Docs: documentation only

  • Feat / Add: a new feature

  • Fix: a bugfix

  • Perf: a change that improves performance

  • Refactor: a change that neither fixes a bug nor adds a feature

  • Style: changes that do not affect the meaning of the code

  • Test: adding or correcting tests

The mechanical half of that format is automated, by gitlint. The commit-msg git hook runs it on every git commit locally, and the commit-messages job in .github/workflows/linter.yml runs the same hook over each commit a pull request adds. checkpatch.sh itself still checks only files – it has no message to read – so message linting rides the commit-msg stage, not a checkpatch.sh run. The rules are in .github/linters/.gitlint, the version is pinned in .pre-commit-config.yaml, and both callers run the one hook, so they cannot drift.

gitlint enforces what a regular expression can judge: the type prefix comes from the fixed set above, capitalized; a capital follows it; the subject is at most 72 characters with no trailing period; and git commit -s’s Signed-off-by trailer is present. It deliberately does not use its own conventional-commit rule, which wants a lowercase feat: with scopes – the opposite of MTL’s format. Body-line wrapping is left off because a valid Fixes: or Signed-off-by footer legitimately runs past 72 and would false-positive.

Everything the mtl-commit skill asks for that a regular expression cannot judge – imperative mood, “explain why, not how”, no chat-or-process leakage, body wrapping – stays a reviewer’s call, in the same bucket as the two-world rule and the prefixes in .github/instructions/mtl-c-coding.instructions.md.