Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion development_docs/adding_lint_rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ This guide explains how to add new lint rules to marimo's linting system.

## Overview

marimo's lint system helps users write better, more reliable notebooks by detecting various issues that could prevent notebooks from running correctly. The system is organized around three severity levels:
marimo's lint system helps users write better, more reliable notebooks by detecting various issues that could prevent notebooks from running correctly. The system is organized around four severity levels:

- **Breaking (MB)**: Errors that prevent notebook execution
- **Runtime (MR)**: Issues that may cause runtime problems
- **Formatting (MF)**: Style and formatting issues
- **WASM (MW)**: Compatibility issues for WASM/Pyodide notebooks *(off by default)*

## Rule Code Assignment

Expand All @@ -19,6 +20,7 @@ Rule codes follow a specific pattern: `M[severity][number]`
- **MB001-MB099**: Breaking rules
- **MR001-MR099**: Runtime rules
- **MF001-MF099**: Formatting rules
- **MW001-MW099**: WASM compatibility rules *(off by default)*

### Assigning New Codes

Expand All @@ -45,6 +47,7 @@ Create your rule in the appropriate directory:
- Breaking rules: `marimo/_lint/rules/breaking/`
- Runtime rules: `marimo/_lint/rules/runtime/`
- Formatting rules: `marimo/_lint/rules/formatting/`
- WASM rules: `marimo/_lint/rules/wasm/`

**Template for a new rule**:

Expand Down Expand Up @@ -315,6 +318,24 @@ the `--unsafe-fixes` flag. To do this, implement an `async def
apply_unsafe_fixes(self, notebook, diagnostics) -> Notebook` method in your
rule class, and inherit from `UnsafeFixRule` instead of `LintRule`.

## Default vs Opt-in Rules

Rules in `RULE_CODES` are the full set of all rules. Rules in `DEFAULT_RULE_CODES` are those enabled when no `--select` is specified.

To make a category **off by default** (like WASM rules), include it in `RULE_CODES` but exclude it from `DEFAULT_RULE_CODES` in `marimo/_lint/rules/__init__.py`:

```python
# Rules enabled by default (excludes opt-in categories like WASM).
DEFAULT_RULE_CODES: dict[str, type[LintRule]] = (
BREAKING_RULE_CODES | RUNTIME_RULE_CODES | FORMATTING_RULE_CODES
)

# All known rules (including opt-in). Used when --select is provided.
RULE_CODES: dict[str, type[LintRule]] = DEFAULT_RULE_CODES | WASM_RULE_CODES
```

Users opt in via `marimo check --select MW` or `--select ALL`. The `resolve_rules()` function in `rule_selector.py` uses `DEFAULT_RULE_CODES` when no `select` is specified, and `RULE_CODES` when it is.

## Common Patterns

### Checking All Cells
Expand Down
12 changes: 11 additions & 1 deletion docs/guides/lint_rules/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ marimo check --fix .

## Rule Categories

marimo's lint rules are organized into three main categories based on their severity:
marimo's lint rules are organized into categories based on their severity:

### 🚨 Breaking Rules

Expand Down Expand Up @@ -60,6 +60,16 @@ These are style and formatting issues.
| [MF006](rules/misc_log_capture.md) | misc-log-capture | Miscellaneous log messages during processing | ❌ |
| [MF007](rules/markdown_indentation.md) | markdown-indentation | Markdown cells in `mo.md()` should be properly indented. | 🛠️ |

### 🌐 WASM Rules

These issues affect WASM/Pyodide compatibility (off by default).

| Code | Name | Description | Fixable |
|------|------|-------------|----------|
| [MW001](rules/incompatible_import.md) | incompatible-import | Importing a module unavailable in WASM/Pyodide | ❌ |
| [MW002](rules/unsafe_system_call.md) | unsafe-system-call | System call that fails in WASM/Pyodide | ❌ |
| [MW003](rules/incompatible_package.md) | incompatible-package | Package with native extensions not available in Pyodide | ❌ |

## Legend

- 🛠️ = Automatically fixable with `marimo check --fix`
Expand Down
39 changes: 39 additions & 0 deletions docs/guides/lint_rules/rules/incompatible_import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# MW001: incompatible-import

🌐 **WASM** ❌ Not Fixable

MW001: Importing modules unavailable in WASM/Pyodide.

## What it does

Checks each cell's imports against a blocklist of stdlib modules that
either don't exist in Pyodide or are stubs that fail at runtime.

## Why is this bad?

WASM notebooks run in the browser via Pyodide, which cannot support
modules that depend on OS-level process control, terminal I/O, or
native GUI toolkits. Importing these modules will raise ImportError
or produce broken stubs.

## Examples

**Problematic:**
```python
import subprocess

result = subprocess.run(["ls"])
```

**Problematic:**
```python
from multiprocessing import Pool
```

**Solution:**
Remove or replace the import with a WASM-compatible alternative.

## References

- https://pyodide.org/en/stable/usage/wasm-constraints.html

40 changes: 40 additions & 0 deletions docs/guides/lint_rules/rules/incompatible_package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# MW003: incompatible-package

🌐 **WASM** ❌ Not Fixable

MW003: Packages in the dependency tree incompatible with WASM.

## What it does

Reads the notebook's PEP 723 ``dependencies``, walks their transitive
dependency tree via installed metadata, then queries PyPI's JSON API
to check whether each package has a ``py3-none-any`` or emscripten
wheel available. Packages only in pyodide-lock.json are also accepted.

## Why is this bad?

Pyodide can only install pure-Python wheels via micropip, or packages
that are pre-built in the Pyodide distribution. Packages with only
platform-specific native wheels will fail to install in the browser.

## Examples

**Problematic:**
```python
import jax # jaxlib (transitive dep) has only native wheels
```

**Not flagged:**
```python
import numpy # Native, but pre-built in Pyodide
```

**Not flagged:**
```python
import requests # Pure Python wheel on PyPI
```

## References

- https://pyodide.org/en/stable/usage/packages-in-pyodide.html

40 changes: 40 additions & 0 deletions docs/guides/lint_rules/rules/unsafe_system_call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# MW002: unsafe-system-call

🌐 **WASM** ❌ Not Fixable

MW002: System calls that fail in WASM/Pyodide.

## What it does

Walks the AST of each cell looking for calls to functions like
``os.system()``, ``os.fork()``, ``signal.signal()``, and
``breakpoint()`` that have no meaningful implementation in WASM.

## Why is this bad?

These functions depend on OS features (process spawning, signal
handling, debugger attachment) that don't exist in a browser
environment. They will raise ``OSError``, ``NotImplementedError``,
or hang silently.

## Examples

**Problematic:**
```python
import os

os.system("ls")
```

**Problematic:**
```python
breakpoint()
```

**Solution:**
Remove or guard these calls behind a WASM detection check.

## References

- https://pyodide.org/en/stable/usage/wasm-constraints.html

10 changes: 10 additions & 0 deletions marimo/_cli/export/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,16 @@ def html_wasm(
if execute:
cli_args = parse_args(args)

# Run WASM compatibility lint pass. When bootstrapped, this runs
# inside the uv sandbox so MW003 introspects the resolved env.
from marimo._lint import run_check

run_check(
(name,),
lint_config={"select": ["MW"]},
pipe=lambda msg: echo(msg, err=True),
)
Comment thread
dmadisetti marked this conversation as resolved.

def export_callback(file_path: MarimoPath) -> ExportResult:
return asyncio_run(
run_app_then_export_as_wasm(
Expand Down
1 change: 1 addition & 0 deletions marimo/_lint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
Severity.BREAKING: 0,
Severity.RUNTIME: 1,
Severity.FORMATTING: 2,
Severity.WASM: 3,
}


Expand Down
1 change: 1 addition & 0 deletions marimo/_lint/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Severity.BREAKING: 0,
Severity.RUNTIME: 1,
Severity.FORMATTING: 2,
Severity.WASM: 3,
}


Expand Down
1 change: 1 addition & 0 deletions marimo/_lint/diagnostic.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Severity(Enum):
FORMATTING = "formatting" # prefix: MF0000
RUNTIME = "runtime" # prefix: MR0000
BREAKING = "breaking" # prefix: MB0000
WASM = "wasm" # prefix: MW0000


def line_num(line: int) -> str:
Expand Down
4 changes: 2 additions & 2 deletions marimo/_lint/rule_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from marimo._lint.context import LintContext, RuleContext
from marimo._lint.diagnostic import Severity
from marimo._lint.rules import RULE_CODES
from marimo._lint.rules import DEFAULT_RULE_CODES
from marimo._schemas.serialization import NotebookSerialization

if TYPE_CHECKING:
Expand Down Expand Up @@ -154,5 +154,5 @@ def create_default(

rules = resolve_rules(lint_config)
else:
rules = [rule() for rule in RULE_CODES.values()]
rules = [rule() for rule in DEFAULT_RULE_CODES.values()]
return cls(rules, early_stopping)
9 changes: 6 additions & 3 deletions marimo/_lint/rule_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,16 @@ def resolve_rules(

all_rules = RULE_CODES

codes = set(all_rules.keys())
select = config.get("select")
ignore = config.get("ignore")

# Step 1: base set
# Step 1: base set — use only default rules unless explicitly selected
if select:
codes = {c for c in codes if _matches_any_prefix(c, select)}
codes = {c for c in all_rules if _matches_any_prefix(c, select)}
else:
from marimo._lint.rules import DEFAULT_RULE_CODES

codes = set(DEFAULT_RULE_CODES.keys()) & set(all_rules.keys())
Comment on lines +51 to +57

# Step 2: remove ignored
if ignore:
Expand Down
9 changes: 8 additions & 1 deletion marimo/_lint/rules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,21 @@
from marimo._lint.rules.breaking import BREAKING_RULE_CODES
from marimo._lint.rules.formatting import FORMATTING_RULE_CODES
from marimo._lint.rules.runtime import RUNTIME_RULE_CODES
from marimo._lint.rules.wasm import WASM_RULE_CODES

RULE_CODES: dict[str, type[LintRule]] = (
# Rules enabled by default (excludes opt-in categories like WASM).
DEFAULT_RULE_CODES: dict[str, type[LintRule]] = (
BREAKING_RULE_CODES | RUNTIME_RULE_CODES | FORMATTING_RULE_CODES
)

# All known rules (including opt-in). Used when --select is provided.
RULE_CODES: dict[str, type[LintRule]] = DEFAULT_RULE_CODES | WASM_RULE_CODES

__all__ = [
"BREAKING_RULE_CODES",
"DEFAULT_RULE_CODES",
"FORMATTING_RULE_CODES",
"RULE_CODES",
"RUNTIME_RULE_CODES",
"WASM_RULE_CODES",
]
24 changes: 24 additions & 0 deletions marimo/_lint/rules/wasm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

from marimo._lint.rules.base import LintRule
from marimo._lint.rules.wasm.incompatible_imports import (
IncompatibleImportsRule,
)
from marimo._lint.rules.wasm.incompatible_packages import (
IncompatiblePackagesRule,
)
from marimo._lint.rules.wasm.unsafe_system_calls import UnsafeSystemCallsRule

WASM_RULE_CODES: dict[str, type[LintRule]] = {
"MW001": IncompatibleImportsRule,
"MW002": UnsafeSystemCallsRule,
"MW003": IncompatiblePackagesRule,
}

__all__ = [
"WASM_RULE_CODES",
"IncompatibleImportsRule",
"IncompatiblePackagesRule",
"UnsafeSystemCallsRule",
]
Loading
Loading