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_casekind: filename_case + case: snake_casefull
ls.<ext>: kebab-casekind: filename_case + case: kebab-casefull
ls.<ext>: camelCasekind: filename_case + case: camelCasefull
ls.<ext>: PascalCasekind: filename_case + case: PascalCasefull
ls.<ext>: SCREAMING_SNAKE_CASEkind: filename_case + case: SCREAMING_SNAKE_CASEfull
ls.<ext>: lowercasekind: filename_case + case: lowercasefull
ls.<ext>: regex:<pattern>kind: filename_regex + pattern:full (alint anchors ^…$ automatically)
ls.<ext>: point.casekind: 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 shapespartial; no native alternation operator
ls.<dir>.dir: snake_casefilename_regex against <dir>/**/*, or for_each_dir with a nested checkpartial; 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 .gitignorefull
--warn (treat all as warnings)per-rule level: error | warning | infopartial; 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: error

Directory 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: error

Migrating 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/docker

After: .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:

  1. No point.case keyword. ls-lint's point.case (lowercase letters, digits, dots only) has no named equivalent in alint's filename_case. Fall back to filename_regex with [a-z0-9.]+.
  2. No alternation operator on the case rule. ls-lint's kebab-case | PascalCase says "either is OK." alint's filename_case accepts one convention; for an "either" relationship write a single filename_regex whose pattern covers both shapes.
  3. 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 use filename_regex on path components or for_each_dir.
  4. exists:N for N > 0 has no clean equivalent. ls-lint can require exactly N files of a given extension in a directory. alint's closest primitives are dir_contains (at-least-one) and max_files_per_directory (at-most-N).
  5. No global severity-downgrade flag. ls-lint's --warn downgrades every rule to a warning in one switch. alint expresses severity per-rule via level: error | warning | info; there is no global equivalent.
  6. 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)

  1. Install alint. Run our install.sh (Linux/macOS/Windows), or use brew install alint, cargo install alint, or grab a release binary.
  2. Copy .ls-lint.yml to .alint.yml as a starting point.
  3. Translate each rule using the mapping table. Most port verbatim with a wrapper kind: filename_case block; a handful (.dir:, exists:N, alternation) need targeted edits called out in edge cases.
  4. Optionally add the OSS structural baseline: extends: [alint://bundled/oss-baseline@v1].
  5. Run alint check and audit. --format github for PR-friendly diffs; --format agent if you're piping into an AI assistant.
  6. Retire .ls-lint.yml and the ls-lint CI step once .alint.yml is green.

Path B: composability (keep ls-lint, add alint)

  1. Install alint.
  2. Add a minimal .alint.yml with extends: alint://bundled/oss-baseline@v1 (or whichever bundled rulesets fit your stack).
  3. Add alint check as a second CI step alongside the existing ls-lint step.
  4. 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/.