alint
alint is a language-agnostic linter for repository structure. You declare the shape your repo should have — required files, filename conventions, content patterns, values inside package.json / Cargo.toml / GitHub workflows, cross-file relationships — in a single .alint.yml, and alint enforces it. It walks the tree honoring .gitignore, runs rules in parallel, reports violations in human / JSON / SARIF / GitHub-annotation form, and can auto-fix what it flags. One static Rust binary, any language, any repo.
v0.7 ships 54 rule kinds across eleven families and 12 auto-fix ops — see docs/rules.md for the full catalogue. alint fills the active-maintenance gap left when Repolinter was archived in early 2026, with a superset of its rule catalogue plus first-class cross-file, conditional-rule, structured-query, and agent-aware primitives.
Core capabilities
- 54 rule kinds across eleven families (full reference: docs/rules.md):
- Existence —
file_exists,file_absent,dir_exists,dir_absent. - Content —
file_content_matches,file_content_forbidden,file_header,file_footer,file_shebang,file_starts_with,file_ends_with,file_hash,file_max_size,file_min_size,file_max_lines,file_min_lines,file_is_text,file_is_ascii. - Structured query —
json_path_equals,json_path_matches,yaml_path_equals,yaml_path_matches,toml_path_equals,toml_path_matches. JSONPath (RFC 9535) queries over JSON / YAML / TOML. - Naming —
filename_case,filename_regex. - Text hygiene —
no_trailing_whitespace,final_newline,line_endings,line_max_width,indent_style,max_consecutive_blank_lines. - Security / Unicode —
no_merge_conflict_markers,no_bidi_controls,no_zero_width_chars. - Encoding —
no_bom. - Structure —
max_directory_depth,max_files_per_directory,no_empty_files. - Portable metadata —
no_case_conflicts,no_illegal_windows_names. - Unix metadata + git —
no_symlinks,executable_bit,executable_has_shebang,shebang_has_executable,no_submodules. - Cross-file —
pair,for_each_dir,for_each_file,dir_contains,dir_only_contains,unique_by,every_matching_has.
- Existence —
- Auto-fix — 12 file ops covering content edits (trim whitespace, append newline, normalize line endings, strip BOM / bidi / zero-width, collapse blank lines) and path-level changes (create / remove / rename / prepend / append). Preview with
alint fix --dry-run. Content-editing ops honour a configurablefix_size_limit(default 1 MiB) that skips oversize files rather than rewriting them. - Conditional rules — a bounded
when:expression language (boolean logic, comparisons,matchesregex,inlist membership) gates rules on facts evaluated once per run:any_file_exists,all_files_exist,count_files. - Composition —
extends:pulls in other configs by local path, HTTPS URL (with SRI pinning), oralint://bundled/<name>@<rev>. Children override inherited rules field-by-field. Monorepos can opt intonested_configs: trueto auto-discover.alint.ymlfiles in subdirectories and scope their rules to each subtree. - Nineteen bundled rulesets —
oss-baseline,rust,node,python,go,java,ci/github-actions,monorepo,monorepo/cargo-workspace,monorepo/pnpm-workspace,monorepo/yarn-workspace,hygiene/no-tracked-artifacts,hygiene/lockfiles,tooling/editorconfig,docs/adr,compliance/reuse,compliance/apache-2,agent-hygiene,agent-context. Built into the binary — no network round-trip. - Eight output formats —
human,json(stable schema),sarif(GitHub Code Scanning),github(inline PR annotations),markdown(PR comments),junit(CI test reports),gitlab(Code Quality),agent(LLM-shaped JSON withagent_instructionper violation). - JSON Schemas — config at
schemas/v1/config.jsonfor editor autocomplete; report shapes atschemas/v1/check-report.jsonandschemas/v1/fix-report.jsonfor downstream tooling. - Official GitHub Action —
asamarts/alint@v0.9.2.
Non-goals
alint is deliberately not:
- a code / AST linter — use ESLint, Clippy, ruff
- a SAST scanner — use Semgrep, CodeQL
- an IaC scanner — use Checkov, Conftest, tfsec
- a commit-message linter — use commitlint
- a secret scanner — use gitleaks, trufflehog
Scope is the filesystem shape and contents of a repository, not the semantics of the code inside it.
Install
Homebrew (macOS + Linuxbrew)
The asamarts/homebrew-alint tap is auto-updated on every alint release — the formula downloads the matching pre-built binary, verifies its SHA-256, and installs to the Homebrew cellar.
install.sh (Linux + macOS + Windows tarballs)
|
Detects platform (Linux / macOS, x86_64 / aarch64), downloads the matching tarball, verifies the SHA-256, and installs to $INSTALL_DIR (default ~/.local/bin). Windows users download the Windows tarball from the Releases page.
Docker
A distroless multi-arch image (linux/amd64, linux/arm64) is published to ghcr.io on each release:
# Lint the current directory:
# Pin to an exact version:
The image runs as the distroless nonroot user (UID 65532); host files must be world-readable. To apply fixes and preserve host ownership, pass -u:
Also published: :<major>.<minor> (e.g. :0.9) and the raw git tag (:v0.9.2).
From crates.io
From npm
# project-local
# global (puts `alint` on PATH)
The @asamarts/alint package is a thin shim that downloads the matching pre-built binary at install time, verifies its SHA-256 against the same .sha256 companion install.sh and Homebrew use, and stages it under the package's bin-platform/. The package itself ships zero JS runtime behaviour. Set ALINT_SKIP_INSTALL=1 to suppress the postinstall network hop in CI environments that snapshot node_modules.
From source
Quick start
The fastest on-ramp is alint init — it scans your repo for the obvious markers (Cargo.toml, package.json, pnpm-workspace.yaml, …) and writes a .alint.yml with the right extends: lines:
For an existing repo with prior debt, follow up with alint suggest — it scans for *.bak files, scratch docs at root, console.log residue in production source, and TODO markers older than 180 days, then proposes the bundled rulesets and rule entries that would catch them. Output is review-only — suggest never edits your config:
For agent-driven workflows where AGENTS.md / CLAUDE.md / .cursorrules carries the directives the agent reads at session start, alint export-agents-md renders the active rule set as a markdown directive block — alint becomes the single source of truth, and the agent reads what alint enforces:
--inline writes only between <!-- alint:start --> / <!-- alint:end --> markers; everything else in AGENTS.md is human-owned prose. Re-runs are idempotent (when the on-disk content already matches, no write happens), and missing markers auto-init with a stderr warning so the second run splices cleanly.
The generated file is editable — start there, override or extend as needed. If you'd rather hand-roll, the minimum viable shape is:
# .alint.yml
# yaml-language-server: $schema=https://raw.githubusercontent.com/asamarts/alint/main/schemas/v1/config.json
version: 1
extends:
- alint://bundled/oss-baseline@v1 # README/LICENSE/SECURITY.md, merge markers, hygiene
Then run:
Output formats:
Exit codes: 0 no errors; 1 one or more errors; 2 config error; 3 internal error. Warnings do not fail by default — use --fail-on-warning to flip that.
Cookbook
The patterns below are copy-pasteable. Each one targets a real repo-maintenance problem that has cost somebody time in production.
1. One-line baseline from a bundled ruleset
The shortest useful .alint.yml — adopt the OSS-hygiene baseline and nothing else. Good for "we just want README / LICENSE / no merge markers" rigour on a fresh repo.
version: 1
extends:
- alint://bundled/oss-baseline@v1
2. Compose several bundled rulesets for a specific stack
A Rust monorepo wants OSS docs + Rust-idiomatic structure + layout checks + no tracked build artefacts:
version: 1
extends:
- alint://bundled/oss-baseline@v1
- alint://bundled/rust@v1 # Cargo.toml, target/ ban, snake_case
- alint://bundled/monorepo@v1 # every crate has README
- alint://bundled/hygiene/no-tracked-artifacts@v1 # node_modules, target/, .DS_Store…
- alint://bundled/hygiene/lockfiles@v1 # Cargo.lock only at root
The Rust and Node rulesets are gated by facts (when: facts.is_rust / facts.is_node) and silently no-op in projects where they don't apply, so layering them is cheap.
3. Override a bundled rule without restating its body
Children in an extends: chain only need to declare the fields that change. The inherited kind, paths, pattern, etc. carry over:
version: 1
extends:
- alint://bundled/oss-baseline@v1
rules:
# Turn a warning into a blocking error for our repo:
- id: oss-license-exists
level: error
# Silence a rule we've deliberately opted out of:
- id: oss-code-of-conduct-exists
level: off
Unknown-id overrides are flagged at config load, so typos don't silently pass.
3b. Adopt only part of a bundled ruleset
When you want most of a bundled ruleset but not all of it, filter at the extends: entry with only: or except: (mutually exclusive). Unknown rule ids in either list are flagged at load time.
version: 1
extends:
# Most of oss-baseline, minus the CoC nag:
- url: alint://bundled/oss-baseline@v1
except:
# Just the pinning check from the CI ruleset, nothing else:
- url: alint://bundled/ci/github-actions@v1
only:
4. Enforce values inside package.json with structured queries
json_path_equals applies a JSONPath query and checks the value. Missing fields are treated as violations (conservative default — scope narrowly if a field is truly optional).
version: 1
rules:
- id: require-mit-license
kind: json_path_equals
paths: "packages/*/package.json"
path: "$.license"
equals: "MIT"
level: error
- id: semver-package-version
kind: json_path_matches
paths: "packages/*/package.json"
path: "$.version"
matches: '^\d+\.\d+\.\d+$'
level: error
5. Lock down GitHub Actions workflows
yaml_path_equals for workflow-wide permissions; yaml_path_matches for action-SHA pinning. Both use the same JSONPath engine — YAML is coerced through serde into a JSON value first, so array and wildcard expressions work the same way. If you want the full set without typing them, extends: [alint://bundled/ci/github-actions@v1] ships these rules plus a name: presence check.
if_present: true on the pinning rule means workflows with only run: steps (no uses: at all) are silently OK — the rule only fires on actual matches that fail the regex.
version: 1
rules:
# OpenSSF: workflows should declare `permissions.contents: read` explicitly.
- id: workflow-contents-read
kind: yaml_path_equals
paths: ".github/workflows/*.yml"
path: "$.permissions.contents"
equals: "read"
level: error
# Security practice: pin third-party actions to a full commit SHA,
# not a mutable @v4-style tag. `$.jobs.*.steps[*].uses` iterates
# every step across every job. `if_present: true` skips workflows
# that have no `uses:` at all.
- id: pin-actions-to-sha
kind: yaml_path_matches
paths: ".github/workflows/*.yml"
path: "$.jobs.*.steps[*].uses"
matches: '^[a-zA-Z0-9._/-]+@[a-f0-9]{40}$'
if_present: true
level: warning
6. Enforce Cargo manifest shape across a workspace
toml_path_equals / toml_path_matches round out the structured-query family for Rust and Python (pyproject.toml) projects.
version: 1
rules:
- id: rust-edition-2024
kind: toml_path_equals
paths: "crates/*/Cargo.toml"
path: "$.package.edition"
equals: "2024"
level: error
- id: crate-version-follows-semver
kind: toml_path_matches
paths: "crates/*/Cargo.toml"
path: "$.package.version"
matches: '^\d+\.\d+\.\d+(-[\w.-]+)?$'
level: error
7. Monorepo: every package has README + license + non-stub docs
for_each_dir iterates every directory matching select: and evaluates the nested require: block against each, substituting {path} with the iterated directory. file_min_lines catches the "README is a title plus TODO" case without being pedantic about word count.
version: 1
rules:
- id: every-package-is-documented
kind: for_each_dir
select: "packages/*"
level: error
require:
- kind: file_exists
paths: "{path}/README.md"
- kind: file_min_lines
paths: "{path}/README.md"
min_lines: 5
level: warning
- kind: file_exists
paths:
level: warning
8. Nested .alint.yml for subtree-specific rules
Large repos rarely have a single policy. nested_configs: true auto-discovers .alint.yml files in subdirectories and scopes each nested rule's paths / select / primary to the subtree it lives in. The frontend team can own packages/frontend/.alint.yml without waiting on root-config review:
# .alint.yml (repo root)
version: 1
nested_configs: true
extends:
- alint://bundled/oss-baseline@v1
# packages/frontend/.alint.yml
version: 1
rules:
- id: components-are-pascal-case
kind: filename_case
paths: "components/**/*.{tsx,jsx}" # auto-scoped to packages/frontend/**
case: pascal
level: error
MVP guardrails: nested rules must declare at least one scope field; absolute paths and ..-prefixed globs are rejected; duplicate rule ids across configs surface with a clear message.
9. Auto-fix hygiene on commit
Pair a low-severity rule with a fixer and let alint fix do the boring part. Ideal for pre-commit or editor-save hooks.
version: 1
rules:
- id: trim-trailing-whitespace
kind: no_trailing_whitespace
paths:
level: info
fix:
file_trim_trailing_whitespace:
- id: final-newline
kind: final_newline
paths:
level: info
fix:
file_append_final_newline:
- id: no-bak-files
kind: file_absent
paths: "**/*.{bak,swp,orig}"
level: warning
fix:
file_remove:
Preview with alint fix --dry-run; apply with alint fix. Content-editing fixers honour fix_size_limit (default 1 MiB) and skip oversize files rather than rewriting them.
10. Conditional rules gated on repo facts
Facts are evaluated once per run and referenced in when:. Here: only enforce snake_case Rust filenames when the repo actually is a Rust project.
version: 1
facts:
- id: is_rust
any_file_exists:
- id: is_typescript
any_file_exists:
rules:
- id: rust-snake-case
when: facts.is_rust
kind: filename_case
paths: "src/**/*.rs"
case: snake
level: error
- id: ts-kebab-case
when: facts.is_typescript and not (facts.is_rust)
kind: filename_case
paths: "src/**/*.ts"
case: kebab
level: warning
11. Cross-file relationships
pair and unique_by cover the "every X has a matching Y" and "no two files share a derived key" cases — the ones that ad-hoc shell pipelines usually get wrong on the edges. Template tokens are {path}, {dir}, {basename}, {stem}, {ext}, {parent_name}.
version: 1
rules:
# Every `*.c` source file has a same-directory `*.h` header:
- id: every-c-has-a-header
kind: pair
primary: "src/**/*.c"
partner: "{dir}/{stem}.h"
level: error
# No two Rust source files share a stem anywhere in the repo — a
# frequent mod-path surprise in larger workspaces:
- id: unique-rs-stems
kind: unique_by
select: "**/*.rs"
key: "{stem}"
level: warning
12. Ban risky characters / files outright
The security-family rules catch categories that are almost never intentional. Trojan-Source (CVE-2021-42574), zero-width tricks, and stray merge markers all lead to "I didn't write that" incidents.
version: 1
rules:
- id: no-merge-markers
kind: no_merge_conflict_markers
paths:
level: error
- id: no-bidi
kind: no_bidi_controls
paths:
level: error
fix:
file_strip_bidi_controls:
- id: no-zero-width
kind: no_zero_width_chars
paths:
level: error
fix:
file_strip_zero_width:
- id: no-committed-env
kind: file_absent
paths:
level: error
13. Lint only what changed (pre-commit / PR-fast-path)
--changed restricts the check to files in the working-tree
diff (or <base>...HEAD's merge-base diff). Per-file rules
evaluate only against changed files in scope; cross-file
rules (pair, for_each_dir, every_matching_has,
unique_by, dir_contains, dir_only_contains) and
existence rules (file_exists, file_absent, …) keep
full-tree semantics so an unchanged-but-broken state still
surfaces. Empty diffs short-circuit to an empty report.
# Pre-commit: lint the working-tree diff
# (`git ls-files --modified --others --exclude-standard`).
# PR check: lint everything that diverged from main
# (`git diff --name-only --relative main...HEAD`).
Pairs with the pre-commit hook (the hook can pass
--changed via args:) and with git_tracked_only: true
on absence rules so locally-built artefacts never fire.
14. Wrap external linters with command
command shells out to any CLI on PATH per matched file. Exit 0 passes; non-zero produces a violation whose message is the tool's stdout+stderr. Argv tokens take the same {path} / {dir} / {stem} substitutions as cross-file rules. Pairs naturally with --changed — the expensive check only spawns for changed files.
version: 1
rules:
# actionlint over every workflow.
- id: workflows-clean
kind: command
paths: ".github/workflows/*.{yml,yaml}"
command:
level: error
# shellcheck every committed shell script.
- id: shell-clean
kind: command
paths: "scripts/**/*.sh"
command:
level: warning
# In-repo policy script.
- id: cargo-license-check
kind: command
paths: "**/Cargo.toml"
command:
level: error
timeout: 10
command rules are only allowed in your own top-level .alint.yml. A kind: command rule that arrives via extends: (local file, HTTPS URL, or alint://bundled/) is a load-time error — adopting someone else's ruleset never grants it arbitrary process execution. Same trust model as custom: facts.
15. Per-iteration filter with when_iter:
for_each_dir / for_each_file / every_matching_has accept an optional when_iter: expression that filters iterations. Inside it, iter.* references the entry currently being iterated — useful for "iterate only the dirs that look like a workspace member."
version: 1
rules:
# Only iterate `crates/*` dirs that contain a Cargo.toml.
# `crates/notes/` (no Cargo.toml) is skipped silently —
# without when_iter:, the missing-README rule would have
# fired on it.
- id: workspace-member-has-readme
kind: for_each_dir
select: "crates/*"
when_iter: 'iter.has_file("Cargo.toml")'
require:
- kind: file_exists
paths: "{path}/README.md"
level: error
# Bazel-style dirs: anything under services/* with at least
# one .proto under it.
- id: proto-pkg-has-readme
kind: for_each_dir
select: "services/*"
when_iter: 'iter.has_file("**/*.proto")'
require:
- kind: file_exists
paths: "{path}/README.md"
level: error
# Compose with facts.*:
- id: rust-pkg-license-set
kind: for_each_dir
select: "crates/*"
when_iter: 'facts.is_rust and iter.has_file("Cargo.toml")'
require:
- kind: toml_path_matches
paths: "{path}/Cargo.toml"
path: "$.package.license"
matches: "^Apache-2\\.0|MIT$"
level: warning
iter.* exposes path, basename, parent_name, stem, ext, is_dir, and has_file(pattern). The full when: grammar applies — boolean logic, comparisons, matches, in. See docs/rules.md for the full reference.
Bundled rulesets
Seventeen rulesets ship in the binary — zero network round-trip, pinned to the version of alint you're running:
Ecosystem + project-shape baselines
oss-baseline@v1— README / LICENSE / SECURITY.md / CODE_OF_CONDUCT.md / .gitignore existence; minimum sensible file sizes; merge-marker + bidi-control bans; trailing-whitespace and final-newline hygiene (auto-fixable).rust@v1— Cargo.toml / Cargo.lock / rust-toolchain.toml existence; no committedtarget/; snake_case source filenames; Trojan-Source defenses. Gated withwhen: facts.is_rust.node@v1— package.json + lockfile; no committednode_modules/,dist/,.next/, etc.; Node-version pin via.nvmrcorengines; JS/TS source hygiene. Gated withwhen: facts.is_node.python@v1— manifest (pyproject.toml / setup.py / setup.cfg) exists; lockfile (uv / poetry / Pipenv / PDM); pyproject.toml declaresproject.name+project.requires-pythonvia structured-query; PEP 8 snake_case module filenames; Trojan-Source defenses. Gated withwhen: facts.is_python.go@v1— go.mod + go.sum at root; go.mod declaresmodule <path>+go <version>; Trojan-Source defenses on*.go. Gated withwhen: facts.is_go.java@v1— Maven (pom.xml) or Gradle (build.gradle/build.gradle.kts) manifest; build wrapper (mvnw/gradlew); no committedtarget//build/(usinggit_tracked_onlyso locally-built dirs stay silent); no committed*.class; PascalCase Java filenames; Trojan-Source defenses. Gated withwhen: facts.is_java.monorepo@v1— everypackages/*,crates/*,apps/*,services/*directory has a README + ecosystem manifest; unique basenames.
Workspace-aware overlays (use when_iter: to scope per-member checks to actual package directories — non-package dirs under crates/ / packages/ don't fire false positives)
monorepo/cargo-workspace@v1— Cargo workspaces. Gated byfacts.is_cargo_workspace(rootCargo.tomlhas[workspace]). Verifiesmembers = [...]is declared and every workspace member has a README +[package].name.monorepo/pnpm-workspace@v1— pnpm workspaces. Gated byfacts.is_pnpm_workspace(rootpnpm-workspace.yamlexists). Verifies thepackages:declaration and per-member README +name.monorepo/yarn-workspace@v1— Yarn / npm workspaces. Gated byfacts.is_yarn_workspace(rootpackage.jsonhas"workspaces"). Per-member README +name, scoped to{packages,apps}/*.
License compliance (no fact gate — extending signals intent)
compliance/reuse@v1— FSFE REUSE Specification compliance: top-levelLICENSES/directory + every source file declares bothSPDX-License-Identifier:andSPDX-FileCopyrightText:in its first ~10 lines.compliance/apache-2@v1— Apache-2.0 compliance: LICENSE contains the Apache 2.0 text, root NOTICE file present, and every source file carries the canonical "Licensed under the Apache License, Version 2.0" header.
Namespaced utilities
hygiene/no-tracked-artifacts@v1— build outputs (node_modules,target,dist,__pycache__, …), OS junk (.DS_Store,Thumbs.db), editor backups (*~,*.swp), secret-shaped files (.envand locals), and files over 10 MiB. Several rules auto-fixable viafile_remove.hygiene/lockfiles@v1— enforce lockfiles (yarn.lock,pnpm-lock.yaml,package-lock.json,bun.lock,Cargo.lock,poetry.lock,uv.lock) live only at the workspace root.tooling/editorconfig@v1— root.editorconfig+.gitattributeswith line-ending normalization.docs/adr@v1— MADR-style Architecture Decision Records underdocs/adr/:NNNN-kebab-title.mdfilename + required## Status/## Context/## Decisionsections.ci/github-actions@v1— GitHub Actions hardening guided by OpenSSF Scorecard: workflow-levelpermissions.contents: read, pin third-party actions to full commit SHAs, every workflow declares aname:. Scoped to.github/workflows/*.y{,a}ml, so it no-ops in repos that don't use GitHub Actions.
All rulesets ship with non-blocking defaults (info / warning for recommendations, error only for unambiguous bugs). Override severity or scope by redeclaring the rule id in your own .alint.yml, or disable with level: off. Per-ruleset rule lists in docs/rules.md.
Use in CI
GitHub Actions
Inline PR annotations (default):
- uses: asamarts/alint@v0.9.2
All inputs (all optional):
- uses: asamarts/alint@v0.9.2
with:
version: v0.9.2 # alint release tag (default: latest)
path: . # directory to lint (default: .)
format: github # human | json | sarif | github | markdown | junit | gitlab (default: github)
config: | # extra config path(s), one per line
.alint.yml
fail-on-warning: false
args: "" # extra CLI args appended verbatim
Upload findings to GitHub Code Scanning:
- uses: asamarts/alint@v0.9.2
id: alint
with:
format: sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: ${{ steps.alint.outputs.sarif-file }}
pre-commit
Add to your .pre-commit-config.yaml:
repos:
- repo: https://github.com/asamarts/alint
rev: v0.9.2
hooks:
- id: alint
The hook runs alint check against the repo's .alint.yml. For auto-fix, add id: alint-fix — it's registered under stages: [manual] so it only runs when invoked explicitly (pre-commit run alint-fix), since fixers mutate the tree.
Docs
- docs/rules.md — per-rule user reference, one entry per rule kind with a YAML example and fix-op cross-reference.
- ARCHITECTURE.md — rule model, DSL, execution model, crate layout, plugin model.
- ROADMAP.md — scope per version from v0.1 through v1.0.
- CHANGELOG.md — per-version changes, breaking and otherwise.
- docs/benchmarks/METHODOLOGY.md — how benchmarks are measured and published.
- Per-version, per-platform benchmark results under
docs/benchmarks/<version>/.
Development
End-to-end tests live in crates/alint-e2e/scenarios/ as declarative YAML; adding a new scenario only requires a new file. CLI snapshot tests live in crates/alint/tests/cli/ under trycmd. Property-based invariants are in crates/alint-e2e/tests/invariants.rs.
CI is self-hosted with per-job bash scripts under ci/scripts/ that run locally or in GitHub Actions unchanged. See ci/env.example for runner setup.
License
Dual-licensed under either of:
at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in alint shall be dual-licensed as above, without any additional terms or conditions.