aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-07-21 08:08:15 +0900
committernsfisis <nsfisis@gmail.com>2026-07-21 08:08:45 +0900
commitc899a4675ca90507a420d901ef7fa26d8b285f23 (patch)
tree405b8049c3ac59c65d56792c96f4a8c316bf605b /scripts
parent6b16a2cd672edcbfeaf7988591db92ed4c594ddc (diff)
downloadphp-shirabe-c899a4675ca90507a420d901ef7fa26d8b285f23.tar.gz
php-shirabe-c899a4675ca90507a420d901ef7fa26d8b285f23.tar.zst
php-shirabe-c899a4675ca90507a420d901ef7fa26d8b285f23.zip
refactor(lint): rewrite structural linters from Ruby to PHP
Fold scripts/lint and scripts/linters/*.rb into a standalone Composer project under scripts/linters/, matching the scripts/plugin-class-classifier/ convention. Uses no external packages, only PHP + Composer autoloading. Verified byte-for-byte identical output against the original Ruby implementation, both on the current repo (all linters pass) and on a synthetic fixture exercising every violation type. Entry point moves from `scripts/lint` to `scripts/linters/lint`.
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/lint32
-rw-r--r--scripts/linters/.gitignore1
-rw-r--r--scripts/linters/cargo_workspace_dependencies.rb50
-rw-r--r--scripts/linters/composer.json13
-rw-r--r--scripts/linters/composer.lock20
-rw-r--r--scripts/linters/contiguous_use_block.rb82
-rwxr-xr-xscripts/linters/lint38
-rw-r--r--scripts/linters/no_banned_use.rb124
-rw-r--r--scripts/linters/no_decorative_section_comment.rb36
-rw-r--r--scripts/linters/no_format_trailing_comma.rb27
-rwxr-xr-xscripts/linters/no_mod_rs.rb17
-rw-r--r--scripts/linters/no_std_collections_maps.rb50
-rw-r--r--scripts/linters/no_use_as_alias.rb57
-rw-r--r--scripts/linters/sorted_dependencies.rb52
-rw-r--r--scripts/linters/src/Linter.php19
-rw-r--r--scripts/linters/src/Linters/CargoWorkspaceDependencies.php83
-rw-r--r--scripts/linters/src/Linters/ContiguousUseBlock.php124
-rw-r--r--scripts/linters/src/Linters/NoBannedUse.php170
-rw-r--r--scripts/linters/src/Linters/NoDecorativeSectionComment.php65
-rw-r--r--scripts/linters/src/Linters/NoFormatTrailingComma.php48
-rw-r--r--scripts/linters/src/Linters/NoModRs.php38
-rw-r--r--scripts/linters/src/Linters/NoStdCollectionsMaps.php80
-rw-r--r--scripts/linters/src/Linters/NoUseAsAlias.php85
-rw-r--r--scripts/linters/src/Linters/SortedDependencies.php100
-rw-r--r--scripts/linters/src/Runner.php39
-rw-r--r--scripts/linters/src/Support/FileFinder.php68
-rw-r--r--scripts/linters/src/Support/Paths.php15
27 files changed, 1006 insertions, 527 deletions
diff --git a/scripts/lint b/scripts/lint
deleted file mode 100755
index c9e33049..00000000
--- a/scripts/lint
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/usr/bin/env ruby
-
-require 'pathname'
-
-LINTERS = {
- cargo_workspace_dependencies: [],
- contiguous_use_block: [],
- no_banned_use: [],
- no_decorative_section_comment: %w[
- crates/shirabe-semver/src/version_parser.rs
- ],
- no_format_trailing_comma: %w[
- crates/shirabe/src/package/loader/root_package_loader.rs
- crates/shirabe-spdx-licenses/src/spdx_licenses.rs
- ],
- no_mod_rs: [],
- no_std_collections_maps: [],
- no_use_as_alias: [],
- sorted_dependencies: [],
-}
-
-root_dir = Pathname.new(__dir__).join('..').expand_path
-
-results = LINTERS.map do |linter, excludes|
- require_relative "linters/#{linter}"
- puts "===== #{linter} ====="
- ok = send(linter, root_dir, excludes)
- puts "Passed." if ok
- puts
- ok
-end
-exit(results.all? ? 0 : 1)
diff --git a/scripts/linters/.gitignore b/scripts/linters/.gitignore
new file mode 100644
index 00000000..48b8bf90
--- /dev/null
+++ b/scripts/linters/.gitignore
@@ -0,0 +1 @@
+vendor/
diff --git a/scripts/linters/cargo_workspace_dependencies.rb b/scripts/linters/cargo_workspace_dependencies.rb
deleted file mode 100644
index 6e4278fb..00000000
--- a/scripts/linters/cargo_workspace_dependencies.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-def cargo_workspace_dependencies(root_dir, excludes = [])
- 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
- next [] if excludes.include?(relative)
-
- 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/composer.json b/scripts/linters/composer.json
new file mode 100644
index 00000000..fddb624d
--- /dev/null
+++ b/scripts/linters/composer.json
@@ -0,0 +1,13 @@
+{
+ "name": "shirabe/lint",
+ "description": "Structural linters enforcing this repository's Rust source conventions",
+ "license": "MIT",
+ "require": {
+ "php": ">=8.1"
+ },
+ "autoload": {
+ "psr-4": {
+ "Shirabe\\Lint\\": "src/"
+ }
+ }
+}
diff --git a/scripts/linters/composer.lock b/scripts/linters/composer.lock
new file mode 100644
index 00000000..dcee9166
--- /dev/null
+++ b/scripts/linters/composer.lock
@@ -0,0 +1,20 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "f113cbf87cb1a110c764ffabac1d9167",
+ "packages": [],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=8.1"
+ },
+ "platform-dev": {},
+ "plugin-api-version": "2.9.0"
+}
diff --git a/scripts/linters/contiguous_use_block.rb b/scripts/linters/contiguous_use_block.rb
deleted file mode 100644
index b535a038..00000000
--- a/scripts/linters/contiguous_use_block.rb
+++ /dev/null
@@ -1,82 +0,0 @@
-def contiguous_use_block(root_dir, excludes = [])
- 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
- next [] if excludes.include?(relative)
-
- 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/lint b/scripts/linters/lint
new file mode 100755
index 00000000..bcdf1c67
--- /dev/null
+++ b/scripts/linters/lint
@@ -0,0 +1,38 @@
+#!/usr/bin/env php
+<?php
+
+declare(strict_types=1);
+
+require __DIR__ . '/vendor/autoload.php';
+
+use Shirabe\Lint\Linters\CargoWorkspaceDependencies;
+use Shirabe\Lint\Linters\ContiguousUseBlock;
+use Shirabe\Lint\Linters\NoBannedUse;
+use Shirabe\Lint\Linters\NoDecorativeSectionComment;
+use Shirabe\Lint\Linters\NoFormatTrailingComma;
+use Shirabe\Lint\Linters\NoModRs;
+use Shirabe\Lint\Linters\NoStdCollectionsMaps;
+use Shirabe\Lint\Linters\NoUseAsAlias;
+use Shirabe\Lint\Linters\SortedDependencies;
+use Shirabe\Lint\Runner;
+
+$rootDir = dirname(__DIR__, 2);
+
+$runner = new Runner($rootDir, [
+ [new CargoWorkspaceDependencies(), []],
+ [new ContiguousUseBlock(), []],
+ [new NoBannedUse(), []],
+ [new NoDecorativeSectionComment(), [
+ 'crates/shirabe-semver/src/version_parser.rs',
+ ]],
+ [new NoFormatTrailingComma(), [
+ 'crates/shirabe/src/package/loader/root_package_loader.rs',
+ 'crates/shirabe-spdx-licenses/src/spdx_licenses.rs',
+ ]],
+ [new NoModRs(), []],
+ [new NoStdCollectionsMaps(), []],
+ [new NoUseAsAlias(), []],
+ [new SortedDependencies(), []],
+]);
+
+exit($runner->run() ? 0 : 1);
diff --git a/scripts/linters/no_banned_use.rb b/scripts/linters/no_banned_use.rb
deleted file mode 100644
index 74e50d06..00000000
--- a/scripts/linters/no_banned_use.rb
+++ /dev/null
@@ -1,124 +0,0 @@
-BANNED_USE_PATHS = %w[
- anyhow::Result
- std::any::Any
- std::cell::RefCell
- std::io::Read
- std::io::Write
- std::process::Command
- std::rc::Rc
-].freeze
-
-def no_banned_use(root_dir, excludes = [])
- 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
- next [] if excludes.include?(relative)
-
- find_banned_uses(path, relative)
- end
-
- return true if errors.empty?
-
- puts 'Found banned `use` imports.'
- puts 'These items must always be referenced by their fully-qualified path.'
- puts 'For imports to use trait methods, use `as _` (e.g., `use std::io::Write as _;`).'
- errors.each do |err|
- puts " #{err}"
- end
- false
-end
-
-BANNED_USE_START_RE = /\A(?:pub(?:\([^)]*\))?\s+)?use\b/
-
-def find_banned_uses(path, relative)
- errors = []
- lines = File.readlines(path)
- buffer = nil
- start_idx = nil
-
- lines.each_with_index do |raw, idx|
- code = raw.split('//', 2).first || raw
- stripped = code.strip
-
- if buffer.nil?
- next unless stripped =~ BANNED_USE_START_RE
-
- buffer = +''
- start_idx = idx
- end
-
- buffer << ' ' << stripped
- next unless buffer.include?(';')
-
- tree = buffer[/\buse\s+(.*?);/m, 1]
- expand_use_tree(tree).each do |full|
- next unless BANNED_USE_PATHS.include?(full)
-
- errors << "#{relative}:#{start_idx + 1}: `use #{full}` is banned (fully qualify as `#{full}` instead)"
- end
-
- buffer = nil
- end
-
- errors.uniq
-end
-
-def expand_use_tree(tree)
- return [] if tree.nil?
-
- tree = tree.strip
- brace = tree.index('{')
-
- if brace.nil?
- stripped = strip_use_alias(tree)
- return [] if stripped.nil?
-
- return [stripped].reject(&:empty?)
- end
-
- prefix = tree[0...brace].sub(/::\s*\z/, '').strip
- inner = tree[(brace + 1)..].sub(/\}\s*\z/, '')
-
- split_top_level(inner).flat_map do |child|
- expand_use_tree(child).map do |sub|
- if sub.empty? || sub == 'self'
- prefix
- elsif prefix.empty?
- sub
- else
- "#{prefix}::#{sub}"
- end
- end
- end
-end
-
-def strip_use_alias(segment)
- return nil if segment =~ /\s+as\s+_\s*\z/
-
- segment.sub(/\s+as\s+\S+\s*\z/, '').strip
-end
-
-def split_top_level(str)
- parts = []
- current = +''
- depth = 0
-
- str.each_char do |ch|
- case ch
- when '{' then depth += 1; current << ch
- when '}' then depth -= 1; current << ch
- when ','
- if depth.zero?
- parts << current
- current = +''
- else
- current << ch
- end
- else
- current << ch
- end
- end
- parts << current
-
- parts.map(&:strip).reject(&:empty?)
-end
diff --git a/scripts/linters/no_decorative_section_comment.rb b/scripts/linters/no_decorative_section_comment.rb
deleted file mode 100644
index c9e30fe1..00000000
--- a/scripts/linters/no_decorative_section_comment.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-def no_decorative_section_comment(root_dir, excludes = [])
- 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
- next [] if excludes.include?(relative)
-
- 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_format_trailing_comma.rb b/scripts/linters/no_format_trailing_comma.rb
deleted file mode 100644
index 3e474a76..00000000
--- a/scripts/linters/no_format_trailing_comma.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-def no_format_trailing_comma(root_dir, excludes = [])
- crates_dir = root_dir.join('crates')
- raw = `grep -rn --include='*.rs' -e ',)' #{crates_dir}`
-
- errors = []
- raw.each_line do |line|
- line = line.chomp
- path, lineno, content = line.split(':', 3)
- next if content.nil?
-
- relative = Pathname.new(path).relative_path_from(root_dir).to_s
- next if excludes.include?(relative)
-
- # `(,)` is a macro repetition fragment (e.g. `$(,)?`).
- next if content.match?(/\(,\)/)
-
- errors << "#{relative}:#{lineno}: trailing `,)` before a closing paren"
- end
-
- return true if errors.empty?
-
- puts 'Found `,)` introduced by formatting. Remove it.'
- errors.each do |err|
- puts " #{err}"
- end
- false
-end
diff --git a/scripts/linters/no_mod_rs.rb b/scripts/linters/no_mod_rs.rb
deleted file mode 100755
index c1656378..00000000
--- a/scripts/linters/no_mod_rs.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-def no_mod_rs(root_dir, excludes = [])
- pattern = root_dir.join('crates', '*', 'src', '**', 'mod.rs').to_s
- errors = Dir.glob(pattern).sort.filter_map do |path|
- relative = Pathname.new(path).relative_path_from(root_dir).to_s
- next if excludes.include?(relative)
-
- relative
- 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
deleted file mode 100644
index 3701d753..00000000
--- a/scripts/linters/no_std_collections_maps.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-def no_std_collections_maps(root_dir, excludes = [])
- 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
- next [] if excludes.include?(relative)
-
- 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
deleted file mode 100644
index 8e0c86b3..00000000
--- a/scripts/linters/no_use_as_alias.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-def no_use_as_alias(root_dir, excludes = [])
- 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
- next [] if excludes.include?(relative)
-
- find_use_aliases(path, relative)
- end
-
- return true if errors.empty?
-
- puts 'Found `use ... as name` aliases.'
- puts 'Aliasing imports merely to shorten a namespace is forbidden.'
- puts 'Only `as _` (e.g. `use std::io::Write as _;`) and PascalCase renames'
- puts '(for collision avoidance, e.g. `use foo::Error as FooError;`) are allowed:'
- errors.each do |err|
- puts " #{err}"
- end
- false
-end
-
-USE_ALIAS_START_RE = /\A(?:pub(?:\([^)]*\))?\s+)?use\b/
-PASCAL_CASE_RE = /\A[A-Z][A-Za-z0-9]*\z/
-
-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 == '_'
- next if name =~ PASCAL_CASE_RE
-
- 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
deleted file mode 100644
index f7ae816e..00000000
--- a/scripts/linters/sorted_dependencies.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-def sorted_dependencies(root_dir, excludes = [])
- 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
- next [] if excludes.include?(relative)
-
- 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 `shirabe-*` 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)
- shirabe, other = deps.partition { |d| d.start_with?('shirabe-') }
- shirabe.sort + other.sort
-end
diff --git a/scripts/linters/src/Linter.php b/scripts/linters/src/Linter.php
new file mode 100644
index 00000000..3fb6038f
--- /dev/null
+++ b/scripts/linters/src/Linter.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint;
+
+interface Linter
+{
+ public function name(): string;
+
+ /**
+ * @param list<string> $excludes root-relative paths to skip
+ * @return list<string> formatted violation lines; empty when the linter passes
+ */
+ public function check(string $rootDir, array $excludes): array;
+
+ /** Printed once, above the violation list, when violations are found. */
+ public function failureIntro(): string;
+}
diff --git a/scripts/linters/src/Linters/CargoWorkspaceDependencies.php b/scripts/linters/src/Linters/CargoWorkspaceDependencies.php
new file mode 100644
index 00000000..3ad86b6f
--- /dev/null
+++ b/scripts/linters/src/Linters/CargoWorkspaceDependencies.php
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint\Linters;
+
+use Shirabe\Lint\Linter;
+use Shirabe\Lint\Support\FileFinder;
+use Shirabe\Lint\Support\Paths;
+
+final class CargoWorkspaceDependencies implements Linter
+{
+ private const SECTION_NAMES = ['dependencies', 'dev-dependencies', 'build-dependencies'];
+
+ public function name(): string
+ {
+ return 'cargo_workspace_dependencies';
+ }
+
+ public function failureIntro(): string
+ {
+ return "Found `[dependencies]` / `[dev-dependencies]` entries that do not use `workspace = true`.\n"
+ . 'In a crate `Cargo.toml`, only `name.workspace = true` or `name = { workspace = true, ... }` is allowed:';
+ }
+
+ public function check(string $rootDir, array $excludes): array
+ {
+ $errors = [];
+
+ foreach (FileFinder::cargoTomls($rootDir) as $path) {
+ $relative = Paths::relativeTo($rootDir, $path);
+ if (in_array($relative, $excludes, true)) {
+ continue;
+ }
+
+ array_push($errors, ...$this->findNonWorkspaceDeps($path, $relative));
+ }
+
+ return $errors;
+ }
+
+ /** @return list<string> */
+ private function findNonWorkspaceDeps(string $path, string $relative): array
+ {
+ $errors = [];
+ $currentSection = null;
+
+ foreach (file($path) as $idx => $rawLine) {
+ $stripped = trim($rawLine);
+
+ if (preg_match('/\A\[([^\]]+)\]\z/', $stripped, $m)) {
+ $currentSection = $m[1];
+ continue;
+ }
+
+ if ($currentSection === null || !in_array($currentSection, self::SECTION_NAMES, true)) {
+ continue;
+ }
+ if ($stripped === '' || str_starts_with($stripped, '#')) {
+ continue;
+ }
+
+ if (preg_match('/\A([A-Za-z0-9_-]+)\.workspace\s*=\s*true\b/', $stripped)) {
+ continue;
+ }
+
+ if (preg_match('/\A([A-Za-z0-9_-]+)\s*=\s*\{(.+)\}\s*\z/', $stripped, $m)) {
+ [, $name, $inner] = $m;
+ if (preg_match('/\bworkspace\s*=\s*true\b/', $inner)) {
+ continue;
+ }
+ $errors[] = "{$relative}:" . ($idx + 1) . ": `{$name}` does not use `workspace = true`";
+ continue;
+ }
+
+ if (preg_match('/\A([A-Za-z0-9_-]+)\s*=/', $stripped, $m)) {
+ $errors[] = "{$relative}:" . ($idx + 1) . ": `{$m[1]}` does not use `workspace = true`";
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/scripts/linters/src/Linters/ContiguousUseBlock.php b/scripts/linters/src/Linters/ContiguousUseBlock.php
new file mode 100644
index 00000000..c73ea6f8
--- /dev/null
+++ b/scripts/linters/src/Linters/ContiguousUseBlock.php
@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint\Linters;
+
+use Shirabe\Lint\Linter;
+use Shirabe\Lint\Support\FileFinder;
+use Shirabe\Lint\Support\Paths;
+
+final class ContiguousUseBlock implements Linter
+{
+ private const USE_START_RE = '/\A(?:pub(?:\([^)]*\))?\s+)?use\b/';
+
+ public function name(): string
+ {
+ return 'contiguous_use_block';
+ }
+
+ public function failureIntro(): string
+ {
+ return "Found blank lines splitting the leading `use` block into sections.\n"
+ . 'All `use` statements at the top of the file must be contiguous (no blank lines between them):';
+ }
+
+ public function check(string $rootDir, array $excludes): array
+ {
+ $errors = [];
+
+ foreach (FileFinder::rustFiles($rootDir) as $path) {
+ $relative = Paths::relativeTo($rootDir, $path);
+ if (in_array($relative, $excludes, true)) {
+ continue;
+ }
+
+ array_push($errors, ...$this->findSplitUseBlock($path, $relative));
+ }
+
+ return $errors;
+ }
+
+ /** @return list<string> */
+ private function findSplitUseBlock(string $path, string $relative): array
+ {
+ $lines = file($path);
+ $errors = [];
+ $count = count($lines);
+
+ $i = $this->skipPreamble($lines);
+ if ($i === null) {
+ return [];
+ }
+
+ while (true) {
+ $i = $this->consumeUseStatement($lines, $i);
+ if ($i >= $count) {
+ break;
+ }
+
+ $blanks = [];
+ $j = $i;
+ while ($j < $count) {
+ $stripped = trim($lines[$j]);
+ if ($stripped === '') {
+ $blanks[] = $j;
+ $j++;
+ } elseif (str_starts_with($stripped, '//') || str_starts_with($stripped, '#[')) {
+ $j++;
+ } else {
+ break;
+ }
+ }
+
+ if ($j < $count && preg_match(self::USE_START_RE, trim($lines[$j]))) {
+ foreach ($blanks as $bi) {
+ $errors[] = "{$relative}:" . ($bi + 1) . ': blank line splits the leading `use` block';
+ }
+ $i = $j;
+ } else {
+ break;
+ }
+ }
+
+ return $errors;
+ }
+
+ /** @param list<string> $lines */
+ private function skipPreamble(array $lines): ?int
+ {
+ foreach ($lines as $idx => $raw) {
+ $stripped = trim($raw);
+ if (preg_match(self::USE_START_RE, $stripped)) {
+ return $idx;
+ }
+ if ($stripped === '' || str_starts_with($stripped, '//') || str_starts_with($stripped, '#![') || str_starts_with($stripped, '#[')) {
+ continue;
+ }
+
+ return null;
+ }
+
+ return null;
+ }
+
+ /** @param list<string> $lines */
+ private function consumeUseStatement(array $lines, int $startIdx): int
+ {
+ $braceDepth = 0;
+ $i = $startIdx;
+ $count = count($lines);
+
+ while ($i < $count) {
+ $line = $lines[$i];
+ $braceDepth += substr_count($line, '{') - substr_count($line, '}');
+ $done = $braceDepth <= 0 && str_ends_with(rtrim($line), ';');
+ $i++;
+ if ($done) {
+ return $i;
+ }
+ }
+
+ return $i;
+ }
+}
diff --git a/scripts/linters/src/Linters/NoBannedUse.php b/scripts/linters/src/Linters/NoBannedUse.php
new file mode 100644
index 00000000..a6bda77e
--- /dev/null
+++ b/scripts/linters/src/Linters/NoBannedUse.php
@@ -0,0 +1,170 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint\Linters;
+
+use Shirabe\Lint\Linter;
+use Shirabe\Lint\Support\FileFinder;
+use Shirabe\Lint\Support\Paths;
+
+final class NoBannedUse implements Linter
+{
+ private const BANNED_USE_PATHS = [
+ 'anyhow::Result',
+ 'std::any::Any',
+ 'std::cell::RefCell',
+ 'std::io::Read',
+ 'std::io::Write',
+ 'std::process::Command',
+ 'std::rc::Rc',
+ ];
+
+ private const USE_START_RE = '/\A(?:pub(?:\([^)]*\))?\s+)?use\b/';
+
+ public function name(): string
+ {
+ return 'no_banned_use';
+ }
+
+ public function failureIntro(): string
+ {
+ return "Found banned `use` imports.\n"
+ . "These items must always be referenced by their fully-qualified path.\n"
+ . 'For imports to use trait methods, use `as _` (e.g., `use std::io::Write as _;`).';
+ }
+
+ public function check(string $rootDir, array $excludes): array
+ {
+ $errors = [];
+
+ foreach (FileFinder::rustFiles($rootDir) as $path) {
+ $relative = Paths::relativeTo($rootDir, $path);
+ if (in_array($relative, $excludes, true)) {
+ continue;
+ }
+
+ array_push($errors, ...$this->findBannedUses($path, $relative));
+ }
+
+ return $errors;
+ }
+
+ /** @return list<string> */
+ private function findBannedUses(string $path, string $relative): array
+ {
+ $errors = [];
+ $buffer = null;
+ $startIdx = null;
+
+ foreach (file($path) as $idx => $raw) {
+ $code = explode('//', $raw, 2)[0];
+ $stripped = trim($code);
+
+ if ($buffer === null) {
+ if (!preg_match(self::USE_START_RE, $stripped)) {
+ continue;
+ }
+ $buffer = '';
+ $startIdx = $idx;
+ }
+
+ $buffer .= ' ' . $stripped;
+ if (!str_contains($buffer, ';')) {
+ continue;
+ }
+
+ preg_match('/\buse\s+(.*?);/s', $buffer, $m);
+ $tree = $m[1] ?? null;
+
+ foreach ($this->expandUseTree($tree) as $full) {
+ if (!in_array($full, self::BANNED_USE_PATHS, true)) {
+ continue;
+ }
+ $errors[] = "{$relative}:" . ($startIdx + 1) . ": `use {$full}` is banned (fully qualify as `{$full}` instead)";
+ }
+
+ $buffer = null;
+ }
+
+ return array_values(array_unique($errors));
+ }
+
+ /** @return list<string> */
+ private function expandUseTree(?string $tree): array
+ {
+ if ($tree === null) {
+ return [];
+ }
+
+ $tree = trim($tree);
+ $brace = strpos($tree, '{');
+
+ if ($brace === false) {
+ $stripped = $this->stripUseAlias($tree);
+
+ return $stripped === null || $stripped === '' ? [] : [$stripped];
+ }
+
+ $prefix = trim(preg_replace('/::\s*\z/', '', substr($tree, 0, $brace)));
+ $inner = preg_replace('/\}\s*\z/', '', substr($tree, $brace + 1));
+
+ $result = [];
+ foreach ($this->splitTopLevel($inner) as $child) {
+ foreach ($this->expandUseTree($child) as $sub) {
+ if ($sub === '' || $sub === 'self') {
+ $result[] = $prefix;
+ } elseif ($prefix === '') {
+ $result[] = $sub;
+ } else {
+ $result[] = "{$prefix}::{$sub}";
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ private function stripUseAlias(string $segment): ?string
+ {
+ if (preg_match('/\s+as\s+_\s*\z/', $segment)) {
+ return null;
+ }
+
+ return trim(preg_replace('/\s+as\s+\S+\s*\z/', '', $segment));
+ }
+
+ /** @return list<string> */
+ private function splitTopLevel(string $str): array
+ {
+ $parts = [];
+ $current = '';
+ $depth = 0;
+
+ foreach (str_split($str) as $ch) {
+ switch ($ch) {
+ case '{':
+ $depth++;
+ $current .= $ch;
+ break;
+ case '}':
+ $depth--;
+ $current .= $ch;
+ break;
+ case ',':
+ if ($depth === 0) {
+ $parts[] = $current;
+ $current = '';
+ } else {
+ $current .= $ch;
+ }
+ break;
+ default:
+ $current .= $ch;
+ }
+ }
+ $parts[] = $current;
+
+ return array_values(array_filter(array_map('trim', $parts), static fn (string $p): bool => $p !== ''));
+ }
+}
diff --git a/scripts/linters/src/Linters/NoDecorativeSectionComment.php b/scripts/linters/src/Linters/NoDecorativeSectionComment.php
new file mode 100644
index 00000000..dba24063
--- /dev/null
+++ b/scripts/linters/src/Linters/NoDecorativeSectionComment.php
@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint\Linters;
+
+use Shirabe\Lint\Linter;
+use Shirabe\Lint\Support\FileFinder;
+use Shirabe\Lint\Support\Paths;
+
+final class NoDecorativeSectionComment implements Linter
+{
+ // 4+ consecutive ASCII `-`/`=`, or 4+ consecutive Unicode box-drawing characters (U+2500-U+257F).
+ private const DECORATIVE_RUN_RE = '/[-=]{4,}|[\x{2500}-\x{257F}]{4,}/u';
+
+ public function name(): string
+ {
+ return 'no_decorative_section_comment';
+ }
+
+ public function failureIntro(): string
+ {
+ return "Found decorative section comments (4+ consecutive `=`, `-`, or Unicode box-drawing characters).\n"
+ . 'These section dividers are unnecessarily noisy — remove them:';
+ }
+
+ public function check(string $rootDir, array $excludes): array
+ {
+ $errors = [];
+
+ foreach (FileFinder::rustFiles($rootDir) as $path) {
+ $relative = Paths::relativeTo($rootDir, $path);
+ if (in_array($relative, $excludes, true)) {
+ continue;
+ }
+
+ array_push($errors, ...$this->findDecorativeComments($path, $relative));
+ }
+
+ return $errors;
+ }
+
+ /** @return list<string> */
+ private function findDecorativeComments(string $path, string $relative): array
+ {
+ $errors = [];
+
+ foreach (file($path) as $idx => $raw) {
+ $stripped = ltrim($raw);
+ if (!str_starts_with($stripped, '//')) {
+ continue;
+ }
+ if (str_starts_with($stripped, '///') || str_starts_with($stripped, '//!')) {
+ continue;
+ }
+ if (!preg_match(self::DECORATIVE_RUN_RE, $stripped)) {
+ continue;
+ }
+
+ $errors[] = "{$relative}:" . ($idx + 1) . ': decorative section comment';
+ }
+
+ return $errors;
+ }
+}
diff --git a/scripts/linters/src/Linters/NoFormatTrailingComma.php b/scripts/linters/src/Linters/NoFormatTrailingComma.php
new file mode 100644
index 00000000..dc364846
--- /dev/null
+++ b/scripts/linters/src/Linters/NoFormatTrailingComma.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint\Linters;
+
+use Shirabe\Lint\Linter;
+use Shirabe\Lint\Support\FileFinder;
+use Shirabe\Lint\Support\Paths;
+
+final class NoFormatTrailingComma implements Linter
+{
+ public function name(): string
+ {
+ return 'no_format_trailing_comma';
+ }
+
+ public function failureIntro(): string
+ {
+ return 'Found `,)` introduced by formatting. Remove it.';
+ }
+
+ public function check(string $rootDir, array $excludes): array
+ {
+ $errors = [];
+
+ foreach (FileFinder::rustFiles($rootDir) as $path) {
+ $relative = Paths::relativeTo($rootDir, $path);
+ if (in_array($relative, $excludes, true)) {
+ continue;
+ }
+
+ foreach (file($path) as $idx => $raw) {
+ if (!str_contains($raw, ',)')) {
+ continue;
+ }
+ // `(,)` is a macro repetition fragment (e.g. `$(,)?`).
+ if (str_contains($raw, '(,)')) {
+ continue;
+ }
+
+ $errors[] = "{$relative}:" . ($idx + 1) . ': trailing `,)` before a closing paren';
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/scripts/linters/src/Linters/NoModRs.php b/scripts/linters/src/Linters/NoModRs.php
new file mode 100644
index 00000000..248958e1
--- /dev/null
+++ b/scripts/linters/src/Linters/NoModRs.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint\Linters;
+
+use Shirabe\Lint\Linter;
+use Shirabe\Lint\Support\FileFinder;
+use Shirabe\Lint\Support\Paths;
+
+final class NoModRs implements Linter
+{
+ public function name(): string
+ {
+ return 'no_mod_rs';
+ }
+
+ public function failureIntro(): string
+ {
+ return 'Found `mod.rs` file(s). Use `src/<submodule>.rs` instead of `<submodule>/mod.rs`:';
+ }
+
+ public function check(string $rootDir, array $excludes): array
+ {
+ $errors = [];
+
+ foreach (FileFinder::modRsFiles($rootDir) as $path) {
+ $relative = Paths::relativeTo($rootDir, $path);
+ if (in_array($relative, $excludes, true)) {
+ continue;
+ }
+
+ $errors[] = $relative;
+ }
+
+ return $errors;
+ }
+}
diff --git a/scripts/linters/src/Linters/NoStdCollectionsMaps.php b/scripts/linters/src/Linters/NoStdCollectionsMaps.php
new file mode 100644
index 00000000..c2f9c32e
--- /dev/null
+++ b/scripts/linters/src/Linters/NoStdCollectionsMaps.php
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint\Linters;
+
+use Shirabe\Lint\Linter;
+use Shirabe\Lint\Support\FileFinder;
+use Shirabe\Lint\Support\Paths;
+
+final class NoStdCollectionsMaps implements Linter
+{
+ private const BANNED_MAP_NAMES = ['HashMap', 'HashSet', 'BTreeMap', 'BTreeSet'];
+
+ public function name(): string
+ {
+ return 'no_std_collections_maps';
+ }
+
+ public function failureIntro(): string
+ {
+ return "Found uses of `std::collections::{HashMap, HashSet, BTreeMap, BTreeSet}`.\n"
+ . 'Use `indexmap::IndexMap` / `indexmap::IndexSet` instead:';
+ }
+
+ public function check(string $rootDir, array $excludes): array
+ {
+ $errors = [];
+
+ foreach (FileFinder::rustFiles($rootDir) as $path) {
+ $relative = Paths::relativeTo($rootDir, $path);
+ if (in_array($relative, $excludes, true)) {
+ continue;
+ }
+
+ array_push($errors, ...$this->findStdMapUsages($path, $relative));
+ }
+
+ return $errors;
+ }
+
+ /** @return list<string> */
+ private function findStdMapUsages(string $path, string $relative): array
+ {
+ $errors = [];
+
+ foreach (file($path) as $idx => $raw) {
+ $code = explode('//', $raw, 2)[0];
+
+ if (preg_match_all('/\bstd::collections::(HashMap|HashSet|BTreeMap|BTreeSet)\b/', $code, $m)) {
+ foreach ($m[1] as $name) {
+ $errors[] = "{$relative}:" . ($idx + 1) . ": use of `std::collections::{$name}` (use `indexmap::" . self::indexmapReplacement($name) . '` instead)';
+ }
+ }
+
+ if (preg_match_all('/\bstd::collections::\{([^}]*)\}/', $code, $m)) {
+ foreach ($m[1] as $group) {
+ foreach (explode(',', $group) as $entry) {
+ $name = preg_split('/\s+as\s+/', trim($entry))[0];
+ if (!in_array($name, self::BANNED_MAP_NAMES, true)) {
+ continue;
+ }
+ $errors[] = "{$relative}:" . ($idx + 1) . ": import of `std::collections::{$name}` (use `indexmap::" . self::indexmapReplacement($name) . '` instead)';
+ }
+ }
+ }
+ }
+
+ return array_values(array_unique($errors));
+ }
+
+ private static function indexmapReplacement(string $name): string
+ {
+ return match ($name) {
+ 'HashMap', 'BTreeMap' => 'IndexMap',
+ 'HashSet', 'BTreeSet' => 'IndexSet',
+ default => throw new \LogicException("unexpected map name: {$name}"),
+ };
+ }
+}
diff --git a/scripts/linters/src/Linters/NoUseAsAlias.php b/scripts/linters/src/Linters/NoUseAsAlias.php
new file mode 100644
index 00000000..c725630c
--- /dev/null
+++ b/scripts/linters/src/Linters/NoUseAsAlias.php
@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint\Linters;
+
+use Shirabe\Lint\Linter;
+use Shirabe\Lint\Support\FileFinder;
+use Shirabe\Lint\Support\Paths;
+
+final class NoUseAsAlias implements Linter
+{
+ private const USE_ALIAS_START_RE = '/\A(?:pub(?:\([^)]*\))?\s+)?use\b/';
+ private const PASCAL_CASE_RE = '/\A[A-Z][A-Za-z0-9]*\z/';
+
+ public function name(): string
+ {
+ return 'no_use_as_alias';
+ }
+
+ public function failureIntro(): string
+ {
+ return "Found `use ... as name` aliases.\n"
+ . "Aliasing imports merely to shorten a namespace is forbidden.\n"
+ . "Only `as _` (e.g. `use std::io::Write as _;`) and PascalCase renames\n"
+ . '(for collision avoidance, e.g. `use foo::Error as FooError;`) are allowed:';
+ }
+
+ public function check(string $rootDir, array $excludes): array
+ {
+ $errors = [];
+
+ foreach (FileFinder::rustFiles($rootDir) as $path) {
+ $relative = Paths::relativeTo($rootDir, $path);
+ if (in_array($relative, $excludes, true)) {
+ continue;
+ }
+
+ array_push($errors, ...$this->findUseAliases($path, $relative));
+ }
+
+ return $errors;
+ }
+
+ /** @return list<string> */
+ private function findUseAliases(string $path, string $relative): array
+ {
+ $errors = [];
+ $inUse = false;
+ $braceDepth = 0;
+
+ foreach (file($path) as $idx => $raw) {
+ $code = explode('//', $raw, 2)[0];
+ $stripped = trim($code);
+
+ if (!$inUse) {
+ if (!preg_match(self::USE_ALIAS_START_RE, $stripped)) {
+ continue;
+ }
+ $inUse = true;
+ $braceDepth = 0;
+ }
+
+ if (preg_match_all('/\bas\s+([A-Za-z_][A-Za-z0-9_]*)/', $code, $m)) {
+ foreach ($m[1] as $name) {
+ if ($name === '_') {
+ continue;
+ }
+ if (preg_match(self::PASCAL_CASE_RE, $name)) {
+ continue;
+ }
+ $errors[] = "{$relative}:" . ($idx + 1) . ": `as {$name}` aliasing in `use` statement";
+ }
+ }
+
+ $braceDepth += substr_count($code, '{') - substr_count($code, '}');
+ if ($braceDepth <= 0 && str_ends_with(rtrim($code), ';')) {
+ $inUse = false;
+ $braceDepth = 0;
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/scripts/linters/src/Linters/SortedDependencies.php b/scripts/linters/src/Linters/SortedDependencies.php
new file mode 100644
index 00000000..78ea0eb2
--- /dev/null
+++ b/scripts/linters/src/Linters/SortedDependencies.php
@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint\Linters;
+
+use Shirabe\Lint\Linter;
+use Shirabe\Lint\Support\FileFinder;
+use Shirabe\Lint\Support\Paths;
+
+final class SortedDependencies implements Linter
+{
+ public function name(): string
+ {
+ return 'sorted_dependencies';
+ }
+
+ public function failureIntro(): string
+ {
+ return "Found unsorted `[dependencies]` / `[dev-dependencies]` in Cargo.toml.\n"
+ . 'Entries must be alphabetical, with `shirabe-*` crates listed before others:';
+ }
+
+ public function check(string $rootDir, array $excludes): array
+ {
+ $errors = [];
+
+ foreach (FileFinder::cargoTomls($rootDir) as $path) {
+ $relative = Paths::relativeTo($rootDir, $path);
+ if (in_array($relative, $excludes, true)) {
+ continue;
+ }
+
+ $sections = $this->parseDepSections(file_get_contents($path));
+
+ foreach (['dependencies', 'dev-dependencies'] as $section) {
+ $deps = $sections[$section] ?? [];
+ if ($deps === []) {
+ continue;
+ }
+
+ $expected = $this->sortDepNames($deps);
+ if ($deps === $expected) {
+ continue;
+ }
+
+ $errors[] = "{$relative} [{$section}]\n"
+ . ' actual: ' . implode(', ', $deps) . "\n"
+ . ' expected: ' . implode(', ', $expected);
+ }
+ }
+
+ return $errors;
+ }
+
+ /** @return array<string, list<string>> */
+ private function parseDepSections(string $content): array
+ {
+ $sections = [];
+ $current = null;
+
+ foreach (explode("\n", $content) as $line) {
+ $stripped = rtrim($line, "\r\n");
+
+ if (preg_match('/\A\s*\[([^\]]+)\]\s*\z/', $stripped, $m)) {
+ $current = $m[1];
+ $sections[$current] ??= [];
+ continue;
+ }
+
+ if ($current !== null && preg_match('/\A([A-Za-z0-9_-]+)\s*[.=]/', $stripped, $m)) {
+ $sections[$current][] = $m[1];
+ }
+ }
+
+ return $sections;
+ }
+
+ /**
+ * @param list<string> $deps
+ * @return list<string>
+ */
+ private function sortDepNames(array $deps): array
+ {
+ $shirabe = [];
+ $other = [];
+
+ foreach ($deps as $dep) {
+ if (str_starts_with($dep, 'shirabe-')) {
+ $shirabe[] = $dep;
+ } else {
+ $other[] = $dep;
+ }
+ }
+ sort($shirabe);
+ sort($other);
+
+ return array_merge($shirabe, $other);
+ }
+}
diff --git a/scripts/linters/src/Runner.php b/scripts/linters/src/Runner.php
new file mode 100644
index 00000000..ae2d84ed
--- /dev/null
+++ b/scripts/linters/src/Runner.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint;
+
+final class Runner
+{
+ /** @param list<array{0: Linter, 1: list<string>}> $linters */
+ public function __construct(
+ private readonly string $rootDir,
+ private readonly array $linters,
+ ) {
+ }
+
+ public function run(): bool
+ {
+ $allPassed = true;
+
+ foreach ($this->linters as [$linter, $excludes]) {
+ echo "===== {$linter->name()} =====\n";
+
+ $errors = $linter->check($this->rootDir, $excludes);
+ if ($errors === []) {
+ echo "Passed.\n";
+ } else {
+ echo $linter->failureIntro(), "\n";
+ foreach ($errors as $error) {
+ echo " {$error}\n";
+ }
+ $allPassed = false;
+ }
+
+ echo "\n";
+ }
+
+ return $allPassed;
+ }
+}
diff --git a/scripts/linters/src/Support/FileFinder.php b/scripts/linters/src/Support/FileFinder.php
new file mode 100644
index 00000000..50dabcd8
--- /dev/null
+++ b/scripts/linters/src/Support/FileFinder.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint\Support;
+
+final class FileFinder
+{
+ // Returns absolute paths to each crate's Cargo.toml (crates/*/Cargo.toml), sorted.
+ public static function cargoTomls(string $rootDir): array
+ {
+ $paths = glob("{$rootDir}/crates/*/Cargo.toml") ?: [];
+ sort($paths);
+
+ return $paths;
+ }
+
+ /** @return list<string> absolute paths to *.rs files under crates/, sorted */
+ public static function rustFiles(string $rootDir): array
+ {
+ return self::walk("{$rootDir}/crates", static fn (string $path): bool => str_ends_with($path, '.rs'));
+ }
+
+ // Returns absolute paths to mod.rs files under each crate's src/ tree
+ // (crates/*/src/**/mod.rs), sorted.
+ public static function modRsFiles(string $rootDir): array
+ {
+ $found = [];
+
+ foreach (glob("{$rootDir}/crates/*", GLOB_ONLYDIR) ?: [] as $crateDir) {
+ $srcDir = "{$crateDir}/src";
+ if (!is_dir($srcDir)) {
+ continue;
+ }
+
+ foreach (self::walk($srcDir, static fn (string $path): bool => basename($path) === 'mod.rs') as $path) {
+ $found[] = $path;
+ }
+ }
+
+ sort($found);
+
+ return $found;
+ }
+
+ /** @return list<string> */
+ private static function walk(string $baseDir, callable $predicate): array
+ {
+ if (!is_dir($baseDir)) {
+ return [];
+ }
+
+ $found = [];
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($baseDir, \FilesystemIterator::SKIP_DOTS),
+ );
+ foreach ($iterator as $file) {
+ /** @var \SplFileInfo $file */
+ if ($file->isFile() && $predicate($file->getPathname())) {
+ $found[] = $file->getPathname();
+ }
+ }
+
+ sort($found);
+
+ return $found;
+ }
+}
diff --git a/scripts/linters/src/Support/Paths.php b/scripts/linters/src/Support/Paths.php
new file mode 100644
index 00000000..2e500570
--- /dev/null
+++ b/scripts/linters/src/Support/Paths.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\Lint\Support;
+
+final class Paths
+{
+ public static function relativeTo(string $rootDir, string $path): string
+ {
+ $root = rtrim($rootDir, '/') . '/';
+
+ return str_starts_with($path, $root) ? substr($path, strlen($root)) : $path;
+ }
+}