Moving bash check scripts to alint
Large repositories collect check scripts: a hack/verify-*.sh directory, Python checkers under scripts/, lint targets in a Makefile. alint takes over the ones that check files, names and config values, in one config and one run. Scripts that parse code or call the network stay scripts, and alint can run those too.
From a one-liner to a rule
The shell lines that turn up in check scripts, and the rule kind that does the same job. Each kind links to its page in the rule catalog, with its options and an example.
| In a script | alint rule kind | Notes |
|---|---|---|
grep -rn 'TODO\|FIXME' src/ | file_content_forbidden | Add git_blame_age to flag only the ones older than, say, 180 days. |
find . -name '*.swp' -o -name '*~' | file_absent | The hygiene/no-tracked-artifacts ruleset forbids editor backups and removes them with alint fix. |
find . -name .DS_Store | file_absent with a file_remove fix | Also in hygiene/no-tracked-artifacts. |
for d in packages/*/; do test -f "$d/README.md"; done | for_each_dir with a nested file_exists | The monorepo ruleset has this rule. |
grep -q 'license =' crates/*/Cargo.toml | toml_path_matches over crates/*/Cargo.toml | The second example below shows what the grep misses. |
[ -x "$f" ] for every *.sh | executable_bit with require: true | Or pair executable_has_shebang with shebang_has_executable. |
grep -rnE '[[:space:]]+$' src/ | no_trailing_whitespace | Give the rule a file_trim_trailing_whitespace fix and alint fix trims it. oss-baseline's rule has one. |
| A check for CRLF line endings | line_endings with target: lf | Give the rule a file_normalize_line_endings fix and alint fix converts the file. |
git diff --check | no_merge_conflict_markers and no_trailing_whitespace | oss-baseline has both for text and config files. |
[[ $f =~ ^test-.*\.js$ ]] in test/parallel | filename_regex | The naming convention nodejs/node uses for its test files. |
jq -e '.license == "MIT"' package.json | json_path_equals | Use json_path_matches for a regex. |
python -c 'import json, sys; json.load(open(sys.argv[1]))' | json_schema_passes | A file that doesn't parse is a finding. alint accepts comments and trailing commas, which Python's json module rejects. |
yq '.permissions' .github/workflows/*.yml | gha-workflow-contents-read | In the ci/github-actions ruleset, or write a yaml_path_equals rule. |
grep -E 'uses: .*@[0-9a-f]{40}' | gha-pin-actions-to-sha | Also in ci/github-actions. |
head -1 src/*.rs | grep SPDX | file_header | A file_prepend fix adds the missing header. |
find . -size +5M | file_max_size | Put vendored and test data directories in exclude. |
find migrations -name '*.sql' -printf '%f\n' | sort | uniq -d | unique_by | The default key is the file name; {stem} ignores the extension. |
Two scripts side by side
Every package has a README
#!/usr/bin/env bash
set -euo pipefail
fail=0
for d in packages/*/ ; do
if [ ! -f "${d}README.md" ]; then
echo "missing: ${d}README.md" >&2
fail=1
fi
done
exit $failbash hack/verify-readmes.shmissing: packages/emails/README.mdmissing: packages/web/README.mdBoth find the two packages. The rule's report names the rule and comes in any of alint's8 output formats, and --changed doesn't skip it: rules across files always see the whole tree.
- id: package-readme
kind: for_each_dir
select: "packages/*"
require:
- kind: file_exists
paths: "{path}/README.md"
message: "{path} has no README.md"
level: erroralint checkpackages/emails ✗ error package-readme packages/emails has no README.md packages/web ✗ error package-readme packages/web has no README.mdpackages/emails ✗ error package-readme packages/emails has no README.md packages/web ✗ error package-readme packages/web has no README.mdThe monorepo ruleset ships this rule for packages/*, crates/*, apps/* and services/*.
Every crate declares a license
#!/usr/bin/env bash
for f in crates/*/Cargo.toml ; do
grep -q 'license =' "$f" || { echo "no license: $f"; exit 1; }
donebash hack/verify-licenses.shno license: crates/core/Cargo.tomlThe script stops at the first crate without a license, and a commented-out # license = "MIT" line in crates/cli/Cargo.toml satisfies its grep, so it reports 1 of the 3 crates that declare no license. The rule reads package.license from each Cargo.toml and checks that the value looks like an SPDX expression.
- id: every-crate-declares-license
kind: toml_path_matches
paths: "crates/*/Cargo.toml"
path: "$.package.license"
matches: '^[A-Za-z0-9.+-]+( (OR|AND) [A-Za-z0-9.+-]+)*$'
message: "Set package.license to an SPDX expression"
level: erroralint checkcrates/cli/Cargo.toml ✗ error every-crate-declares-license Set package.license to an SPDX expression crates/core/Cargo.toml ✗ error every-crate-declares-license Set package.license to an SPDX expression crates/web/Cargo.toml ✗ error every-crate-declares-license Set package.license to an SPDX expressioncrates/cli/Cargo.toml ✗ error every-crate-declares-license Set package.license to an SPDX expression crates/core/Cargo.toml ✗ error every-crate-declares-license Set package.license to an SPDX expression crates/web/Cargo.toml ✗ error every-crate-declares-license Set package.license to an SPDX expressionThe glob only matches directories that have a Cargo.toml, so a fixtures folder under crates/ is left alone.
Checks that need more than grep
| The script checks that | Rule kind |
|---|---|
| A generated file matches what its generator produces | generated_file_fresh |
| Only some packages may import a path | import_gate |
| Vendored files match a checksum file | pair_hash |
| Paths listed in a manifest exist, such as workspace members | registry_paths_resolve |
| A value agrees across files, such as a version in two manifests | cross_file |
| No import cycles or layering violations between files | file_graph |
| A change to one file comes with a change to another | pair_changed_together |
| Every commit in a pull request has the right message | git_commit_message with since: |
Which scripts move
| The script checks | In alint |
|---|---|
| Which files exist, what they're called, what they contain | Rule kinds for existence, naming and content |
Values in package.json, Cargo.toml or workflow files | Structured query rules, with JSONPath |
| Relations between files, such as a README in every package | Cross-file rules |
| Commit messages, sign-offs, which files a pull request changes | Git hygiene rules |
| Anything that needs a compiler or parser: types, API compatibility, module graphs | Keep the script, and run it from a command rule if you like |
| Builds, tool versions, network probes | Keep the script |
What stays. Checks that need type information, such as Kubernetes' verify-typecheck.sh; checks that build a binary and inspect it; link checkers, license lookups and registry calls; module graphs from go list or cargo metadata. file_graph covers edges between files, read from their contents.
In Kubernetes, 17 of the 51 checks the verify scripts make stay in them.
Running the scripts you keep
A command rule runs a program for each matching file and reports a non-zero exit as a finding, so CI still calls one tool:
- id: shellcheck
kind: command
paths: "**/*.sh"
command: ["shellcheck", "-x", "{path}"]
level: error
- id: templated-fields
kind: command
paths: "src/**/*.py"
command: ["scripts/check-templated-fields.py", "{path}"]
timeout: 60
level: warning- id: shellcheck
kind: command
paths: "**/*.sh"
command: ["shellcheck", "-x", "{path}"]
level: error
- id: templated-fields
kind: command
paths: "src/**/*.py"
command: ["scripts/check-templated-fields.py" , "{path}"]
timeout: 60
level: warning- Lines 1 to 5
ShellCheck, once per shell script. Under
--changed, only for the scripts in the diff. - Lines 7 to 12
Your own checker, with a longer timeout than the default of 30 seconds and a warning instead of an error.
- Command rules work only in your own top-level config; a ruleset pulled in with
extends:can't add one. - The program gets the file path in its arguments as
{path}and inALINT_PATH. Other variables carry the repository root, the rule id and level, and your vars and facts; the command rule page lists them.
Three repositories
alint configs were written for these projects, with every check they already run sorted. The projects don't use alint; each reference page has the config and what it found.
| Repository | Checks it ran in May 2026 | In the alint config |
|---|---|---|
| kubernetes/kubernetes | 50 hack/verify-*.sh scripts; make verify ran most of them on every pull request | The config covers about two thirds of what they check, 34 of the 51 checks counted: most as alint rules, and the code, docs and vendor generators by running Kubernetes' own scripts from alint, which fails when one would change a file. The rest needs Go's type checker, a build or the network. |
| python/cpython | 35 pre-commit hooks, Make targets that lint or regenerate code, checking scripts under Tools/build and 25 workflows | The config covers 25 of the 31 checks counted. The C API checks need the C code or the built interpreter, so they stay Make targets. |
| apache/airflow | A 1,085-line Python script validates the 101 provider packages | The config checks the provider layout with four rules, without importing any package. |
A plan for the move
- Sort each script into the rows of the "Which scripts move" table.
- Start
.alint.ymlwith the bundled rulesets for your stack.alint listprints every rule they bring in, so you can cross off the scripts they already cover. - Add a rule for each remaining script that moves. Use readable ids; they appear in every report.
- Run
alint checkon your main branch. Expect a few findings the scripts missed and a few rules with paths set too wide. Record what you won't fix yet withalint baseline. - Wrap the scripts you keep in command rules.
- Replace the CI steps with one
alint checkand pick the output your CI reads: SARIF, GitHub annotations, JUnit or GitLab code quality are among the 8 output formats.
alint suggest looks for problems already in the tree and proposes rules for them, which helps with step 3.
Try it
alint init writes a starter config for the languages and tools it finds. For a monorepo with packages, a first version can be three rulesets:
version: 1
extends:
- alint://bundled/oss-baseline@v1
- alint://bundled/monorepo@v1
- alint://bundled/hygiene/no-tracked-artifacts@v1 Install alint, run alint check, then add the rules your scripts need. The examples have a starter per stack.
Next: the Kubernetes reference config, with what its verify scripts check and which checks alint covers; the rule catalog and the cookbook; and how alint compares.