Migrating from ls-lint to alint
For ls-lint users who have decided they also want structural checks (required files, manifest fields, content patterns, cross-file invariants) and would rather not run two config files for two tools.
ls-lint enforces filesystem naming conventions: file and directory
basenames, depth-aware globbing, ignore lists. alint covers the same
ground via filename_case and filename_regex,
plus the rest of its 60-rule catalogue.
There is also a composability path at the
end. alint and ls-lint can coexist if you would rather keep your
existing .ls-lint.yml and only adopt alint for what
ls-lint doesn't cover.
Mapping table: ls-lint primitives to alint rules
| ls-lint primitive | alint equivalent | Coverage |
|---|---|---|
ls: (top-level) | rules: (top-level) | full |
ls.<ext>: snake_case | kind: filename_case + case: snake_case | full |
ls.<ext>: kebab-case | kind: filename_case + case: kebab-case | full |
ls.<ext>: camelCase | kind: filename_case + case: camelCase | full |
ls.<ext>: PascalCase | kind: filename_case + case: PascalCase | full |
ls.<ext>: SCREAMING_SNAKE_CASE | kind: filename_case + case: SCREAMING_SNAKE_CASE | full |
ls.<ext>: lowercase | kind: filename_case + case: lowercase | full |
ls.<ext>: regex:<pattern> | kind: filename_regex + pattern: | full (alint anchors ^…$ automatically) |
ls.<ext>: point.case | kind: filename_regex + pattern: "[a-z0-9.]+\.<ext>" | partial; no built-in point case in alint |
ls.<ext>: a | b (alternatives) | two rules with overlapping paths:, OR one filename_regex covering both shapes | partial; no native alternation operator |
ls.<dir>.dir: snake_case | filename_regex against <dir>/**/*, or for_each_dir with a nested check | partial; alint's filename_case runs on file basenames |
ls.<dir>.<ext>: (nested per-directory) | one rule per nested block, scoped via paths: "<dir>/**/*.<ext>" | full |
ls.<glob>/**: (deep-glob block) | one rule with paths: "<glob>/**/*.<ext>" | full |
ls.<ext>: exists:0 (forbid) | kind: file_absent + paths: "**/*.<ext>" | full |
ls.<ext>: exists:N (require N) | closest is dir_contains (at-least-one) or max_files_per_directory (at-most-N) | partial; no exact exactly N equivalent |
ignore: (top-level) | ignore: (same key) plus implicit .gitignore | full |
--warn (treat all as warnings) | per-rule level: error | warning | info | partial; severity is per-rule, no global flag |
The two rule kinds doing most of the work are
filename_case
for the named conventions and
filename_regex
for everything else. alint's filename_case accepts
ls-lint's keywords directly (snake_case,
kebab-case, camelCase, PascalCase,
SCREAMING_SNAKE_CASE, lowercase,
UPPERCASE, flatcase) plus aliases
(pascal, UpperCamelCase, etc.). Config
copy-paste from .ls-lint.yml to .alint.yml
works without rewriting the case keyword.
Per-primitive walkthrough
filename_case: the common case
ls-lint's .<ext>: <case> entry maps directly
to a single alint rule. For example, .md: SCREAMING_SNAKE_CASE:
- id: markdown-screaming
kind: filename_case
paths: "**/*.md"
case: SCREAMING_SNAKE_CASE
level: errorDirectory naming with .dir:
ls-lint's .dir keyword scopes a case rule to directory
names specifically. alint's filename_case runs on
file basenames, so the cleanest port is a
filename_regex matching every path component:
- id: dirs-snake-case
kind: filename_regex
paths: "**/*"
pattern: "[a-z0-9_]+(\.[a-z0-9_]+)?"
stem: false
level: error
message: "Directory and file basenames must be snake_case."
For a heavier but more explicit alternative, use
for_each_dir iterating the directories you care about
with a nested basename pattern check.
Forbid an extension with exists:0
Maps to file_absent. If a parent block re-allows that
extension under a subtree (ls-lint's nested-block pattern), use an
include/exclude path pair:
- id: no-png-at-root
kind: file_absent
paths:
include: ["**/*.png"]
exclude: ["assets/**/*.png"]
level: errorMigrating an existing ls-lint config
Below is ls-lint's
own canonical config ported to .alint.yml. It
covers the four common shapes: top-level extension rules, directory
rules, nested per-directory blocks, and an ignore list.
Before: .ls-lint.yml
ls:
.dir: snake_case
.*: snake_case
.*.*: snake_case
.*.*.*: exists:0
.png: exists:0
.jpg: exists:0
.md: SCREAMING_SNAKE_CASE
.bazel: SCREAMING_SNAKE_CASE
.bazel.lock: SCREAMING_SNAKE_CASE
examples/**: # allow only .yml files
.dir: snake_case
.*: exists:0
.yml: kebab-case
assets/**: # allow only .png files
.dir: snake_case
.*: exists:0
.png: kebab-case
ignore:
- .git
- .github
- genhtml
- bazel-*
- gha-*
- deployments/npm/pnpm-lock.yaml
- deployments/dockerAfter: .alint.yml
version: 1
# Same key, same semantics. .gitignore is also respected by default;
# set respect_gitignore: false to disable.
ignore:
- .git
- .github
- genhtml
- bazel-*
- gha-*
- deployments/npm/pnpm-lock.yaml
- deployments/docker
rules:
- id: dirs-snake-case
kind: filename_regex
paths: "**/*"
pattern: "[a-z0-9_]+(\.[a-z0-9_]+)?"
stem: false
level: error
message: "Directory and file basenames must be snake_case."
- id: files-snake-case
kind: filename_case
paths: "**/*"
case: snake_case
level: error
- id: no-png-at-root
kind: file_absent
paths:
include: ["**/*.png"]
exclude: ["assets/**/*.png"]
level: error
- id: no-jpg
kind: file_absent
paths: "**/*.jpg"
level: error
- id: markdown-screaming
kind: filename_case
paths: "**/*.md"
case: SCREAMING_SNAKE_CASE
level: error
- id: bazel-screaming
kind: filename_case
paths: ["**/*.bazel", "**/*.bazel.lock"]
case: SCREAMING_SNAKE_CASE
level: error
- id: examples-only-yml
kind: dir_only_contains
select: "examples/**"
allow: ["*.yml"]
level: error
- id: examples-yml-kebab
kind: filename_case
paths: "examples/**/*.yml"
case: kebab-case
level: error
- id: assets-only-png
kind: dir_only_contains
select: "assets/**"
allow: ["*.png"]
level: error
- id: assets-png-kebab
kind: filename_case
paths: "assets/**/*.png"
case: kebab-case
level: error
What changed: ls-lint's two-axis indexing (extension key + scope by
directory) is split into one alint rule per (case-convention, scope)
pair. More verbose, but trades implicit indexing for explicit
id:s and per-rule severity. The
.*: exists:0 inside a nested block becomes a declarative
dir_only_contains
rule. The ignore: list ports verbatim.
What alint doesn't replicate 1:1
In the spirit of being honest:
- No
point.casekeyword. ls-lint'spoint.case(lowercase letters, digits, dots only) has no named equivalent in alint'sfilename_case. Fall back tofilename_regexwith[a-z0-9.]+. - No alternation operator on the case rule. ls-lint's
kebab-case | PascalCasesays "either is OK." alint'sfilename_caseaccepts one convention; for an "either" relationship write a singlefilename_regexwhose pattern covers both shapes. - Directory-name rules need a different shape.
ls-lint's
.dir:keyword targets directory basenames specifically. alint's filename rules run on file basenames, so usefilename_regexon path components orfor_each_dir. -
exists:Nfor N > 0 has no clean equivalent. ls-lint can require exactly N files of a given extension in a directory. alint's closest primitives aredir_contains(at-least-one) andmax_files_per_directory(at-most-N). - No global severity-downgrade flag. ls-lint's
--warndowngrades every rule to a warning in one switch. alint expresses severity per-rule vialevel: error | warning | info; there is no global equivalent. - Slightly different glob dialect. Both use
doublestar globs (
**,*,{a,b}); minor edge cases in trailing-slash handling and brace expansion. Test in dry-run mode against your repo before flipping CI.
Composability: keep ls-lint, add alint
If you're happy with .ls-lint.yml and just want to add
structural checks without rewriting it, the two tools coexist
cleanly. Run ls-lint as one CI step and alint as another, and use
alint just for the non-naming checks:
# .alint.yml — only the non-naming surface
version: 1
extends:
- alint://bundled/oss-baseline@v1
# …plus whatever else alint covers that ls-lint doesn't:
# json_path_equals, file_header, pair, dir_contains, etc.This is a real adoption path. Many teams will keep their existing tool and add capability on top rather than migrate.
Step-by-step adoption
Path A: full migration (one config)
- Install alint. Run our
install.sh(Linux/macOS/Windows), or usebrew install alint,cargo install alint, or grab a release binary. - Copy
.ls-lint.ymlto.alint.ymlas a starting point. - Translate each rule using the
mapping table. Most port verbatim with
a wrapper
kind: filename_caseblock; a handful (.dir:,exists:N, alternation) need targeted edits called out in edge cases. - Optionally add the OSS structural baseline:
extends: [alint://bundled/oss-baseline@v1]. - Run
alint checkand audit.--format githubfor PR-friendly diffs;--format agentif you're piping into an AI assistant. - Retire
.ls-lint.ymland thels-lintCI step once.alint.ymlis green.
Path B: composability (keep ls-lint, add alint)
- Install alint.
-
Add a minimal
.alint.ymlwithextends: alint://bundled/oss-baseline@v1(or whichever bundled rulesets fit your stack). -
Add
alint checkas a second CI step alongside the existing ls-lint step. - Iterate. If you find yourself maintaining the same naming rules in two places, revisit Path A.
Either path is correct. This guide is not pushing one over the other.
Help and questions land on GitHub issues. Full rule reference at /docs/rules/; tool comparison at /compare/.