Metadata-Version: 2.4
Name: jg-lint
Version: 0.5.0
License-File: LICENSE
Summary: Extensible Python linter written in Rust
License-Expression: MIT
Requires-Python: >=3.14
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

# jg-lint

Extensible Python linter with a Rust core.

## Installation

Requires Python 3.14+.

```bash
uv add jg-lint
```

or with pip:

```bash
pip install jg-lint
```

or with Poetry:

```bash
poetry add jg-lint
```

### Development install

To build from source, you need [Maturin](https://www.maturin.rs/) and a Rust toolchain:

```bash
git clone https://github.com/giacosoft/jg-lint.git
cd jg-lint
uv sync
uv run maturin develop
```

## Usage

```bash
jg-lint check <paths...>
```

Lint one or more files or directories:

```bash
jg-lint check src/
jg-lint check src/ lib/ main.py
jg-lint check --config path/to/project src/
```

The `--config` flag points to the directory containing `pyproject.toml` (defaults to `.`).

### Built-in rules

| Code  | Description                                         |
|-------|-----------------------------------------------------|
| JG001 | Imports must be at module top level                 |
| JG002 | `if` statements are not allowed in test functions (any function or method whose name starts with `test_`) |

Built-in rules are **opt-in**: they only run when you list them in `select` (see [Configuration](#configuration)). For example, `select = ["JG*"]` enables every built-in rule, and `select = ["JG001"]` enables only `JG001`.

Output looks like:

```
src/app.py:12:1: MY001 TODO comments should be tracked as issues
src/app.py:45:1: MY001 TODO comments should be tracked as issues

Found 2 violation(s)
```

Exit code is `1` if any violations are found, `0` otherwise.

### Inline suppression

Some rules opt in to per-line suppression via `# noqa`:

```python
x = something()  # noqa: MY001
y = another()  # noqa: MY001, MY002
```

By design, `# noqa` is **not** honored by default — rules must explicitly set `allow_noqa = True` to allow it. This keeps suppression intentional and reserved for cases where the rule author has decided it's acceptable.

## Configuration

All configuration lives in `pyproject.toml` under `[tool.jg-lint]`:

```toml
[tool.jg-lint]
select = ["MY001", "JG*"]    # rules to enable (see "Selecting rules" below)
ignore = ["MY002"]           # skip these rules globally
exclude = [".venv/**", "build/**"]
rules_path = "./rules"       # directory containing your custom rule modules

[tool.jg-lint.per-file-ignores]
"tests/**" = ["MY001"]       # skip MY001 in test files
```

### Selecting rules

Each entry in `select`, `ignore`, and `per-file-ignores` is matched against a rule's code with the following pattern semantics:

- `"*"` — matches every code.
- `"JG*"` — matches every code starting with `JG` (e.g. `JG001`, `JG042`).
- `"JG001"` — exact match.

Defaults when `select` is empty:

- **Built-in rules** (e.g. `JG001`, `JG002`) are **not** enabled. List them explicitly with `select = ["JG*"]` or by code.
- **Plugin rules** from `rules_path` are enabled.

## Writing custom rules

### 1. Create a rule module

Put it inside the directory you've configured as `rules_path` (e.g. `./rules/no_todo.py` or `./rules/no_todo/__init__.py`).

```python
from jg_linter import Rule, Violation


class NoTodoComments(Rule):
    code = "MY001"
    message = "TODO comments should be tracked as issues"

    def check(self, file_path: str, content: str) -> list[Violation]:
        violations = []
        for i, line in enumerate(content.splitlines(), 1):
            if "# TODO" in line:
                violations.append(
                    Violation(file_path, i, 1, self.code, self.message)
                )
        return violations


def get_rules() -> list[Rule]:
    return [NoTodoComments()]
```

Each rule needs:

- `code` -- unique identifier (e.g. `MY001`)
- `message` -- human-readable description
- `check(file_path, content)` -- returns a list of `Violation` objects

Set `test_only = True` on a rule to run it only against test files (files named `test_*.py`, `*_test.py`, or inside a `tests/` directory).

Set `allow_noqa = True` on a rule to allow inline `# noqa: CODE` suppression. Without this, the rule cannot be silenced per-line and can only be turned off project-wide via `ignore` or `per-file-ignores`.

### 2. Point `jg-lint` at the rules folder

```toml
[tool.jg-lint]
rules_path = "./rules"
```

Every top-level `.py` file and package inside `rules_path` is imported automatically; any module exposing `get_rules()` contributes rules. Files and folders starting with `_` are skipped. The rules directory is prepended to `sys.path` during loading, so no `PYTHONPATH` setup is needed.

### 3. Run

```bash
jg-lint check src/
```

```
src/app.py:3:1: MY001 TODO comments should be tracked as issues
src/utils.py:17:1: MY001 TODO comments should be tracked as issues

Found 2 violation(s)
```

## Contributing

You need a Rust toolchain (stable), Python 3.14+, and [`uv`](https://docs.astral.sh/uv/).

```bash
git clone https://github.com/giacosoft/jg-lint.git
cd jg-lint
uv sync
uv run maturin develop
```

### Tests

```bash
cargo test                 # Rust unit tests
uv run pytest              # Python tests (rebuild with `maturin develop` if Rust changed)
```

### Linting and formatting

Before opening a PR, run the same checks CI does:

```bash
# Rust
cargo fmt --all -- --check          # formatting
cargo clippy --all-targets -- -D warnings   # lints (fails on any warning)
cargo audit                          # CVEs in dependencies (install with `cargo install cargo-audit`)

# Python
uv run ruff check .                  # lint
uv run ruff format --check .         # format check
```

To auto-fix:

```bash
cargo fmt --all
cargo clippy --fix --all-targets
uv run ruff check --fix .
uv run ruff format .
```

## License

MIT — see [LICENSE](LICENSE).

