Skip to content

[ssort] Implement statement sorting#22235

Open
JackAshwell11 wants to merge 13 commits intoastral-sh:mainfrom
JackAshwell11:jack/ssort
Open

[ssort] Implement statement sorting#22235
JackAshwell11 wants to merge 13 commits intoastral-sh:mainfrom
JackAshwell11:jack/ssort

Conversation

@JackAshwell11
Copy link

@JackAshwell11 JackAshwell11 commented Dec 28, 2025

Summary

Closes #3946 and #2436

  • Adds a new SS001 (unsorted statements) rule allowing a file to be automatically sorted based on the dependencies between its functions/methods/classes. This also comes with an extra linter option narrative_order allowing the statement ordering to be reversed meaning the calling functions are defined before the dependency functions.

Note that this PR intentionally avoids sorting statements inside classes such as the docstring/special attribute/lifecycle methods/etc which ssort implements (https://github.com/bwhmather/ssort?tab=readme-ov-file#output) as these fall under #2425 which needs further discussion.

Test Plan

  • Multiple Python files have been added to test the diagnostic reporting of the two rules in both dependency and narrative ordering mode.
  • Additional Rust unit tests have been added to test extra functionality not directly covered by the Python tests.

I've checked the ruff-ecosystem results and each change looks to be correct with this new rule (although there will be many rule violations).

@MichaReiser MichaReiser added the isort Related to import sorting label Dec 29, 2025
@ntBre ntBre self-requested a review December 29, 2025 14:19
@JackAshwell11 JackAshwell11 marked this pull request as ready for review December 29, 2025 17:35
Copy link
Contributor

@ntBre ntBre left a comment

Choose a reason for hiding this comment

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

Thanks! I need to do a much deeper review at some point, just a couple of passing thoughts while scrolling through to approve the workflows.

Copy link
Contributor

Choose a reason for hiding this comment

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

Wow that's a lot of new test files! Thanks for being thorough here, but is there any chance we could condense this down a bit? I haven't taken a look at the implementation yet, but something we often do is create outer functions or classes to contain test cases. For example, could we test the same sorting behavior if this file were like:

class C:
	def function():
	    return dependency()
	
	
	def dependency():
	    return True
	
	
	async def async_function():
	    return function()

and then another file could be:

class D:
    # another test case
    ...

or do they really need to be separate files?

Copy link
Author

Choose a reason for hiding this comment

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

Some of them could be compacted, but I'm hesitant to do this unless necessary as, since this rule rearranges files based on the function calls, I'm not sure how many side effects there will be if some of the tests are combined

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we already have a file for statement analysis in checkers/ast/analyze:

Could this check go there instead? Or maybe module.rs in the same directory.

Copy link
Author

Choose a reason for hiding this comment

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

Should be fixed in latest

/// def method_b(self): return self.method_a()
/// ```
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "v0.14.11")]
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
#[violation_metadata(preview_since = "v0.14.11")]
#[violation_metadata(preview_since = "0.14.11")]

We don't use a v in our version numbers anymore.

Copy link
Author

Choose a reason for hiding this comment

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

Should be fixed in latest

@codspeed-hq
Copy link

codspeed-hq bot commented Dec 29, 2025

Merging this PR will not alter performance

✅ 30 untouched benchmarks
⏩ 26 skipped benchmarks1


Comparing JackAshwell11:jack/ssort (2a78588) with main (57a1eb2)

Open in CodSpeed

Footnotes

  1. 26 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@astral-sh-bot
Copy link

astral-sh-bot bot commented Dec 29, 2025

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

ℹ️ ecosystem check detected linter changes. (+1313 -0 violations, +0 -0 fixes in 9 projects; 47 projects unchanged)

DisnakeDev/disnake (+84 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview

+ disnake/abc.py:3:1: SS001 [*] Statements are unsorted
+ disnake/activity.py:3:1: SS001 [*] Statements are unsorted
+ disnake/app_commands.py:3:1: SS001 [*] Statements are unsorted
+ disnake/asset.py:3:1: SS001 [*] Statements are unsorted
+ disnake/audit_logs.py:3:1: SS001 [*] Statements are unsorted
+ disnake/automod.py:3:1: SS001 [*] Statements are unsorted
+ disnake/channel.py:3:1: SS001 [*] Statements are unsorted
+ disnake/client.py:3:1: SS001 [*] Statements are unsorted
+ disnake/components.py:3:1: SS001 [*] Statements are unsorted
+ disnake/embeds.py:3:1: SS001 [*] Statements are unsorted
... 74 additional changes omitted for project

apache/airflow (+583 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview --select ALL

+ airflow-core/hatch_build.py:17:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api/common/mark_tasks.py:18:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/app.py:17:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py:18:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py:18:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/auth/tokens.py:17:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/common/exceptions.py:18:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/common/parameters.py:18:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/common/types.py:17:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/core_api/datamodels/hitl.py:17:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/core_api/services/public/common.py:18:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/core_api/services/ui/calendar.py:17:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/execution_api/app.py:18:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/execution_api/routes/assets.py:18:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/execution_api/routes/dag_runs.py:18:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:18:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/api_fastapi/gunicorn_app.py:17:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/cli/commands/api_server_command.py:17:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/cli/commands/cheat_sheet_command.py:17:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/cli/commands/connection_command.py:17:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/cli/commands/dag_command.py:18:1: SS001 [*] Statements are unsorted
+ airflow-core/src/airflow/cli/commands/pool_command.py:18:1: SS001 [*] Statements are unsorted
... 561 additional changes omitted for project

apache/superset (+190 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview --select ALL

+ RELEASING/changelog.py:16:1: SS001 [*] Statements are unsorted
+ docs/scripts/extract_custom_errors.py:18:1: SS001 [*] Statements are unsorted
+ scripts/check-env.py:19:1: SS001 [*] Statements are unsorted
+ scripts/extract_feature_flags.py:18:1: SS001 [*] Statements are unsorted
+ superset/app.py:17:1: SS001 [*] Statements are unsorted
+ superset/async_events/async_query_manager.py:17:1: SS001 [*] Statements are unsorted
+ superset/charts/schemas.py:18:1: SS001 [*] Statements are unsorted
+ superset/cli/test_db.py:18:1: SS001 [*] Statements are unsorted
+ superset/cli/viz_migrations.py:17:1: SS001 [*] Statements are unsorted
+ superset/commands/annotation_layer/annotation/create.py:17:1: SS001 [*] Statements are unsorted
... 180 additional changes omitted for project

bokeh/bokeh (+91 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview --select ALL

+ examples/output/jupyter/push_notebook/Numba Image Example.ipynb:cell 2:1:1: SS001 [*] Statements are unsorted
+ examples/server/app/duffing_oscillator.py:1:1: SS001 [*] Statements are unsorted
+ examples/server/app/server_auth/auth.py:1:1: SS001 [*] Statements are unsorted
+ examples/server/app/stocks/main.py:1:1: SS001 [*] Statements are unsorted
+ release/logger.py:7:1: SS001 [*] Statements are unsorted
+ setup.py:7:1: SS001 [*] Statements are unsorted
+ src/bokeh/application/application.py:7:1: SS001 [*] Statements are unsorted
+ src/bokeh/application/handlers/code.py:7:1: SS001 [*] Statements are unsorted
+ src/bokeh/application/handlers/code_runner.py:7:1: SS001 [*] Statements are unsorted
+ src/bokeh/client/connection.py:7:1: SS001 [*] Statements are unsorted
... 81 additional changes omitted for project

langchain-ai/langchain (+111 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview

+ libs/core/langchain_core/agents.py:1:1: SS001 [*] Statements are unsorted
+ libs/core/langchain_core/callbacks/base.py:1:1: SS001 [*] Statements are unsorted
+ libs/core/langchain_core/callbacks/file.py:1:1: SS001 [*] Statements are unsorted
+ libs/core/langchain_core/callbacks/manager.py:1:1: SS001 [*] Statements are unsorted
+ libs/core/langchain_core/indexing/api.py:1:1: SS001 [*] Statements are unsorted
+ libs/core/langchain_core/language_models/_utils.py:1:1: SS001 [*] Statements are unsorted
+ libs/core/langchain_core/language_models/chat_models.py:1:1: SS001 [*] Statements are unsorted
+ libs/core/langchain_core/language_models/llms.py:1:1: SS001 [*] Statements are unsorted
+ libs/core/langchain_core/load/_validation.py:1:1: SS001 [*] Statements are unsorted
+ libs/core/langchain_core/load/load.py:1:1: SS001 [*] Statements are unsorted
... 101 additional changes omitted for project

reflex-dev/reflex (+41 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview

+ reflex/app.py:1:1: SS001 [*] Statements are unsorted
+ reflex/compiler/compiler.py:1:1: SS001 [*] Statements are unsorted
+ reflex/compiler/templates.py:1:1: SS001 [*] Statements are unsorted
+ reflex/compiler/utils.py:1:1: SS001 [*] Statements are unsorted
+ reflex/components/component.py:1:1: SS001 [*] Statements are unsorted
+ reflex/components/core/upload.py:1:1: SS001 [*] Statements are unsorted
+ reflex/components/datadisplay/shiki_code_block.py:1:1: SS001 [*] Statements are unsorted
+ reflex/components/el/elements/forms.py:1:1: SS001 [*] Statements are unsorted
+ reflex/components/markdown/markdown.py:1:1: SS001 [*] Statements are unsorted
+ reflex/components/props.py:1:1: SS001 [*] Statements are unsorted
... 31 additional changes omitted for project

scikit-build/scikit-build-core (+13 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview

+ src/scikit_build_core/build/__main__.py:1:1: SS001 [*] Statements are unsorted
+ src/scikit_build_core/build/_file_processor.py:1:1: SS001 [*] Statements are unsorted
+ src/scikit_build_core/build/_wheelfile.py:1:1: SS001 [*] Statements are unsorted
+ src/scikit_build_core/build/wheel.py:1:1: SS001 [*] Statements are unsorted
+ src/scikit_build_core/builder/wheel_tag.py:1:1: SS001 [*] Statements are unsorted
+ src/scikit_build_core/cmake.py:1:1: SS001 [*] Statements are unsorted
+ src/scikit_build_core/format.py:1:1: SS001 [*] Statements are unsorted
+ src/scikit_build_core/hatch/plugin.py:3:1: SS001 [*] Statements are unsorted
+ src/scikit_build_core/resources/_editable_redirect.py:1:1: SS001 [*] Statements are unsorted
+ src/scikit_build_core/settings/auto_requires.py:1:1: SS001 [*] Statements are unsorted
... 3 additional changes omitted for project

zulip/zulip (+191 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview --select ALL

+ analytics/lib/counts.py:1:1: SS001 [*] Statements are unsorted
+ analytics/management/commands/check_analytics_state.py:1:1: SS001 [*] Statements are unsorted
+ analytics/management/commands/update_analytics_counts.py:1:1: SS001 [*] Statements are unsorted
+ analytics/views/stats.py:1:1: SS001 [*] Statements are unsorted
+ confirmation/models.py:3:1: SS001 [*] Statements are unsorted
+ corporate/lib/stripe.py:1:1: SS001 [*] Statements are unsorted
+ corporate/models/plans.py:1:1: SS001 [*] Statements are unsorted
+ corporate/models/stripe_state.py:1:1: SS001 [*] Statements are unsorted
+ corporate/tests/test_stripe.py:1:1: SS001 [*] Statements are unsorted
+ scripts/lib/puppet_cache.py:1:1: SS001 [*] Statements are unsorted
... 181 additional changes omitted for project

... Truncated remaining completed project reports due to GitHub comment length restrictions

Changes by rule (1 rules affected)

code total + violation - violation + fix - fix
SS001 1313 1313 0 0 0

Comment on lines +66 to +70
if let Expr::Name(value) = &*attr.value {
if value.id.as_str() == "self" {
self.names.insert(&attr.attr.id);
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Don't we end up visiting attr.value twice? Or did you want to return early when seeing an attribute expression?

Copy link
Author

Choose a reason for hiding this comment

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

This is unintentional, I'll fix it. Thanks 👍

///
/// ## Arguments
/// * `expr` - The expression node to visit.
fn visit_expr(&mut self, expr: &'a Expr) {
Copy link
Member

Choose a reason for hiding this comment

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

Does this visitor correctly handle scopes? What if two functions define the same variables but they're nested within each other? What about type vars?

Copy link
Author

Choose a reason for hiding this comment

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

From my understanding, ssort is intentionally scope-agnostic as it only tracks text references, so as long as the text reference matches another function, this should work.

If two functions define the same variables but they're nested then this shouldn't handle they would be considered a separate suite (although I'd argue that is a problem with your code as you shouldn't name two things with the same name).

I'm not sure if type variables will work as I don't think visit_expr() handles annotations, but I could be wrong

Copy link
Member

Choose a reason for hiding this comment

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

How does ssort ensure that type annotations don't break (specificially in project that don't use from __future__ import annotations)?

You probably want to adjust your visitor so that it doesn't traverse into nested classes or functions.

@JackAshwell11 JackAshwell11 force-pushed the jack/ssort branch 2 times, most recently from f3409a9 to 9ba4467 Compare December 30, 2025 18:25
@ntBre
Copy link
Contributor

ntBre commented Jan 1, 2026

Thank you again for all of your work here! It's going to take me some time to digest this and review, but I have it on my todo list for the month.

Happy new year!

…d resolving a function's dependencies with basic autofix support
…ecomes a `Node` allowing top-level statements, which aren't functions or classes, to impact the dependency ordering

This also makes the replacement string construction much easier as we can emit each node in order as it is fully sorted at that point
…rules

Added comprehensive Rust tests for functionality not directly covered by the Python tests
- Optimised `DependencyVisitor::visit_expr()` so it doesn't slow Ruff down as much.
- Removed some problematic Python test files.
- Fixed a bug where `attr.value` is visited twice.
- Fixed a bug where type annotations would cause sorting to break.
- Fixed all clippy warnings.
@ntBre ntBre changed the title [ssort] Implement statement reording [ssort] Implement statement sorting Feb 25, 2026
@ntBre ntBre added rule Implementing or modifying a lint rule preview Related to preview mode features labels Feb 25, 2026
Copy link
Contributor

@ntBre ntBre left a comment

Choose a reason for hiding this comment

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

Thanks for your work here and for your patience! I still need to wrap my head fully around the graph algorithm, but I did a deeper dive today and came up with some suggestions for simplification. In particular, I think if we can narrow down the (very thorough!) tests and restructure them so that the important features are more evident, it will be a bit easier to review the logical aspects of the PR.

#[derive(Debug, Clone, CacheKey, Default)]
pub struct Settings {
/// Whether to sort statements in narrative order (definitions before usages).
pub narrative_order: bool,
Copy link
Contributor

Choose a reason for hiding this comment

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

I think I would go ahead and introduce a custom enum here. It sounds like in addition to narrative order and newspaper order there may be some interest in a django order in the future, and I think something like order = "narrative" or order = "newspaper" will read better for now anyway.

use crate::settings::LinterSettings;
use crate::test::test_path;

#[test_case(Path::new("async_function.py"), false)]
Copy link
Contributor

Choose a reason for hiding this comment

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

I might be the odd one out here, but I typically prefer all of the files to have names like RULECODE_0.py. It's slightly less explanatory in the filename but also makes them easier to find when working on the rule later, in my experience.

/// return self.method_a()
/// ```
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.14.11")]
Copy link
Contributor

Choose a reason for hiding this comment

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

We introduced this lately, but you can now use this to keep this updated automatically:

Suggested change
#[violation_metadata(preview_since = "0.14.11")]
#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")]

(Flake8Logging, "015") => rules::flake8_logging::rules::RootLoggerCall,

// ssort
(SSort, "001") => rules::ssort::rules::UnsortedStatements,
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd be curious to get other thoughts on this, but I'd probably just make this a RUF rule. We're hoping to move away from having all of these separate linters before long anyway (#1774), and I don't think it's worth adding a new linter prefix for one rule.

#[test_case(Path::new("top_level_statement.py"), true)]
#[test_case(Path::new("type_alias.py"), false)]
#[test_case(Path::new("type_alias.py"), true)]
fn default(path: &Path, narrative_order: bool) -> Result<()> {
Copy link
Contributor

Choose a reason for hiding this comment

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

An Order type will also read a bit more clearly here than plain bools anyway.

///
/// ## Returns
/// A vector of nodes representing the statements in the suite.
pub(super) fn nodes_from_suite(suite: &'_ [Stmt]) -> Vec<Node<'_>> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
pub(super) fn nodes_from_suite(suite: &'_ [Stmt]) -> Vec<Node<'_>> {
pub(super) fn nodes_from_suite(suite: &[Stmt]) -> Vec<Node> {

Not sure if it will let you elide the second one, but the first one should definitely be okay.

index,
name,
stmt,
movable: name.is_some(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to track this field separately if it's just name.is_some()?

Comment on lines +398 to +410
/// Test that `CycleError` displays correctly.
#[test]
fn cycle_error_display() {
let err = CycleError {
cycle: vec![0, 1, 2],
};
let display = format!("{err}");

assert!(display.contains("Cycle detected"));
assert!(display.contains('0'));
assert!(display.contains('1'));
assert!(display.contains('2'));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This really seems like overkill to me. Do we render this to users anywhere?

Comment on lines +412 to +417
/// Test that `CycleError` implements `std::error::Error`.
#[test]
fn cycle_error_is_error() {
let err = CycleError { cycle: vec![0] };
let _: &dyn std::error::Error = &err;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This also seems unnecessary. In fact, it seems that we only use the Error implementation in tests? I would probably just drop the Error and Display impls entirely, along with these tests.

Comment on lines +422 to +431
let source = r#"
def foo():
return 1

def bar():
return 2
"#;
let parsed = parse_module(source)?;
let nodes = nodes_from_suite(parsed.suite());
let mut visitor = DependencyVisitor::new(&nodes);
Copy link
Contributor

Choose a reason for hiding this comment

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

There seems to be a lot of common code here again. Can we factor out a test helper?

@ntBre
Copy link
Contributor

ntBre commented Feb 25, 2026

Oh I also resolved the conflicts and got the tests passing while I was trying out the code locally, so I pushed that up too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

isort Related to import sorting preview Related to preview mode features rule Implementing or modifying a lint rule

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement ssort

3 participants