aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-11 19:45:17 +0900
committernsfisis <nsfisis@gmail.com>2026-05-11 19:45:17 +0900
commit24cc697a9cd0dcac854359d65b8265f02f483b72 (patch)
treec9693dbf3136d840157609161a3a5695828e853b
parent2aceeb116150b6d6e6d3f371c2af509902ceafea (diff)
downloadphp-mozart-24cc697a9cd0dcac854359d65b8265f02f483b72.tar.gz
php-mozart-24cc697a9cd0dcac854359d65b8265f02f483b72.tar.zst
php-mozart-24cc697a9cd0dcac854359d65b8265f02f483b72.zip
chore(lint): add Ruby linter scripts and apply rules
Adds scripts/lint with linters for mod.rs naming, contiguous use blocks, use-as aliasing, sorted Cargo dependencies, std::collections maps, and workspace dependency requirements. Renames mod.rs files, reorders use statements, drops unnecessary import aliases, and sorts Cargo.toml entries to satisfy the new rules.
-rw-r--r--crates/mozart-console-macros/src/codegen.rs3
-rw-r--r--crates/mozart-core/src/config.rs3
-rw-r--r--crates/mozart-core/src/config_source.rs3
-rw-r--r--crates/mozart-core/src/config_validator.rs16
-rw-r--r--crates/mozart-core/src/installer.rs (renamed from crates/mozart-core/src/installer/mod.rs)0
-rw-r--r--crates/mozart-core/src/package/archiver/archive_manager.rs3
-rw-r--r--crates/mozart-core/src/repository/downloader.rs2
-rw-r--r--crates/mozart-core/src/repository/installer_executor.rs (renamed from crates/mozart-core/src/repository/installer_executor/mod.rs)3
-rw-r--r--crates/mozart-core/src/repository/installer_executor/trace_recorder.rs3
-rw-r--r--crates/mozart-core/src/repository/path_repository.rs13
-rw-r--r--crates/mozart-core/src/repository/repository.rs (renamed from crates/mozart-core/src/repository/repository/mod.rs)3
-rw-r--r--crates/mozart-core/src/vcs/process.rs3
-rw-r--r--crates/mozart-core/src/vcs/util.rs (renamed from crates/mozart-core/src/vcs/util/mod.rs)0
-rw-r--r--crates/mozart-test-harness/src/pool_builder_parser.rs3
-rw-r--r--crates/mozart-test-harness/src/runner.rs3
-rw-r--r--crates/mozart/Cargo.toml2
-rw-r--r--crates/mozart/src/commands/audit.rs3
-rw-r--r--crates/mozart/src/commands/base_config.rs3
-rw-r--r--crates/mozart/src/commands/create_project.rs3
-rw-r--r--crates/mozart/src/commands/repository.rs5
-rw-r--r--crates/mozart/tests/installer.rs5
-rwxr-xr-xscripts/lint27
-rw-r--r--scripts/linters/cargo_workspace_dependencies.rb48
-rw-r--r--scripts/linters/contiguous_use_block.rb80
-rw-r--r--scripts/linters/no_decorative_section_comment.rb34
-rwxr-xr-xscripts/linters/no_mod_rs.rb14
-rw-r--r--scripts/linters/no_std_collections_maps.rb48
-rw-r--r--scripts/linters/no_use_as_alias.rb51
-rw-r--r--scripts/linters/sorted_dependencies.rb50
29 files changed, 384 insertions, 50 deletions
diff --git a/crates/mozart-console-macros/src/codegen.rs b/crates/mozart-console-macros/src/codegen.rs
index 8601e07..c6c0f83 100644
--- a/crates/mozart-console-macros/src/codegen.rs
+++ b/crates/mozart-console-macros/src/codegen.rs
@@ -1,10 +1,9 @@
+use crate::parser::Segment;
use proc_macro2::TokenStream;
use quote::quote;
use syn::Expr;
use syn::punctuated::Punctuated;
-use crate::parser::Segment;
-
/// Returns true if the string contains any format placeholders (`{}`, `{name}`, `{0}`, `{:<10}`, etc.)
/// but not escaped braces `{{` or `}}`.
fn has_placeholders(s: &str) -> bool {
diff --git a/crates/mozart-core/src/config.rs b/crates/mozart-core/src/config.rs
index 58d1d17..1fbbb41 100644
--- a/crates/mozart-core/src/config.rs
+++ b/crates/mozart-core/src/config.rs
@@ -5,12 +5,11 @@
//! known properties. Unknown properties are captured in the `extra` map so
//! that round-tripping through serde is lossless.
+use crate::composer::composer_home;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
-use crate::composer::composer_home;
-
/// Parse a size string like "300MiB", "1GB", "512k", or a plain integer string
/// into a byte count. Mirrors Composer's `Config::get('cache-files-maxsize')`.
fn parse_size_bytes(s: &str) -> Option<u64> {
diff --git a/crates/mozart-core/src/config_source.rs b/crates/mozart-core/src/config_source.rs
index 984007a..42d2d6f 100644
--- a/crates/mozart-core/src/config_source.rs
+++ b/crates/mozart-core/src/config_source.rs
@@ -1,6 +1,5 @@
-use std::path::{Path, PathBuf};
-
use anyhow::anyhow;
+use std::path::{Path, PathBuf};
pub struct JsonConfigSource {
path: PathBuf,
diff --git a/crates/mozart-core/src/config_validator.rs b/crates/mozart-core/src/config_validator.rs
index dbed651..85cc538 100644
--- a/crates/mozart-core/src/config_validator.rs
+++ b/crates/mozart-core/src/config_validator.rs
@@ -5,13 +5,11 @@
//! Composer's: `ValidateCommand` and `DiagnoseCommand` each `new
//! ConfigValidator(...)`; neither depends on the other.
+use crate::validation;
+use regex::Regex;
use std::collections::HashSet;
use std::sync::LazyLock;
-use regex::Regex;
-
-use crate::validation as v;
-
static DEPRECATED_GPL_OR_LATER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^[AL]?GPL-[123](\.[01])?\+$").unwrap());
@@ -113,7 +111,7 @@ fn check_name(obj: &serde_json::Map<String, serde_json::Value>, result: &mut Val
if name.chars().any(|c| c.is_ascii_uppercase()) {
let suggested = name
.split('/')
- .map(v::sanitize_package_name_component)
+ .map(validation::sanitize_package_name_component)
.collect::<Vec<_>>()
.join("/");
result.publish_errors.push(format!(
@@ -122,7 +120,7 @@ fn check_name(obj: &serde_json::Map<String, serde_json::Value>, result: &mut Val
));
}
- if !name.is_empty() && !v::validate_package_name(name) && !name.contains('/') {
+ if !name.is_empty() && !validation::validate_package_name(name) && !name.contains('/') {
result.errors.push(format!(
"The name \"{name}\" is invalid, it should be in the format \"vendor/package\"."
));
@@ -224,11 +222,11 @@ fn check_license(obj: &serde_json::Map<String, serde_json::Value>, result: &mut
continue;
}
let to_validate = license.replace("proprietary", "MIT");
- if v::validate_license(&to_validate) {
+ if validation::validate_license(&to_validate) {
continue;
}
let quoted = serde_json::to_string(license).unwrap_or_else(|_| format!("\"{license}\""));
- if v::validate_license(to_validate.trim()) {
+ if validation::validate_license(to_validate.trim()) {
result.warnings.push(format!(
"License {quoted} must not contain extra spaces, make sure to trim it."
));
@@ -461,7 +459,7 @@ fn check_minimum_stability(
result: &mut ValidationResult,
) {
if let Some(stability) = obj.get("minimum-stability").and_then(|v| v.as_str())
- && !v::validate_stability(stability)
+ && !validation::validate_stability(stability)
{
result.errors.push(format!(
"The minimum-stability \"{stability}\" is invalid. \
diff --git a/crates/mozart-core/src/installer/mod.rs b/crates/mozart-core/src/installer.rs
index 8572627..8572627 100644
--- a/crates/mozart-core/src/installer/mod.rs
+++ b/crates/mozart-core/src/installer.rs
diff --git a/crates/mozart-core/src/package/archiver/archive_manager.rs b/crates/mozart-core/src/package/archiver/archive_manager.rs
index b4f8e27..14497b2 100644
--- a/crates/mozart-core/src/package/archiver/archive_manager.rs
+++ b/crates/mozart-core/src/package/archiver/archive_manager.rs
@@ -1,9 +1,8 @@
-use crate::downloader::DownloadManager;
-
use super::{
ArchiveFormat, collect_archivable_files, create_archive, generate_archive_filename,
parse_composer_excludes, parse_gitattributes, parse_gitignore_pattern, self_exclusion_patterns,
};
+use crate::downloader::DownloadManager;
use std::path::{Path, PathBuf};
/// A package to be archived.
diff --git a/crates/mozart-core/src/repository/downloader.rs b/crates/mozart-core/src/repository/downloader.rs
index f2e33a7..711a678 100644
--- a/crates/mozart-core/src/repository/downloader.rs
+++ b/crates/mozart-core/src/repository/downloader.rs
@@ -350,7 +350,7 @@ pub async fn install_package(
#[cfg(test)]
mod tests {
use super::*;
- use std::io::Write as IoWrite;
+ use std::io::Write as _;
use tempfile::tempdir;
/// Build a minimal zip archive in memory.
diff --git a/crates/mozart-core/src/repository/installer_executor/mod.rs b/crates/mozart-core/src/repository/installer_executor.rs
index f67c612..1cb26d2 100644
--- a/crates/mozart-core/src/repository/installer_executor/mod.rs
+++ b/crates/mozart-core/src/repository/installer_executor.rs
@@ -13,10 +13,9 @@
//! Composer's `(string) $operation` byte-for-byte without the executor
//! having to also reproduce console formatting.
-use std::path::PathBuf;
-
use super::installed::InstalledPackageEntry;
use super::lockfile::{LockAlias, LockedPackage};
+use std::path::PathBuf;
pub mod filesystem;
pub mod trace_recorder;
diff --git a/crates/mozart-core/src/repository/installer_executor/trace_recorder.rs b/crates/mozart-core/src/repository/installer_executor/trace_recorder.rs
index b60a869..5dd39b0 100644
--- a/crates/mozart-core/src/repository/installer_executor/trace_recorder.rs
+++ b/crates/mozart-core/src/repository/installer_executor/trace_recorder.rs
@@ -14,12 +14,11 @@
//! - Update (downgrade direction): `Downgrading <name> (<oldVersion> => <newVersion>)`
//! - Uninstall: `Removing <name> (<version>)`
-use mozart_semver::Version;
-
use super::{
ExecuteContext, InstallerExecutor, PackageOperation, format_full_pretty_alias,
format_full_pretty_version,
};
+use mozart_semver::Version;
/// Recording-only executor. Construct with [`TraceRecorderExecutor::new`],
/// then read [`TraceRecorderExecutor::trace`] after the run completes.
diff --git a/crates/mozart-core/src/repository/path_repository.rs b/crates/mozart-core/src/repository/path_repository.rs
index 0cff012..2353809 100644
--- a/crates/mozart-core/src/repository/path_repository.rs
+++ b/crates/mozart-core/src/repository/path_repository.rs
@@ -19,11 +19,10 @@
//! consumers comparing references against Composer-produced lockfiles see
//! byte-identical values.
-use std::path::{Path, PathBuf};
-
use crate::package::RawRepository;
-use mozart_php_serialize::{Value as PhpValue, serialize as php_serialize};
+use mozart_php_serialize::{Value, serialize};
use sha1::{Digest as _, Sha1};
+use std::path::{Path, PathBuf};
/// Translate path repos in `repositories` into synthetic `type: package`
/// entries. Non-path entries are returned unchanged in original order.
@@ -123,11 +122,11 @@ fn resolve_path(url: &str, base_dir: &Path) -> PathBuf {
/// flag is the only option Composer's auto-detection populates when the user
/// supplied no `options` block.
fn compute_path_reference(json_bytes: &[u8], is_relative: bool) -> String {
- let options = PhpValue::Array(vec![(
- PhpValue::String("relative".to_string()),
- PhpValue::Bool(is_relative),
+ let options = Value::Array(vec![(
+ Value::String("relative".to_string()),
+ Value::Bool(is_relative),
)]);
- let serialized = php_serialize(&options);
+ let serialized = serialize(&options);
let mut hasher = Sha1::new();
hasher.update(json_bytes);
hasher.update(serialized.as_bytes());
diff --git a/crates/mozart-core/src/repository/repository/mod.rs b/crates/mozart-core/src/repository/repository.rs
index 4afff54..ece0c5f 100644
--- a/crates/mozart-core/src/repository/repository/mod.rs
+++ b/crates/mozart-core/src/repository/repository.rs
@@ -10,10 +10,9 @@
//! the live Packagist HTTP repo, [`inline_package_repo`] for `type: package`
//! entries embedded in `composer.json`, and [`vcs_repo`] for VCS repositories.
-use std::collections::BTreeMap;
-
use super::advisory::{MatchedAdvisory, PackageInfo};
use super::packagist::{PackagistVersion, SearchResult};
+use std::collections::BTreeMap;
pub mod inline_package_repo;
pub mod packagist_repo;
diff --git a/crates/mozart-core/src/vcs/process.rs b/crates/mozart-core/src/vcs/process.rs
index 8ccc11d..7538d55 100644
--- a/crates/mozart-core/src/vcs/process.rs
+++ b/crates/mozart-core/src/vcs/process.rs
@@ -1,10 +1,9 @@
+use anyhow::{Result, bail};
use indexmap::IndexMap;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
-use anyhow::{Result, bail};
-
/// Output from a process execution.
#[derive(Debug, Clone)]
pub struct ProcessOutput {
diff --git a/crates/mozart-core/src/vcs/util/mod.rs b/crates/mozart-core/src/vcs/util.rs
index b2c35fc..b2c35fc 100644
--- a/crates/mozart-core/src/vcs/util/mod.rs
+++ b/crates/mozart-core/src/vcs/util.rs
diff --git a/crates/mozart-test-harness/src/pool_builder_parser.rs b/crates/mozart-test-harness/src/pool_builder_parser.rs
index 2876c25..a8f7326 100644
--- a/crates/mozart-test-harness/src/pool_builder_parser.rs
+++ b/crates/mozart-test-harness/src/pool_builder_parser.rs
@@ -4,12 +4,11 @@
//! Section bodies are stored as raw strings (typically JSON); the runner is
//! responsible for interpreting them.
+use crate::parser::split_sections;
use anyhow::{Context as _, Result, bail};
use std::fs;
use std::path::Path;
-use crate::parser::split_sections;
-
const VALID_SECTIONS: &[&str] = &[
"TEST",
"ROOT",
diff --git a/crates/mozart-test-harness/src/runner.rs b/crates/mozart-test-harness/src/runner.rs
index bb26255..fa5c360 100644
--- a/crates/mozart-test-harness/src/runner.rs
+++ b/crates/mozart-test-harness/src/runner.rs
@@ -1,10 +1,9 @@
+use crate::parser::ParsedTest;
use anyhow::{Context as _, Result};
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
-use crate::parser::ParsedTest;
-
/// Outcome of running a parsed `.test` against the `mozart` binary.
///
/// The temp directory is kept alive in this struct so callers can inspect
diff --git a/crates/mozart/Cargo.toml b/crates/mozart/Cargo.toml
index b8b3c06..4fef18a 100644
--- a/crates/mozart/Cargo.toml
+++ b/crates/mozart/Cargo.toml
@@ -22,8 +22,8 @@ sha1.workspace = true
tempfile.workspace = true
terminal_size.workspace = true
tokio.workspace = true
-tracing-subscriber.workspace = true
tracing.workspace = true
+tracing-subscriber.workspace = true
url.workspace = true
[dev-dependencies]
diff --git a/crates/mozart/src/commands/audit.rs b/crates/mozart/src/commands/audit.rs
index 5193b06..8b28770 100644
--- a/crates/mozart/src/commands/audit.rs
+++ b/crates/mozart/src/commands/audit.rs
@@ -1,5 +1,3 @@
-use std::path::Path;
-
use crate::composer::Composer;
use clap::Args;
use indexmap::IndexMap;
@@ -8,6 +6,7 @@ use mozart_core::console::IoInterface;
use mozart_core::repository::advisory::{AuditOptions, Auditor, PackageInfo};
use mozart_core::repository::cache::{Cache, build_cache_config};
use mozart_core::repository::repository::RepositorySet;
+use std::path::Path;
#[derive(Args)]
pub struct AuditArgs {
diff --git a/crates/mozart/src/commands/base_config.rs b/crates/mozart/src/commands/base_config.rs
index c10e7e7..ed05184 100644
--- a/crates/mozart/src/commands/base_config.rs
+++ b/crates/mozart/src/commands/base_config.rs
@@ -1,7 +1,6 @@
-use std::path::PathBuf;
-
use mozart_core::composer::composer_home;
use mozart_core::config_source::JsonConfigSource;
+use std::path::PathBuf;
/// Mirrors Composer's `BaseConfigCommand`: resolves the target config file path
/// and enforces the `--file` ↔ `--global` mutual exclusivity.
diff --git a/crates/mozart/src/commands/create_project.rs b/crates/mozart/src/commands/create_project.rs
index 49354d1..fd2edfa 100644
--- a/crates/mozart/src/commands/create_project.rs
+++ b/crates/mozart/src/commands/create_project.rs
@@ -1,3 +1,4 @@
+use crate::factory::create_download_manager;
use clap::Args;
use indexmap::IndexMap;
use mozart_core::console::IoInterface;
@@ -12,8 +13,6 @@ use mozart_core::repository::version;
use mozart_core::validation;
use std::path::{Path, PathBuf};
-use crate::factory::create_download_manager;
-
#[derive(Args)]
pub struct CreateProjectArgs {
/// Package name to install
diff --git a/crates/mozart/src/commands/repository.rs b/crates/mozart/src/commands/repository.rs
index 6616352..fe94eac 100644
--- a/crates/mozart/src/commands/repository.rs
+++ b/crates/mozart/src/commands/repository.rs
@@ -1,11 +1,10 @@
+use super::base_config::BaseConfigContext;
+use super::config_helpers::{normalize_repositories, render_value};
use anyhow::anyhow;
use clap::Args;
use mozart_core::console::IoInterface;
use mozart_core::console_writeln;
-use super::base_config::BaseConfigContext;
-use super::config_helpers::{normalize_repositories, render_value};
-
#[derive(Args)]
pub struct RepositoryArgs {
/// Action (list, add, remove, set-url, get-url, enable, disable)
diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs
index fbeba6d..c96e3b4 100644
--- a/crates/mozart/tests/installer.rs
+++ b/crates/mozart/tests/installer.rs
@@ -8,9 +8,6 @@
//! EXPECT-LOCK + EXPECT-INSTALLED — the same load-bearing assertions
//! Composer's PHPUnit suite uses.
-use std::path::{Path, PathBuf};
-use std::sync::{Arc, Mutex};
-
use clap::Parser;
use mozart::commands::{Cli, Commands, install, update};
use mozart_core::console::{Console, IoInterface};
@@ -18,6 +15,8 @@ use mozart_core::exit_code::MozartError;
use mozart_core::repository::installer_executor::TraceRecorderExecutor;
use mozart_core::repository::repository::RepositorySet;
use mozart_test_harness::{ParsedTest, parse_test_file};
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex};
use tempfile::TempDir;
fn fixtures_dir() -> PathBuf {
diff --git a/scripts/lint b/scripts/lint
new file mode 100755
index 0000000..59bdcf3
--- /dev/null
+++ b/scripts/lint
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+
+require 'pathname'
+
+LINTERS = [
+ :cargo_workspace_dependencies,
+ :contiguous_use_block,
+ # TODO: re-enable this linter
+ # :no_decorative_section_comment,
+ :no_mod_rs,
+ # TODO: re-enable this linter
+ # :no_std_collections_maps,
+ :no_use_as_alias,
+ :sorted_dependencies,
+]
+
+root_dir = Pathname.new(__dir__).join('..').expand_path
+
+results = LINTERS.map do |linter|
+ require_relative "linters/#{linter}"
+ puts "===== #{linter} ====="
+ ok = send(linter, root_dir)
+ puts "Passed." if ok
+ puts
+ ok
+end
+exit(results.all? ? 0 : 1)
diff --git a/scripts/linters/cargo_workspace_dependencies.rb b/scripts/linters/cargo_workspace_dependencies.rb
new file mode 100644
index 0000000..85305c9
--- /dev/null
+++ b/scripts/linters/cargo_workspace_dependencies.rb
@@ -0,0 +1,48 @@
+def cargo_workspace_dependencies(root_dir)
+ pattern = root_dir.join('crates', '*', 'Cargo.toml').to_s
+ errors = Dir.glob(pattern).sort.flat_map do |path|
+ relative = Pathname.new(path).relative_path_from(root_dir).to_s
+ find_non_workspace_deps(path, relative)
+ end
+
+ return true if errors.empty?
+
+ puts 'Found `[dependencies]` / `[dev-dependencies]` entries that do not use `workspace = true`.'
+ puts 'In a crate `Cargo.toml`, only `name.workspace = true` or `name = { workspace = true, ... }` is allowed:'
+ errors.each do |err|
+ puts " #{err}"
+ end
+ false
+end
+
+def find_non_workspace_deps(path, relative)
+ errors = []
+ current_section = nil
+
+ File.read(path).each_line.with_index do |raw_line, idx|
+ stripped = raw_line.chomp.strip
+
+ if stripped =~ /\A\[([^\]]+)\]\z/
+ current_section = $1
+ next
+ end
+
+ next unless %w[dependencies dev-dependencies build-dependencies].include?(current_section)
+ next if stripped.empty? || stripped.start_with?('#')
+
+ if stripped =~ /\A([A-Za-z0-9_-]+)\.workspace\s*=\s*true\b/
+ next
+ elsif stripped =~ /\A([A-Za-z0-9_-]+)\s*=\s*\{(.+)\}\s*\z/
+ name = $1
+ inner = $2
+ next if inner =~ /\bworkspace\s*=\s*true\b/
+
+ errors << "#{relative}:#{idx + 1}: `#{name}` does not use `workspace = true`"
+ elsif stripped =~ /\A([A-Za-z0-9_-]+)\s*=/
+ name = $1
+ errors << "#{relative}:#{idx + 1}: `#{name}` does not use `workspace = true`"
+ end
+ end
+
+ errors
+end
diff --git a/scripts/linters/contiguous_use_block.rb b/scripts/linters/contiguous_use_block.rb
new file mode 100644
index 0000000..bce9e32
--- /dev/null
+++ b/scripts/linters/contiguous_use_block.rb
@@ -0,0 +1,80 @@
+def contiguous_use_block(root_dir)
+ pattern = root_dir.join('crates', '**', '*.rs').to_s
+ errors = Dir.glob(pattern).sort.flat_map do |path|
+ relative = Pathname.new(path).relative_path_from(root_dir).to_s
+ find_split_use_block(path, relative)
+ end
+
+ return true if errors.empty?
+
+ puts 'Found blank lines splitting the leading `use` block into sections.'
+ puts 'All `use` statements at the top of the file must be contiguous (no blank lines between them):'
+ errors.each do |err|
+ puts " #{err}"
+ end
+ false
+end
+
+USE_START_RE = /\A(?:pub(?:\([^)]*\))?\s+)?use\b/
+
+def find_split_use_block(path, relative)
+ lines = File.readlines(path)
+ errors = []
+
+ i = skip_preamble(lines)
+ return [] if i.nil?
+
+ loop do
+ i = consume_use_statement(lines, i)
+ break if i >= lines.length
+
+ blanks = []
+ j = i
+ while j < lines.length
+ stripped = lines[j].strip
+ if stripped.empty?
+ blanks << j
+ j += 1
+ elsif stripped.start_with?('//') || stripped.start_with?('#[')
+ j += 1
+ else
+ break
+ end
+ end
+
+ if j < lines.length && lines[j].strip =~ USE_START_RE
+ blanks.each do |bi|
+ errors << "#{relative}:#{bi + 1}: blank line splits the leading `use` block"
+ end
+ i = j
+ else
+ break
+ end
+ end
+
+ errors
+end
+
+def skip_preamble(lines)
+ lines.each_with_index do |raw, idx|
+ stripped = raw.strip
+ return idx if stripped =~ USE_START_RE
+ next if stripped.empty? || stripped.start_with?('//') || stripped.start_with?('#![') || stripped.start_with?('#[')
+
+ return nil
+ end
+ nil
+end
+
+def consume_use_statement(lines, start_idx)
+ brace_depth = 0
+ i = start_idx
+ while i < lines.length
+ line = lines[i]
+ brace_depth += line.count('{') - line.count('}')
+ done = brace_depth <= 0 && line.rstrip.end_with?(';')
+ i += 1
+ return i if done
+ end
+ i
+end
diff --git a/scripts/linters/no_decorative_section_comment.rb b/scripts/linters/no_decorative_section_comment.rb
new file mode 100644
index 0000000..8f26fad
--- /dev/null
+++ b/scripts/linters/no_decorative_section_comment.rb
@@ -0,0 +1,34 @@
+def no_decorative_section_comment(root_dir)
+ pattern = root_dir.join('crates', '**', '*.rs').to_s
+ errors = Dir.glob(pattern).sort.flat_map do |path|
+ relative = Pathname.new(path).relative_path_from(root_dir).to_s
+ find_decorative_comments(path, relative)
+ end
+
+ return true if errors.empty?
+
+ puts 'Found decorative section comments (4+ consecutive `=`, `-`, or Unicode box-drawing characters).'
+ puts 'These section dividers are unnecessarily noisy — remove them:'
+ errors.each do |err|
+ puts " #{err}"
+ end
+ false
+end
+
+DECORATIVE_RUN_RE = /[-=]{4,}|[─-╿]{4,}/
+
+def find_decorative_comments(path, relative)
+ errors = []
+
+ File.readlines(path).each_with_index do |raw, idx|
+ stripped = raw.lstrip
+ next unless stripped.start_with?('//')
+ next if stripped.start_with?('///') || stripped.start_with?('//!')
+
+ next unless stripped.match?(DECORATIVE_RUN_RE)
+
+ errors << "#{relative}:#{idx + 1}: decorative section comment"
+ end
+
+ errors
+end
diff --git a/scripts/linters/no_mod_rs.rb b/scripts/linters/no_mod_rs.rb
new file mode 100755
index 0000000..8fef9f0
--- /dev/null
+++ b/scripts/linters/no_mod_rs.rb
@@ -0,0 +1,14 @@
+def no_mod_rs(root_dir)
+ pattern = root_dir.join('crates', '*', 'src', '**', 'mod.rs').to_s
+ errors = Dir.glob(pattern).sort.map do |path|
+ Pathname.new(path).relative_path_from(root_dir).to_s
+ end
+
+ return true if errors.empty?
+
+ puts 'Found `mod.rs` file(s). Use `src/<submodule>.rs` instead of `<submodule>/mod.rs`:'
+ errors.each do |path|
+ puts " #{path}"
+ end
+ false
+end
diff --git a/scripts/linters/no_std_collections_maps.rb b/scripts/linters/no_std_collections_maps.rb
new file mode 100644
index 0000000..b80ef07
--- /dev/null
+++ b/scripts/linters/no_std_collections_maps.rb
@@ -0,0 +1,48 @@
+def no_std_collections_maps(root_dir)
+ pattern = root_dir.join('crates', '**', '*.rs').to_s
+ errors = Dir.glob(pattern).sort.flat_map do |path|
+ relative = Pathname.new(path).relative_path_from(root_dir).to_s
+ find_std_map_usages(path, relative)
+ end
+
+ return true if errors.empty?
+
+ puts 'Found uses of `std::collections::{HashMap, HashSet, BTreeMap, BTreeSet}`.'
+ puts 'Use `indexmap::IndexMap` / `indexmap::IndexSet` instead:'
+ errors.each do |err|
+ puts " #{err}"
+ end
+ false
+end
+
+BANNED_MAP_NAMES = %w[HashMap HashSet BTreeMap BTreeSet].freeze
+
+def find_std_map_usages(path, relative)
+ errors = []
+
+ File.readlines(path).each_with_index do |raw, idx|
+ code = raw.split('//', 2).first || raw
+
+ code.scan(/\bstd::collections::(HashMap|HashSet|BTreeMap|BTreeSet)\b/) do |m|
+ errors << "#{relative}:#{idx + 1}: use of `std::collections::#{m[0]}` (use `indexmap::#{indexmap_replacement(m[0])}` instead)"
+ end
+
+ code.scan(/\bstd::collections::\{([^}]*)\}/) do |m|
+ m[0].split(',').each do |entry|
+ name = entry.strip.split(/\s+as\s+/).first
+ next unless BANNED_MAP_NAMES.include?(name)
+
+ errors << "#{relative}:#{idx + 1}: import of `std::collections::#{name}` (use `indexmap::#{indexmap_replacement(name)}` instead)"
+ end
+ end
+ end
+
+ errors.uniq
+end
+
+def indexmap_replacement(name)
+ case name
+ when 'HashMap', 'BTreeMap' then 'IndexMap'
+ when 'HashSet', 'BTreeSet' then 'IndexSet'
+ end
+end
diff --git a/scripts/linters/no_use_as_alias.rb b/scripts/linters/no_use_as_alias.rb
new file mode 100644
index 0000000..adb2e67
--- /dev/null
+++ b/scripts/linters/no_use_as_alias.rb
@@ -0,0 +1,51 @@
+def no_use_as_alias(root_dir)
+ pattern = root_dir.join('crates', '**', '*.rs').to_s
+ errors = Dir.glob(pattern).sort.flat_map do |path|
+ relative = Pathname.new(path).relative_path_from(root_dir).to_s
+ find_use_aliases(path, relative)
+ end
+
+ return true if errors.empty?
+
+ puts 'Found `use ... as Name` aliases.'
+ puts 'Renaming imports is forbidden; only unnamed imports `as _` (e.g. `use std::io::Write as _;`) are allowed:'
+ errors.each do |err|
+ puts " #{err}"
+ end
+ false
+end
+
+USE_ALIAS_START_RE = /\A(?:pub(?:\([^)]*\))?\s+)?use\b/
+
+def find_use_aliases(path, relative)
+ errors = []
+ in_use = false
+ brace_depth = 0
+
+ File.readlines(path).each_with_index do |raw, idx|
+ code = raw.split('//', 2).first || raw
+ stripped = code.strip
+
+ unless in_use
+ next unless stripped =~ USE_ALIAS_START_RE
+
+ in_use = true
+ brace_depth = 0
+ end
+
+ code.scan(/\bas\s+([A-Za-z_][A-Za-z0-9_]*)/) do |m|
+ name = m[0]
+ next if name == '_'
+
+ errors << "#{relative}:#{idx + 1}: `as #{name}` aliasing in `use` statement"
+ end
+
+ brace_depth += code.count('{') - code.count('}')
+ if brace_depth <= 0 && code.rstrip.end_with?(';')
+ in_use = false
+ brace_depth = 0
+ end
+ end
+
+ errors
+end
diff --git a/scripts/linters/sorted_dependencies.rb b/scripts/linters/sorted_dependencies.rb
new file mode 100644
index 0000000..16ec894
--- /dev/null
+++ b/scripts/linters/sorted_dependencies.rb
@@ -0,0 +1,50 @@
+def sorted_dependencies(root_dir)
+ pattern = root_dir.join('crates', '*', 'Cargo.toml').to_s
+ errors = Dir.glob(pattern).sort.flat_map do |path|
+ relative = Pathname.new(path).relative_path_from(root_dir).to_s
+ sections = parse_dep_sections(File.read(path))
+
+ %w[dependencies dev-dependencies].filter_map do |section|
+ deps = sections[section]
+ next if deps.nil? || deps.empty?
+
+ expected = sort_dep_names(deps)
+ next if deps == expected
+
+ { path: relative, section: section, actual: deps, expected: expected }
+ end
+ end
+
+ return true if errors.empty?
+
+ puts 'Found unsorted `[dependencies]` / `[dev-dependencies]` in Cargo.toml.'
+ puts 'Entries must be alphabetical, with `mozart-*` crates listed before others:'
+ errors.each do |err|
+ puts " #{err[:path]} [#{err[:section]}]"
+ puts " actual: #{err[:actual].join(', ')}"
+ puts " expected: #{err[:expected].join(', ')}"
+ end
+ false
+end
+
+def parse_dep_sections(content)
+ sections = {}
+ current = nil
+
+ content.each_line do |line|
+ stripped = line.chomp
+ if stripped =~ /\A\s*\[([^\]]+)\]\s*\z/
+ current = $1
+ sections[current] ||= []
+ elsif current && stripped =~ /\A([A-Za-z0-9_-]+)\s*[.=]/
+ sections[current] << $1
+ end
+ end
+
+ sections
+end
+
+def sort_dep_names(deps)
+ mozart, other = deps.partition { |d| d.start_with?('mozart-') }
+ mozart.sort + other.sort
+end