lintp / getting-started

Install lintp, write your first lintp.yml, and run it against a project — plus configuration, CLI flags, and troubleshooting.

getting-started/
├── why-lintp # relational rules, vs name-only linters
├── installation # npm, from source
├── quick-start # first config, first run
├── built-in-variables # $NAME, $EXT, $PATH, $item…
├── configuration # rules, messages, ignore
├── cli-reference # flags, exit codes, output
├── best-practices # composing rules that scale
└── troubleshooting # common errors, debug tips

why-lintp/

File-naming linters already exist — ls-lint is a good one, and if your conventions are purely "this extension uses this case style", it may be all you need. lintp is for the conventions a name-only check can't express, where a rule depends on what exists around a file:

lintp.yml — a relational rule
lintp:
  custom-matchers:
    pascal-case: "matches($BASENAME, /^[A-Z][a-zA-Z0-9]*$/)"
  config:
    # every component is PascalCase and has a test file sitting next to it
    .tsx:
      rule: 'pascal-case && in("${$BASENAME}.test.tsx", siblings("*.test.tsx"))'
    # longest suffix wins: test files match .test.tsx, not the component rule
    .test.tsx: 'matches($BASENAME, /^[A-Z][a-zA-Z0-9]*\.test$/)'

Rules are expressions in a small DSL with functions like siblings(), children(), find(), and exists(), so a rule can look at a file's context — not just its name.

installation/

npm ships a prebuilt binary for macOS, Linux, and Windows (x64/arm64) via optionalDependencies. On Windows ARM64, the x64 binary runs through Windows' built-in emulation layer. If no prebuilt package matches your platform, the launcher falls back to a checksum-verified binary from the GitHub release (the checksum guards download integrity, not tamper-proof supply-chain verification). The npm package is named lintp-cli (npm reserves the bare name) — the installed command is lintp.

shell — install via npm
# run without installing
npx lintp-cli

# or install globally — the command is `lintp`
npm install -g lintp-cli
From crates.io
shell — install via cargo
cargo install lintp            # compiles with your Rust toolchain (1.85+)
cargo install lintp --locked   # exact tested dependency versions
From source

The project uses asdf to pin Node.js and Rust versions.

shell — build from source
git clone https://github.com/narehart/lintp.git
cd lintp
asdf install          # required Node.js + Rust versions
cargo build --release
./target/release/lintp --help

quick-start/

Create lintp.yml in your project root. Define reusable patterns under custom-matchers, then assign a rule to each file type under config.

lintp.yml
lintp:
  # define reusable patterns
  custom-matchers:
    kebab-case: "matches($BASENAME, /^[a-z0-9]+(?:-[a-z0-9]+)*$/)"
    PascalCase: "matches($BASENAME, /^[A-Z][a-zA-Z0-9]*$/)"
    js-file: '$EXT == "js"'
    ts-file: '$EXT == "ts"'

  # rules per file type
  config:
    .js: "kebab-case && js-file"
    .ts: "PascalCase && ts-file"
    .dir: "kebab-case || PascalCase"

  ignore:
    - node_modules
    - .git
    - dist

Run it. Every file and directory is checked against the longest-matching suffix rule; failures name the exact condition that failed.

shell — first run
$ lintp
✓ ./lintp.yml
✓ ./tests
✓ ./tests/user-tests.js
✓ ./src
✗ ./src/badFile.js - .js - Does not match rule: kebab-case && js-file (failed: kebab-case)
✓ ./src/UserManager.ts
✓ ./src/utils.js
Some files or directories do not match the configured rules.

Rules combine variables ($NAME, $EXT…), operators (&&, ||, !, ==…), functions (matches, contains, startsWith…) and collections (siblings, children, find). The complete language lives in the dsl-reference; reusable recipes in common-patterns.

built-in-variables/

Every DSL expression has access to the file being checked:

variables — file & context
$NAME      # full filename incl. extension   "index.test.js"
$BASENAME  # filename without extension      "index.test"
$EXT       # extension without the dot       "js"
$PATH      # full file path                  "./src/index.test.js"
$PARENT    # parent directory path           "./src"
$item      # current item inside any(), all(), map(), filter()
lintp.yml — variables in matchers
lintp:
  custom-matchers:
    js-file: '$EXT == "js"'
    in-src: 'contains($PATH, "/src/")'
    has-js-sibling: 'any(siblings("*"), endsWith($item, ".js"))'

configuration/

Rule keys are suffix patterns, not just extensions: a file matches every key its path ends with, and the longest matching suffix wins. Button.test.tsx matches both .tsx and .test.tsx, and the .test.tsx rule applies. .* applies only when no other key matches; .dir targets directories.

A key can group several suffixes with brace alternation — each expansion gets the same rule and message:

lintp.yml — grouped suffixes
lintp:
  custom-matchers:
    camelCase: "matches($BASENAME, /^[a-z][a-zA-Z0-9]*$/)"
  config:
    ".{png,jpg,jpeg,gif,webp,svg}":
      rule: "camelCase"
      message: "image files are camelCase"

Suffix matching has one subtlety with dotfiles: a file literally named .rules also matches a .rules: key, but as a dotfile its $EXT is "" and its $BASENAME is the full dotted name. Write $EXT == "rules" when a rule should apply only to real .rules extensions — or use the behavior deliberately: .gitignore: is a valid key for targeting that exact file.

Directory-scoped rules

A top-level key that is a glob pattern holds its own suffix→rule map, applied only to matching paths — and it overrides the global rule for the same suffix there. Globs match the path relative to the linted directory, and * crosses /, so src/ui/* covers the whole subtree. When several scopes match the same path, the most specific (longest pattern) wins: src/ui/* beats src/* for files under src/ui/. Braces expand in scope keys too: "api/{auth,billing}/*" is two scopes sharing one rule map.

lintp.yml — per-directory conventions
lintp:
  config:
    .ts: "false" # default-deny: a .ts file must live in a listed location
    "src/ecs/systems/*":
      .ts:
        rule: 'matches($BASENAME, /^[a-z][a-zA-Z0-9]*System$/) && exists("${$BASENAME}.test.ts")'
        message: "systems are camelCase, end in System, and need a sibling test"
    "src/hooks/*":
      .ts:
        rule: "matches($BASENAME, /^use[A-Z][a-zA-Z0-9]*$/)"
        message: "hooks are named useX"

Prefer this over one global rule that chains ($PARENT == "./src/x" && ...) || ... branches: each location gets its own message, and failures point at the one rule that applies instead of printing the whole chain.

lintp.yml — full structure
lintp:
  custom-matchers: # reusable pattern definitions
    kebab-case: "matches($BASENAME, /^[a-z0-9]+(?:-[a-z0-9]+)*$/)"
    pascal-case: "matches($BASENAME, /^[A-Z][a-zA-Z0-9]*$/)"
  config: # suffix pattern → rule
    .test.js: 'matches($BASENAME, /^[a-z0-9-]+\.test$/)'
    .js: "kebab-case"
    .dir: "kebab-case || pascal-case"
    .*: '!contains($NAME, " ")'
  ignore: # glob patterns to skip
    - node_modules
    - "build/**"
    - "*.tmp"
Custom failure messages

Any rule can be a map with a message that replaces the raw expression in failure output — point teammates at your conventions doc instead of a regex.

lintp.yml — custom message
lintp:
  custom-matchers:
    component-file: "matches($BASENAME, /^[A-Z][a-zA-Z0-9]*$/)"
  config:
    .tsx:
      rule: "component-file"
      message: "Components must be PascalCase (see CONTRIBUTING.md)"
shell — failure output
✗ ./src/badName.tsx - .tsx - Components must be PascalCase (see CONTRIBUTING.md)

cli-reference/

shell — usage
lintp                          # lint cwd with ./lintp.yml
lintp /path/to/project         # lint a specific directory
lintp --config custom.yml      # custom config file
lintp --verbose                # show every file checked

Exit code 0 when everything passes, 1 on any violation or configuration error — a one-line CI gate. When a rule is a chain of && conditions, the failing condition(s) are listed in the (failed: …) suffix so you don't have to bisect composed rules by hand.

Symlinks: lintp does not follow symlinks. A symlinked directory's name IS checked against .dir rules, but its contents are not traversed.

best-practices/

troubleshooting/

common errors — cause → fix
✗ No config file found
  → create lintp.yml or pass --config path/to/config.yml

✗ Failed to parse config file: mapping values are not allowed in this context at line 3
  → quote DSL expressions:  rule: '$NAME == "test"'

✗ Failed to parse rule: kebab-case && js file
  → DSL syntax error; the matcher name is missing its hyphen

✗ Unknown matcher 'keba-case' referenced by rule '.js'
  → define the matcher under custom-matchers first (checked at startup)

✗ Circular reference detected: rule-a
  → matchers may not reference each other in a cycle

Debugging a rule: run with --verbose, read the (failed: …) suffix first, and test expressions in isolation with a minimal single-rule config.

continue/
├── dsl-reference   # every operator, variable, function
├── common-patterns # reusable naming & structure rules
└── examples        # React, Node API, monorepo configs
MIT License github.com/narehart/lintp