Custom Lint Rules
Custom Lint Rules
Built-in rules cover common patterns for supported languages. When your project has its own conventions — banned function calls, required comment formats, architectural boundaries — you write custom rules.
Two options:
- YAML rules — declarative pattern matching. Works on any supported language. No code needed.
- Native Rust rules — full programmatic access to the AST. For complex logic, cross-file analysis, or when YAML isn't expressive enough.
Your first custom rule
Create a file at rules/no-todo-comments.yaml in your project:
id: no-todo-comments
message: "TODO comment found -- track in 8Vast instead"
severity: warning
languages: [rust]
files: ["src/**/*.rs"]
matcher:
kind: line_comment
text_contains: "TODO"
Run it:
8v lint . --rules-dir ./rules/
Any // TODO comment in a .rs file under src/ will produce a warning. The rule file is self-contained -- it defines what to match, what to say, and how severe it is.
Anatomy of a rule
Every custom rule has the same structure:
id: rule-name # Unique identifier (kebab-case)
message: "What's wrong" # Shown to the user
severity: error | warning # How serious
languages: [rust, typescript] # Which languages to check
files: ["src/**/*.rs"] # Which files to check (glob patterns)
matcher:
kind: call_expression # AST node type to match
text_matches: "unwrap\\(\\)" # Regex on the matched node's text
inside: [function_item] # Must appear inside this AST node
not_inside: [test_function] # Must NOT appear inside this AST node
The id must be unique across all rules -- built-in and custom. Run 8v lint rules to see all built-in IDs and avoid conflicts.
The files field is optional. If omitted, the rule applies to all files in the specified languages.
Matching by AST node type
The kind field matches code structure types. Common ones:
# Match function calls
matcher:
kind: call_expression
# Match if expressions
matcher:
kind: if_expression
# Match unsafe blocks
matcher:
kind: unsafe_block
# Match struct definitions
matcher:
kind: struct_item
# Match string literals
matcher:
kind: string_literal
Run 8v lint rules --kinds to see all available node types for each language.
Matching by text content
Filter matches by what the node's text contains:
# Simple string match
matcher:
kind: call_expression
text_contains: "unwrap"
# Regex match
matcher:
kind: call_expression
text_matches: "panic!\\(.*\\)"
# Match string literals containing "password"
matcher:
kind: string_literal
text_matches: "(?i)password"
text_contains does a plain substring search. text_matches uses regular expressions. Use text_matches when you need patterns -- case-insensitive search, wildcards, alternation.
Matching by position in the syntax tree
Control where in the code the match must appear:
# Only inside functions (not at module level)
matcher:
kind: call_expression
text_contains: "println"
inside: [function_item]
# Inside functions but NOT inside tests
matcher:
kind: call_expression
text_contains: "println"
inside: [function_item]
not_inside: [test_function]
# Must have a block as a child node
matcher:
kind: if_expression
has_child: [block]
inside and not_inside walk up the tree from the matched node. has_child looks down. These let you write rules like "ban println! in production code but allow it in tests" or "flag if expressions that contain empty blocks."
You can also match based on sibling relationships:
# Match nodes that follow a use declaration
matcher:
kind: function_item
follows: [use_declaration]
# Match nodes that precede a function
matcher:
kind: line_comment
precedes: [function_item]
Metric rules
Custom rules can also enforce numeric limits on code structure:
id: max-impl-methods
message: "Implementation block has too many methods"
severity: warning
languages: [rust]
matcher:
target: impl_item
measure: child_count
child_kind: function_item
max: 12
This fires when an impl block defines more than 12 methods. Available measures:
line_count-- number of lines the node spanschild_count-- number of direct children matchingchild_kinddepth-- nesting depth of the node
id: max-match-arms
message: "Match expression has too many arms"
severity: warning
languages: [rust]
matcher:
target: match_expression
measure: child_count
child_kind: match_arm
max: 10
Combining matchers
A single rule file defines one matcher. If you need to flag multiple patterns, create multiple rule files. Keep each rule focused on one thing.
Loading custom rules
Point the linter at a directory of rule files:
8v lint . --rules-dir ./rules/
All .yaml files in that directory are loaded as rules. Subdirectories are not scanned -- keep rule files flat.
To make this permanent, add it to lint.toml:
[custom]
rules_dir = "./rules/"
Testing rules
Before running custom rules against your whole codebase, test them:
8v lint test --rules-dir ./rules/
This validates each YAML file for syntax errors, unknown fields, and conflicting rule IDs. It reports problems without running any actual linting.
Practical examples
Ban direct database calls outside the db/ module:
id: db-boundary
message: "Database calls must go through the db module"
severity: error
languages: [rust]
files: ["src/**/*.rs"]
matcher:
kind: call_expression
text_matches: "sqlx::(query|execute)"
not_inside: [mod_item]
Require /// SAFETY: comments before unsafe blocks:
id: require-safety-comment
message: "Unsafe block must have a /// SAFETY: comment"
severity: error
languages: [rust]
matcher:
kind: unsafe_block
not_preceded_by_comment: "SAFETY:"
Flag console.log in TypeScript production code:
id: no-console-log
message: "Remove console.log before merging"
severity: warning
languages: [typescript]
files: ["src/**/*.ts"]
matcher:
kind: call_expression
text_contains: "console.log"
not_inside: [test_block]
Native Rust rules
When YAML isn't enough — cross-file analysis, tracking state across nodes, type-aware logic — write rules in Rust.
Native rules have full access to the parsed AST and can implement any logic. They're compiled into a crate and loaded by the engine at runtime.
use vast_lint::{Rule, RuleContext, Violation};
pub struct NoGlobalState;
impl Rule for NoGlobalState {
fn id(&self) -> &str { "no-global-state" }
fn message(&self) -> &str { "Avoid mutable global state" }
fn check(&self, ctx: &RuleContext) -> Vec<Violation> {
// Full access to AST, file content, project context
ctx.find_nodes("static_item")
.filter(|node| node.text_contains("mut"))
.map(|node| ctx.violation(node, self.message()))
.collect()
}
}
Native rules are for power users who need capabilities YAML can't express. Most teams won't need them — YAML covers the common cases.
When to use what
| Need | Use |
|---|---|
| Match a pattern in one language | YAML rule |
| Enforce a metric (line count, complexity) | YAML rule with measure |
| Ban a function call or import | YAML rule |
| Track state across multiple AST nodes | Native Rust rule |
| Cross-file analysis (imports, dependencies) | Native Rust rule |
| Complex boolean logic beyond AST matching | Native Rust rule |
Tips
- Start with built-in rules. Write custom rules only when you need patterns specific to your project.
- Use
8v lint explain <rule-id>to see a detailed description of any built-in rule. - Keep rule messages actionable. "TODO comment found — track in 8Vast instead" is better than "TODO found."
- Use
filesglobs to scope rules tightly. A rule that runs on all files but only matters insrc/handlers/wastes time and noise. - YAML rules work on any supported language. You don't need built-in rules to lint Go, Python, Java, or C++ — write your own.
Next
- Rules Reference — all built-in rules
- Configuration — adjust thresholds and disable rules