alint 0.9.9

Language-agnostic linter for repository structure, file existence, filename conventions, and file content rules.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
# alint

[![Crates.io](https://img.shields.io/crates/v/alint.svg)](https://crates.io/crates/alint)
[![CI](https://github.com/asamarts/alint/actions/workflows/ci.yml/badge.svg)](https://github.com/asamarts/alint/actions/workflows/ci.yml)
[![License](https://img.shields.io/crates/l/alint.svg)](#license)

**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.9.6 ships **60 rule kinds** across thirteen families and 12 auto-fix ops — see [docs/rules.md](docs/rules.md) for the full catalogue. alint fills the active-maintenance gap left when [Repolinter](https://github.com/todogroup/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

- **60 rule kinds** across thirteen families (full reference: [docs/rules.md](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`, `json_schema_passes`. 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`.
  - *Git hygiene* — `commented_out_code`, `markdown_paths_resolve`, `git_no_denied_paths`, `git_commit_message`, `git_blame_age`.
  - *Cross-file* — `pair`, `for_each_dir`, `for_each_file`, `dir_contains`, `dir_only_contains`, `unique_by`, `every_matching_has`.
  - *Plugin (tier 1)* — `command` (shell out to an external CLI per matched file; trust-gated to the user's top-level config).
- **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 configurable `fix_size_limit` (default 1 MiB) that skips oversize files rather than rewriting them.
- **Conditional rules** — a bounded `when:` expression language (boolean logic, comparisons, `matches` regex, `in` list 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), or `alint://bundled/<name>@<rev>`. Children override inherited rules field-by-field. Monorepos can opt into `nested_configs: true` to auto-discover `.alint.yml` files 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 with `agent_instruction` per violation).
- **JSON Schemas** — config at [`schemas/v1/config.json`](schemas/v1/config.json) for editor autocomplete; report shapes at [`schemas/v1/check-report.json`](schemas/v1/check-report.json) and [`schemas/v1/fix-report.json`](schemas/v1/fix-report.json) for downstream tooling.
- **Official GitHub Action** — `asamarts/alint@v0.9.6`.

## Non-goals

alint is deliberately **not**:

- a code / AST linter — use [ESLint](https://eslint.org/), [Clippy](https://doc.rust-lang.org/clippy/), [ruff](https://docs.astral.sh/ruff/)
- a SAST scanner — use [Semgrep](https://semgrep.dev/), [CodeQL](https://codeql.github.com/)
- an IaC scanner — use [Checkov](https://www.checkov.io/), [Conftest](https://www.conftest.dev/), [tfsec](https://aquasecurity.github.io/tfsec/)
- a commit-message linter — use [commitlint](https://commitlint.js.org/)
- a secret scanner — use [gitleaks](https://github.com/gitleaks/gitleaks), [trufflehog](https://github.com/trufflesecurity/trufflehog)

Scope is the filesystem shape and contents of a repository, not the semantics of the code inside it.

## Install

### Homebrew (macOS + Linuxbrew)

```bash
brew tap asamarts/alint
brew install alint
```

The [asamarts/homebrew-alint](https://github.com/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)

```bash
curl -sSL https://raw.githubusercontent.com/asamarts/alint/main/install.sh | bash
```

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](https://github.com/asamarts/alint/releases).

### Docker

A distroless multi-arch image (`linux/amd64`, `linux/arm64`) is published to ghcr.io on each release:

```bash
# Lint the current directory:
docker run --rm -v "$PWD:/repo" ghcr.io/asamarts/alint:latest

# Pin to an exact version:
docker run --rm -v "$PWD:/repo" ghcr.io/asamarts/alint:v0.9.6 check
```

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`:

```bash
docker run --rm -u $(id -u):$(id -g) -v "$PWD:/repo" ghcr.io/asamarts/alint:latest fix
```

Also published: `:<major>.<minor>` (e.g. `:0.9`) and the raw git tag (`:v0.9.6`).

### From crates.io

```bash
cargo install alint
```

### From npm

```bash
# project-local
npm install --save-dev @asamarts/alint
npx alint check

# global (puts `alint` on PATH)
npm install -g @asamarts/alint
alint check
```

The [@asamarts/alint](https://www.npmjs.com/package/@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

```bash
git clone https://github.com/asamarts/alint
cd alint
cargo build --release -p alint
./target/release/alint --help
```

## 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:

```bash
alint init             # ecosystem-aware (rust@v1, node@v1, …)
alint init --monorepo  # plus workspace overlays for Cargo / pnpm / Yarn
```

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:

```bash
alint suggest                       # human-readable proposal table
alint suggest --format=yaml         # paste-ready config snippet
alint suggest --format=json         # stable shape for agent consumption
alint suggest --explain             # show file-level evidence per proposal
```

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:

```bash
alint export-agents-md                                # to stdout
alint export-agents-md --inline --output AGENTS.md    # splice between alint markers
```

`--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:

```yaml
# .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:

```bash
alint check           # run all rules against the current directory
alint fix --dry-run   # preview the auto-fixes that would be applied
alint fix             # apply every fixable violation in place
alint list            # list effective rules (useful after extends / overrides)
alint explain <id>    # show a rule's full, resolved definition
alint facts           # evaluate facts against the repo — debug `when:` clauses
alint init [--monorepo]  # scaffold a `.alint.yml` based on detected ecosystem + workspace shape
alint suggest            # scan for known antipatterns and propose rules to catch them
alint export-agents-md   # render the active rule set as an AGENTS.md directive section
```

Output formats:

```bash
alint check --format human    # default; colorized; grouped by file
alint check --format json     # stable, versioned JSON schema
alint check --format sarif    # SARIF 2.1.0 (for GitHub Code Scanning)
alint check --format github   # GitHub Actions workflow commands
alint check --format markdown # GFM, suited to PR comments / mkdocs
alint check --format junit    # JUnit XML, the de-facto CI test report
alint check --format gitlab   # GitLab Code Quality JSON (Code Climate spec)
```

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.

```yaml
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:

```yaml
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.has_rust` / `facts.has_node`) and silently no-op in projects where they don't apply, so layering them is cheap. In **polyglot monorepos** (Rust under `crates/`, Node under `packages/`, Python under `apps/`, …), the bundled ecosystem rulesets additionally use `scope_filter: { has_ancestor: <manifest> }` (v0.9.6+) on their per-file content rules to narrow each rule to files inside its own ecosystem's package subtree — a `**/*.py` rule from `python@v1` won't fire on stray `.py` helpers committed under a Rust crate, and vice versa.

### 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:

```yaml
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.

```yaml
version: 1
extends:
  # Most of oss-baseline, minus the CoC nag:
  - url: alint://bundled/oss-baseline@v1
    except: [oss-code-of-conduct-exists]

  # Just the pinning check from the CI ruleset, nothing else:
  - url: alint://bundled/ci/github-actions@v1
    only: [gha-pin-actions-to-sha]
```

### 4. Enforce values inside `package.json` with structured queries

`json_path_equals` applies a [JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) query and checks the value. Missing fields are treated as violations (conservative default — scope narrowly if a field is truly optional).

```yaml
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.

```yaml
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.

```yaml
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.

```yaml
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: ["{path}/LICENSE", "{path}/LICENSE.md"]
        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:

```yaml
# .alint.yml (repo root)
version: 1
nested_configs: true
extends:
  - alint://bundled/oss-baseline@v1
```

```yaml
# 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.

```yaml
version: 1
rules:
  - id: trim-trailing-whitespace
    kind: no_trailing_whitespace
    paths: ["**/*.md", "**/*.rs", "**/*.yml"]
    level: info
    fix:
      file_trim_trailing_whitespace: {}

  - id: final-newline
    kind: final_newline
    paths: ["**/*.md", "**/*.rs", "**/*.yml"]
    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.

```yaml
version: 1

facts:
  - id: has_rust
    any_file_exists: [Cargo.toml]
  - id: has_typescript
    any_file_exists: ["tsconfig.json", "packages/*/tsconfig.json"]

rules:
  - id: rust-snake-case
    when: facts.has_rust
    kind: filename_case
    paths: "src/**/*.rs"
    case: snake
    level: error

  - id: ts-kebab-case
    when: facts.has_typescript and not (facts.has_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}`.

```yaml
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.

```yaml
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: [".env", ".env.*.local"]
    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.

```bash
# Pre-commit: lint the working-tree diff
# (`git ls-files --modified --others --exclude-standard`).
alint check --changed

# PR check: lint everything that diverged from main
# (`git diff --name-only --relative main...HEAD`).
alint check --changed --base=main --format=sarif
```

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.

```yaml
version: 1
rules:
  # actionlint over every workflow.
  - id: workflows-clean
    kind: command
    paths: ".github/workflows/*.{yml,yaml}"
    command: ["actionlint", "{path}"]
    level: error

  # shellcheck every committed shell script.
  - id: shell-clean
    kind: command
    paths: "scripts/**/*.sh"
    command: ["shellcheck", "-S", "warning", "{path}"]
    level: warning

  # In-repo policy script.
  - id: cargo-license-check
    kind: command
    paths: "**/Cargo.toml"
    command: ["./ci/check-cargo-license.sh", "{path}"]
    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."

```yaml
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.has_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](docs/rules.md#for_each_dir--for_each_file) for the full reference.

## Bundled rulesets

Nineteen 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 committed `target/`; snake_case source filenames; Trojan-Source defenses. Gated with `when: facts.has_rust`.
- **`node@v1`** — package.json + lockfile; no committed `node_modules/`, `dist/`, `.next/`, etc.; Node-version pin via `.nvmrc` or `engines`; JS/TS source hygiene. Gated with `when: facts.has_node`.
- **`python@v1`** — manifest (pyproject.toml / setup.py / setup.cfg) exists; lockfile (uv / poetry / Pipenv / PDM); pyproject.toml declares `project.name` + `project.requires-python` via structured-query; PEP 8 snake_case module filenames; Trojan-Source defenses. Gated with `when: facts.has_python`.
- **`go@v1`** — go.mod + go.sum at root; go.mod declares `module <path>` + `go <version>`; Trojan-Source defenses on `*.go`. Gated with `when: facts.has_go`.
- **`java@v1`** — Maven (`pom.xml`) or Gradle (`build.gradle` / `build.gradle.kts`) manifest; build wrapper (`mvnw` / `gradlew`); no committed `target/` / `build/` (using `git_tracked_only` so locally-built dirs stay silent); no committed `*.class`; PascalCase Java filenames; Trojan-Source defenses. Gated with `when: facts.has_java`.
- **`monorepo@v1`** — every `packages/*`, `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 by `facts.is_cargo_workspace` (root `Cargo.toml` has `[workspace]`). Verifies `members = [...]` is declared and every workspace member has a README + `[package].name`.
- **`monorepo/pnpm-workspace@v1`** — pnpm workspaces. Gated by `facts.is_pnpm_workspace` (root `pnpm-workspace.yaml` exists). Verifies the `packages:` declaration and per-member README + `name`.
- **`monorepo/yarn-workspace@v1`** — Yarn / npm workspaces. Gated by `facts.is_yarn_workspace` (root `package.json` has `"workspaces"`). Per-member README + `name`, scoped to `{packages,apps}/*`.

**License compliance** (no fact gate — extending signals intent)

- **`compliance/reuse@v1`** — FSFE [REUSE Specification](https://reuse.software/) compliance: top-level `LICENSES/` directory + every source file declares both `SPDX-License-Identifier:` and `SPDX-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 (`.env` and locals), and files over 10 MiB. Several rules auto-fixable via `file_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` + `.gitattributes` with line-ending normalization.
- **`docs/adr@v1`** — MADR-style Architecture Decision Records under `docs/adr/`: `NNNN-kebab-title.md` filename + required `## Status` / `## Context` / `## Decision` sections.
- **`ci/github-actions@v1`** — GitHub Actions hardening guided by OpenSSF Scorecard: workflow-level `permissions.contents: read`, pin third-party actions to full commit SHAs, every workflow declares a `name:`. 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](docs/rules.md#bundled-rulesets).

## Use in CI

### GitHub Actions

Inline PR annotations (default):

```yaml
- uses: asamarts/alint@v0.9.6
```

All inputs (all optional):

```yaml
- uses: asamarts/alint@v0.9.6
  with:
    version: v0.9.6        # 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:

```yaml
- uses: asamarts/alint@v0.9.6
  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`:

```yaml
repos:
  - repo: https://github.com/asamarts/alint
    rev: v0.9.6
    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**](docs/rules.md) — per-rule user reference, one entry per rule kind with a YAML example and fix-op cross-reference.
- [**ARCHITECTURE.md**](docs/design/ARCHITECTURE.md) — rule model, DSL, execution model, crate layout, plugin model.
- [**ROADMAP.md**](docs/design/ROADMAP.md) — scope per version from v0.1 through v1.0.
- [**CHANGELOG.md**](CHANGELOG.md) — per-version changes, breaking and otherwise.
- [**docs/benchmarks/METHODOLOGY.md**](docs/benchmarks/METHODOLOGY.md) — how benchmarks are measured and published.
- Per-version, per-platform benchmark results under [`docs/benchmarks/<version>/`](docs/benchmarks/).

## Development

```bash
git clone https://github.com/asamarts/alint
cd alint
cargo test --workspace        # 450+ tests; includes end-to-end scenarios
cargo run -- check            # dogfood: alint lints itself
cargo bench -p alint-bench    # criterion micro-benches
```

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](ci/env.example) for runner setup.

## License

Dual-licensed under either of:

- [Apache License 2.0](LICENSE-APACHE) ([SPDX `Apache-2.0`](https://spdx.org/licenses/Apache-2.0.html))
- [MIT License](LICENSE-MIT) ([SPDX `MIT`](https://spdx.org/licenses/MIT.html))

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.