Install lintp, write your first lintp.yml, and run it against a project — plus configuration, CLI flags, and troubleshooting.
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:
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.
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.
# run without installing npx lintp-cli # or install globally — the command is `lintp` npm install -g lintp-cli
cargo install lintp # compiles with your Rust toolchain (1.85+) cargo install lintp --locked # exact tested dependency versions
The project uses asdf to pin Node.js and Rust versions.
git clone https://github.com/narehart/lintp.git
cd lintp
asdf install # required Node.js + Rust versions
cargo build --release
./target/release/lintp --helpCreate lintp.yml in your project root. Define reusable patterns under custom-matchers, then assign a rule to each file type under config.
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.
$ 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.
Every DSL expression has access to the file being checked:
$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:
custom-matchers:
js-file: '$EXT == "js"'
in-src: 'contains($PATH, "/src/")'
has-js-sibling: 'any(siblings("*"), endsWith($item, ".js"))'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:
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.
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:
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: 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"
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:
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)"✗ ./src/badName.tsx - .tsx - Components must be PascalCase (see CONTRIBUTING.md)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.
react-component, not rule1.kebab-case, js-file), then combine them: "kebab-case && js-file".lintp --verbose, verify both outcomes.node_modules, build output, generated files — specific names beat broad wildcards for speed.siblings()/children() are cheap; find(".", "**/*") re-scans the project for every file.✗ 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.