Skip to content
Draft
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
5 changes: 5 additions & 0 deletions marimo/_lint/rules/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

from marimo._lint.rules.base import LintRule
from marimo._lint.rules.runtime.branch_expression import BranchExpressionRule
from marimo._lint.rules.runtime.private_state_capture import (
PrivateStateCaptureRule,
)
from marimo._lint.rules.runtime.reusable_definition_order import (
ReusableDefinitionOrderRule,
)
Expand All @@ -12,11 +15,13 @@
"MR001": SelfImportRule,
"MR002": BranchExpressionRule,
"MR003": ReusableDefinitionOrderRule,
"MR004": PrivateStateCaptureRule,
}

__all__ = [
"RUNTIME_RULE_CODES",
"BranchExpressionRule",
"PrivateStateCaptureRule",
"ReusableDefinitionOrderRule",
"SelfImportRule",
]
98 changes: 98 additions & 0 deletions marimo/_lint/rules/runtime/private_state_capture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

from typing import TYPE_CHECKING

from marimo._ast.variables import is_mangled_local, unmangle_local
from marimo._lint.diagnostic import Diagnostic, Severity
from marimo._lint.rules.breaking.graph import GraphRule

if TYPE_CHECKING:
from marimo._lint.context import RuleContext
from marimo._runtime.dataflow import DirectedGraph


class PrivateStateCaptureRule(GraphRule):
"""MR004: Top-level functions/classes capture private cell-local state.

This rule warns when a top-level function or class closes over a private
variable from the same cell. Private variables are intentionally excluded
from the notebook dependency graph, so mutating them from a reusable
definition can make behavior depend on execution order.

## Why is this bad?

A function that closes over private cell state can appear pure while still
hiding mutable state. When that function is called from different cells,
the observed result may depend on which cells ran first.

## Example

```python
_cache = {}


def square(x):
if x in _cache:
return _cache[x] + 1
_cache[x] = x * x
return _cache[x]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this is fixated on the specific report but not really addressing the general issue. I think that this in a single cell is actually fine.

Title should probably be: MR004: Cross cell variable mutation.

Here are things that the rule should catch:


a = []
b = set()
a.append(value) # I think we can hardcode for known types
b.add(1234)
a[0] = 123 # __setindex__

Or mutation cross cell from closures

   cache = {} # doesn't have to be private !
    def square(x):
        if x in cache:
            return cache[x] + 1
        cache[x] = x * x
        return cache[x]
square(1) # the error would be here

```
"""

code = "MR004"
name = "private-state-capture"
description = "Top-level definitions capture private cell-local state"
severity = Severity.RUNTIME
fixable = False

async def _validate_graph(
self, graph: DirectedGraph, ctx: RuleContext
) -> None:
for cell_id, cell in graph.cells.items():
for (
variable_name,
variable_data_list,
) in cell.variable_data.items():
for variable_data in variable_data_list:
if variable_data.kind not in {"function", "class"}:
continue

private_refs = sorted(
{
unmangle_local(ref, cell_id).name
for ref in variable_data.required_refs
if is_mangled_local(ref, cell_id)
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Exclude self-references from private_refs; recursive private definitions are currently misreported as capturing private state.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At marimo/_lint/rules/runtime/private_state_capture.py, line 61:

<comment>Exclude self-references from `private_refs`; recursive private definitions are currently misreported as capturing private state.</comment>

<file context>
@@ -0,0 +1,90 @@
+                        {
+                            unmangle_local(ref, cell_id).name
+                            for ref in variable_data.required_refs
+                            if is_mangled_local(ref, cell_id)
+                        }
+                    )
</file context>
Fix with Cubic

}
)
if not private_refs:
continue

line, column = self._get_variable_line_info(
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Use unmangled definition names for diagnostics; current output can leak internal mangled names and degrade line/column accuracy for private defs.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At marimo/_lint/rules/runtime/private_state_capture.py, line 67:

<comment>Use unmangled definition names for diagnostics; current output can leak internal mangled names and degrade line/column accuracy for private defs.</comment>

<file context>
@@ -0,0 +1,90 @@
+                    if not private_refs:
+                        continue
+
+                    line, column = self._get_variable_line_info(
+                        cell_id, variable_name, ctx
+                    )
</file context>
Fix with Cubic

cell_id, variable_name, ctx
)
kind = (
"Function"
if variable_data.kind == "function"
else "Class"
)
refs = ", ".join(f"`{ref}`" for ref in private_refs)
await ctx.add_diagnostic(
Diagnostic(
message=(
f"{kind} '{variable_name}' captures private "
f"cell-local variable(s): {refs}"
),
cell_id=[cell_id],
line=line,
column=column,
code=self.code,
name=self.name,
severity=self.severity,
fixable=self.fixable,
fix=(
"Use explicit cell outputs for shared state, or "
"use @mo.cache for memoized values."
),
)
)
11 changes: 9 additions & 2 deletions marimo/_plugins/ui/_core/ui_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ class UIElement(Html, Generic[S, T]):

**Attributes.**

- value: The value of the `UIElement`.
- value: The current value of the `UIElement`. Read-only; it reflects
frontend state and can't be assigned directly. If you need to
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you revert these doc changes?

imperatively drive UI state, use `mo.state()`.

**Methods.**

Expand Down Expand Up @@ -310,7 +312,12 @@ def _register_as_view(self, parent: UIElement[Any, Any], key: str) -> None:

@property
def value(self) -> T:
"""The element's current value."""
"""The element's current value.

Read-only; marimo updates it when the UI element changes in the
frontend. If you need to imperatively drive UI state, use
`mo.state()` instead of assigning to this property.
"""
if self._ctx is None:
return self._value

Expand Down
44 changes: 44 additions & 0 deletions tests/_lint/test_private_state_capture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

from pathlib import Path

from marimo._lint import run_check


def test_private_state_capture_warns_on_captured_cache(tmp_path) -> None:
notebook_file = Path(tmp_path) / "cached.py"
notebook_file.write_text(
"""import marimo

__generated_with = "0.23.2"
app = marimo.App()


@app.cell
def __():
_cache = dict()

def square(x):
if x in _cache:
return _cache[x] + 1
res = x * x
_cache[x] = res
return res
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't an issue if it's called within the cell


return (square,)


if __name__ == "__main__":
app.run()
"""
)

linter = run_check((str(notebook_file),), formatter="json")
result = linter.get_json_result()

assert result["summary"]["files_with_issues"] == 1
issue = result["issues"][0]
assert issue["code"] == "MR004"
assert issue["severity"] == "runtime"
assert "private cell-local variable" in issue["message"]
assert "square" in issue["message"]
Loading