---
name: new-airflow-rule
description: This skill should be used when the user asks to "add a new airflow rule", "create an airflow lint rule", "implement an airflow inspection", "new AIR rule", or discusses creating Ruff linter rules in the airflow category.
license: Apache-2.0
---

# Creating a New Airflow Lint Rule in Ruff

This skill guides creating new Airflow-specific lint rules (AIR prefix) in the Ruff codebase. Detail content lives in `references/` files — load them as needed instead of carrying them in context up front.

## Reference Index

Read the relevant file when you reach a step that needs it:

- **`references/import-paths.md`** — Airflow 2-to-3 import path mapping table (use when the rule must match both old and new SDK paths).
- **`references/templates.md`** — Full rule-file skeletons for general best-practice (AIR001-099) and Airflow 3 migration (AIR3xx) rules.
- **`references/helpers.md`** — `helpers.rs` utilities: `Replacement` enums, try-except guarding, fix generation, class/module identification, `template_fields` scope, transitive inheritance, resolving named annotations to class defs, Dag-file detection, scope-based context detection.
- **`references/patterns.md`** — Common code patterns: decorator checks, dual-dispatch (TaskFlow + operator API), call/argument inspection, operator matching, recursive return collection, expression-type dispatch, parameterized violations, function ordering, mixed-content string scanning.
- **`references/documentation.md`** — Rule docstring structure, terminology (Dag/DAG/@dag), style, message vs. fix-title conventions.
- **`references/pitfalls.md`** — Review-informed bugs to avoid: chained `return matches!()`, `resolve_name` under `from __future__ import annotations`, field naming, eager `let` before `&&`, single-use allowlists. **Skim this before writing predicates that resolve names or chain fallback checks.**

## Pre-Implementation: Gather Context

**Before writing any code, ask the user the following questions (if not already answered in their request):**

1. **Airflow version target:**
   > Which version of Airflow does this rule target?
   > - Airflow 2 only
   > - Airflow 3 onward
   > - Both Airflow 2 and 3

2. **DAG API targeting** (for general best-practice rules AIR001-099 only):
   > Airflow DAGs can be written using the TaskFlow API (decorators like `@task.branch`) or the standard operator API (`BranchPythonOperator`). Both are equally supported. Should this rule target:
   > - TaskFlow API only (decorator-based)
   > - Operator API only (operator-based)
   > - Both (recommended — the rule should be DAG implementation-agnostic)
   >
   > If both: see the dual-dispatch pattern in `references/patterns.md`.

3. **Local Airflow repository for validation:**
   > Do you have a local clone of the Airflow repository? If so, provide the path (e.g., `~/repositories/airflow`).
   > If available, use it after implementation to validate against real-world code (see Step 12 below).

**Code prefix validation:** If a rule targets Airflow 3 (onward), its code MUST start with `AIR3##` (e.g., AIR301, AIR302, AIR311). If the user specified a code that doesn't follow this pattern, WARN them before proceeding.

**Important distinction for AIR3xx rules:** AIR3xx rules are migration rules that flag *old-style* (Airflow 2) imports/patterns to help migrate to Airflow 3. They should only match deprecated import paths — NOT the new `airflow.sdk` paths (which are the correct replacements). However, shared helpers that check context (e.g., "is this function decorated with `@task`?") should match both old and new paths, since deprecated patterns inside a task function need to be flagged regardless of which import style the decorator uses.

## Rule Numbering Scheme

| Range | Category | Description |
|-------|----------|-------------|
| AIR001-099 | General best-practice | Style, readability, common mistakes (not version-specific) |
| AIR301 | Removed in 3.0 | Symbols/args fully removed in Airflow 3.0 with no compat layer |
| AIR302 | Moved to provider in 3.0 | Symbols moved to external provider packages (required migration) |
| AIR303 | Signature change in 3.0 | Function/method signatures changed (args renamed, reordered, etc.) |
| AIR311 | Suggested update for 3.0 | Deprecated with compat layer — still works but will break later |
| AIR312 | Suggested provider move for 3.0 | Deprecated compat layer for provider migrations |
| AIR321 | Moved in 3.1 | Symbols moved/deprecated in Airflow 3.1 |

## Checklist

Follow these steps in order. Each step is mandatory.

### 0. Search for Reusable Utilities

**Before writing rule logic**, search for existing utilities that can be reused:

- **`ruff_python_semantic`**: Check `SemanticModel` methods (e.g., `resolve_qualified_name`, `match_builtin_expr`, `match_typing_expr`) and `analyze/visibility.rs` for decorator-checking patterns.
- **`ruff_python_ast`**: Check `helpers.rs` for AST traversal utilities (e.g., `map_callable`, `ReturnStatementVisitor`).
- **`ruff_python_trivia::Cursor`**: For rules that need ad-hoc parsing of string content (e.g., Jinja templates, SQL fragments), use `Cursor` instead of chaining `strip_prefix`/`strip_suffix`/`find` (see AIR201 for an example).
- **`crate::rules::airflow::helpers`**: Check for existing airflow-specific helpers — full inventory in `references/helpers.md`.
- **Existing airflow rules**: Check rules like AIR301 (`removal_in_3.rs`) for patterns that may already exist or could be extracted.

**If a pattern would be useful in multiple rules, extract it into `helpers.rs` as a shared utility** rather than duplicating code.

### 1. Choose a Rule Code and Name

- Check existing codes in `crates/ruff_linter/src/codes.rs` under the `// airflow` section.
- Pick the next available code in the appropriate range.
- Name the struct following the convention: the name should make sense as "allow ${name}". For example, `TaskBranchAsShortCircuit` reads as "allow task branch as short circuit".
- Do NOT use prefixes like `Disallow` or `Banned`.

### 2. Create the Rule File

Create `crates/ruff_linter/src/rules/airflow/rules/<snake_case_name>.rs`.

Use the appropriate skeleton from **`references/templates.md`** (general best-practice or migration rule). For Airflow-2-and-3 rules, also consult `references/import-paths.md` for the dual-path matching pattern.

### 3. Register the Rule Code

In `crates/ruff_linter/src/codes.rs`, add an entry under the `// airflow` section:

```rust
(Airflow, "xxx") => rules::airflow::rules::MyRuleName,
```

Keep the entries sorted by code number.

### 4. Export the Module

In `crates/ruff_linter/src/rules/airflow/rules/mod.rs`:
- Add `pub(crate) use my_rule_name::*;` in the use-declarations block (alphabetical order).
- Add `mod my_rule_name;` in the mod-declarations block (alphabetical order).

Do NOT add a duplicate module declaration in `crates/ruff_linter/src/rules/airflow/mod.rs` — only `rules/mod.rs` needs it.

### 5. Add the Rule Dispatch

In `crates/ruff_linter/src/checkers/ast/analyze/`:
- For **statement-based** rules (function defs, assignments, class defs): add to `statement.rs`
- For **expression-based** rules (function calls, attribute access): add to `expression.rs`

```rust
if checker.is_rule_enabled(Rule::MyRuleName) {
    airflow::rules::my_rule_name(checker, node);
}
```

Place the dispatch near other airflow rule dispatches for consistency.

### 6. Create the Test Fixture

Create `crates/ruff_linter/resources/test/fixtures/airflow/AIRxxx.py`.

The fixture must include:
- Cases that SHOULD trigger the rule (with `# AIRxxx` comments)
- Cases that should NOT trigger the rule (edge cases, similar but valid patterns)
- For migration rules: cases guarded by try-except (should NOT trigger)
- **If the rule resolves names inside annotations**: include at least one fixture with `from __future__ import annotations` to catch the deferred-annotation bug — see `references/pitfalls.md`.

### 7. Add the Test Case

In `crates/ruff_linter/src/rules/airflow/mod.rs`, add a `#[test_case]` line:

```rust
#[test_case(Rule::MyRuleName, Path::new("AIRxxx.py"))]
```

Keep test cases sorted by rule code.

### 8. Format Code

**Always run `cargo fmt` before testing or committing.** Rust formatting issues will cause CI failures.

```sh
cargo fmt -p ruff_linter
```

### 9. Run Tests and Accept Snapshots

```sh
# Verify output manually first:
cargo run -p ruff -- check crates/ruff_linter/resources/test/fixtures/airflow/AIRxxx.py --no-cache --preview --select AIRxxx

# Run the test (will fail first time, generating a snapshot):
RUFF_UPDATE_SCHEMA=1 cargo nextest run -p ruff_linter -- "airflow::tests"

# Accept the snapshot:
cargo insta accept

# Verify the test passes:
RUFF_UPDATE_SCHEMA=1 cargo nextest run -p ruff_linter -- "airflow::tests"
```

### 10. Regenerate Docs and Schemas

```sh
cargo dev generate-all
```

### 11. Run All Checks

```sh
cargo clippy -p ruff_linter --all-targets --all-features -- -D warnings
uvx prek run -a
```

### 12. Validate Against Airflow (if local repo available)

If the user provided a path to a local Airflow repository during pre-implementation, run the new rule against it:

```sh
cargo run -p ruff -- check <airflow_repo_path> --no-cache --preview --select AIRxxx
```

Review the output:
- **True positives**: Violations that correctly flag the anti-pattern. Report the count and sample locations.
- **False positives**: Violations that flag code that is actually correct. If found:
  1. Identify the pattern causing the false positive.
  2. Update the rule logic to exclude it (e.g., add a guard clause).
  3. Add the false-positive pattern as a non-violation test case in the fixture.
  4. Re-run steps 8–11 to update snapshots and verify.

Report findings to the user before finalizing.

## Key Conventions

- Use `checker.report_diagnostic(ViolationStruct, range)` — NOT `Diagnostic::new()`.
- Add `#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")]` for new rules.
- Always guard with `checker.semantic().seen_module(Modules::AIRFLOW)`.
- For migration rules, use `is_guarded_by_try_except` to avoid false positives on conditional imports.
- For rules targeting both Airflow 2 and 3, match both old and new (`airflow.sdk`) import paths (see `references/import-paths.md`).
- **Reuse over duplication:** before writing a utility function, search `ruff_python_semantic`, `ruff_python_ast`, and `airflow/helpers.rs` for existing implementations. If a pattern is useful in multiple rules, add it to `helpers.rs` rather than keeping it local.
- Follow early-return style (guard clauses) rather than deeply nested if-let chains.
- Prefer let chains (`if let` combined with `&&`) over nested `if let` when possible.
- Avoid `panic!`, `unreachable!`, or `.unwrap()`.
- Use `#[expect()]` over `#[allow()]` for suppressing clippy lints.
- For internal (non-public) functions, implementation notes should be `///` doc comments, not `//` comments.
- **Short-circuit expensive checks**: when a violation requires either of two predicates and one of them traverses the function body, order the cheap one first in `&&` so the expensive one only runs when needed. Avoid eager `let`-bindings of expensive traversals.
- **Inline single-use allowlists**: if a `const &[&str]` is only consulted at one call site, inline it as a `matches!` arm at the use site.
- **Name violation fields by their semantics, not their derivation**: a field used to decide between two fix titles is `annotation_is_mapping`, not `inferred`.