misogi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.editorconfig +9 -0
- data/.misogi.yml.example +39 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +191 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +246 -0
- data/Rakefile +12 -0
- data/exe/misogi +6 -0
- data/lib/misogi/cli.rb +248 -0
- data/lib/misogi/configuration.rb +88 -0
- data/lib/misogi/parsed_content.rb +27 -0
- data/lib/misogi/parser/base.rb +22 -0
- data/lib/misogi/parser/ruby.rb +109 -0
- data/lib/misogi/rule/base.rb +33 -0
- data/lib/misogi/rule/rails.rb +165 -0
- data/lib/misogi/rule/rspec.rb +148 -0
- data/lib/misogi/rule/ruby_standard.rb +77 -0
- data/lib/misogi/validator.rb +40 -0
- data/lib/misogi/version.rb +5 -0
- data/lib/misogi/violation.rb +23 -0
- data/lib/misogi.rb +30 -0
- data/sig/misogi.rbs +4 -0
- metadata +68 -0
data/lib/misogi/cli.rb
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Misogi
|
|
6
|
+
# コマンドラインインターフェース
|
|
7
|
+
class CLI
|
|
8
|
+
attr_reader :options
|
|
9
|
+
|
|
10
|
+
def initialize(argv = ARGV)
|
|
11
|
+
@argv = argv
|
|
12
|
+
@options = {
|
|
13
|
+
rules: nil, # nilの場合は設定ファイルを使用
|
|
14
|
+
base_path: nil,
|
|
15
|
+
pattern: nil,
|
|
16
|
+
config_path: ".misogi.yml"
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# CLIを実行
|
|
21
|
+
# @return [Integer] 終了コード
|
|
22
|
+
def run
|
|
23
|
+
parse_options
|
|
24
|
+
|
|
25
|
+
# Railsルールが必要な場合はRails環境を読み込む
|
|
26
|
+
load_rails_environment_if_needed
|
|
27
|
+
|
|
28
|
+
files = collect_files
|
|
29
|
+
if files.empty?
|
|
30
|
+
warn "検証対象のファイルが見つかりませんでした"
|
|
31
|
+
return 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# CLIオプションでルールが明示的に指定されている場合は従来通りの動作
|
|
35
|
+
violations = if @options[:rules]
|
|
36
|
+
validate_with_cli_rules(files)
|
|
37
|
+
else
|
|
38
|
+
validate_with_config(files)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
display_violations(violations)
|
|
42
|
+
|
|
43
|
+
violations.empty? ? 0 : 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Railsルールが使用される場合にRails環境を読み込む
|
|
49
|
+
def load_rails_environment_if_needed
|
|
50
|
+
needs_rails = if @options[:rules]
|
|
51
|
+
@options[:rules].include?(:rails)
|
|
52
|
+
else
|
|
53
|
+
# 設定ファイルをチェック
|
|
54
|
+
config = load_configuration
|
|
55
|
+
config.rules.key?(:rails) || config.rules.key?("rails")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
return unless needs_rails
|
|
59
|
+
|
|
60
|
+
load_rails_environment
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Rails環境を読み込む
|
|
64
|
+
def load_rails_environment
|
|
65
|
+
# config/boot.rbが存在するかチェック
|
|
66
|
+
boot_file = "config/boot.rb"
|
|
67
|
+
environment_file = "config/environment.rb"
|
|
68
|
+
|
|
69
|
+
unless File.exist?(boot_file)
|
|
70
|
+
warn "警告: #{boot_file} が見つかりません。Railsプロジェクトのルートディレクトリで実行してください。"
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
unless File.exist?(environment_file)
|
|
75
|
+
warn "警告: #{environment_file} が見つかりません。Railsプロジェクトのルートディレクトリで実行してください。"
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Rails環境を読み込む
|
|
80
|
+
require File.expand_path(boot_file)
|
|
81
|
+
require File.expand_path(environment_file)
|
|
82
|
+
|
|
83
|
+
# Rails アプリケーションを初期化(既に初期化されていない場合)
|
|
84
|
+
::Rails.application.eager_load! if defined?(::Rails)
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
warn "警告: Rails環境の読み込みに失敗しました: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# オプションをパース
|
|
90
|
+
def parse_options
|
|
91
|
+
parser = OptionParser.new do |opts|
|
|
92
|
+
opts.banner = "使い方: misogi [オプション] [ファイル...]"
|
|
93
|
+
|
|
94
|
+
opts.on("-r", "--rules RULES", Array, "使用するルール (ruby_standard,rails,rspec)") do |rules|
|
|
95
|
+
@options[:rules] = rules.map(&:to_sym)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
opts.on("-b", "--base-path PATH", "ベースパス (デフォルト: lib)") do |path|
|
|
99
|
+
@options[:base_path] = path
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
opts.on("-p", "--pattern PATTERN", "検証するファイルパターン") do |pattern|
|
|
103
|
+
@options[:pattern] = pattern
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
opts.on("-c", "--config PATH", "設定ファイルのパス (デフォルト: .misogi.yml)") do |path|
|
|
107
|
+
@options[:config_path] = path
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
opts.on("-h", "--help", "ヘルプを表示") do
|
|
111
|
+
puts opts
|
|
112
|
+
exit 0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
opts.on("-v", "--version", "バージョンを表示") do
|
|
116
|
+
puts "Misogi #{Misogi::VERSION}"
|
|
117
|
+
exit 0
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
parser.parse!(@argv)
|
|
122
|
+
rescue OptionParser::InvalidOption => e
|
|
123
|
+
warn "エラー: #{e.message}"
|
|
124
|
+
warn parser.help
|
|
125
|
+
exit 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# 検証対象のファイルを収集
|
|
129
|
+
# @return [Array<String>] ファイルパスのリスト
|
|
130
|
+
def collect_files
|
|
131
|
+
if @argv.any?
|
|
132
|
+
# コマンドライン引数で指定されたファイル
|
|
133
|
+
@argv
|
|
134
|
+
elsif @options[:pattern]
|
|
135
|
+
# パターンで指定されたファイル
|
|
136
|
+
Dir.glob(@options[:pattern])
|
|
137
|
+
else
|
|
138
|
+
# デフォルト: lib, app, spec配下のRubyファイル
|
|
139
|
+
default_pattern = "{lib,app,spec}/**/*.rb"
|
|
140
|
+
Dir.glob(default_pattern)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# CLIルールで検証
|
|
145
|
+
# @param files [Array<String>] ファイルパスのリスト
|
|
146
|
+
# @return [Array<Violation>] 違反のリスト
|
|
147
|
+
def validate_with_cli_rules(files)
|
|
148
|
+
rules = @options[:rules].map do |rule_name|
|
|
149
|
+
create_rule(rule_name)
|
|
150
|
+
end.compact
|
|
151
|
+
|
|
152
|
+
validator = Validator.new(rules: rules)
|
|
153
|
+
validator.validate_files(files)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# 設定ファイルベースで検証
|
|
157
|
+
# @param files [Array<String>] ファイルパスのリスト
|
|
158
|
+
# @return [Array<Violation>] 違反のリスト
|
|
159
|
+
def validate_with_config(files)
|
|
160
|
+
config = load_configuration
|
|
161
|
+
|
|
162
|
+
# 除外パターンを適用
|
|
163
|
+
files = files.reject { |file| config.excluded?(file) }
|
|
164
|
+
|
|
165
|
+
# ファイルごとに適用するルールを決定して検証
|
|
166
|
+
files.flat_map do |file|
|
|
167
|
+
applicable_rules_config = config.rules_for(file)
|
|
168
|
+
next [] if applicable_rules_config.empty?
|
|
169
|
+
|
|
170
|
+
rules = applicable_rules_config.map do |rule_name, rule_config|
|
|
171
|
+
create_rule_from_config(rule_name, rule_config[:config])
|
|
172
|
+
end.compact
|
|
173
|
+
|
|
174
|
+
validator = Validator.new(rules: rules)
|
|
175
|
+
validator.validate_file(file)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# 設定ファイルをロード
|
|
180
|
+
# @return [Configuration]
|
|
181
|
+
def load_configuration
|
|
182
|
+
config = if File.exist?(@options[:config_path])
|
|
183
|
+
Configuration.new(config_path: @options[:config_path])
|
|
184
|
+
else
|
|
185
|
+
Configuration.default
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# CLIオプションでbase_pathが指定されている場合は設定を上書き
|
|
189
|
+
if @options[:base_path] && config.rules.key?(:ruby_standard)
|
|
190
|
+
config.rules[:ruby_standard][:config]["base_path"] = @options[:base_path]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
config
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# ルールを作成(CLIオプションから)
|
|
197
|
+
# @param rule_name [Symbol] ルール名
|
|
198
|
+
# @return [Rule::Base, nil] ルールインスタンス
|
|
199
|
+
def create_rule(rule_name)
|
|
200
|
+
case rule_name
|
|
201
|
+
when :ruby_standard
|
|
202
|
+
Rule::RubyStandard.new(base_path: @options[:base_path] || "lib")
|
|
203
|
+
when :rails
|
|
204
|
+
Rule::Rails.new
|
|
205
|
+
when :rspec
|
|
206
|
+
Rule::RSpec.new
|
|
207
|
+
else
|
|
208
|
+
warn "警告: 不明なルール '#{rule_name}' がスキップされました"
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# ルールを作成(設定ファイルから)
|
|
214
|
+
# @param rule_name [String, Symbol] ルール名
|
|
215
|
+
# @param config [Hash] ルール設定
|
|
216
|
+
# @return [Rule::Base, nil] ルールインスタンス
|
|
217
|
+
def create_rule_from_config(rule_name, config)
|
|
218
|
+
rule_name_sym = rule_name.to_sym
|
|
219
|
+
|
|
220
|
+
case rule_name_sym
|
|
221
|
+
when :ruby_standard
|
|
222
|
+
base_path = config["base_path"] || config[:base_path] || "lib"
|
|
223
|
+
Rule::RubyStandard.new(base_path: base_path)
|
|
224
|
+
when :rails
|
|
225
|
+
inflections_path = config["inflections_path"] || config[:inflections_path]
|
|
226
|
+
Rule::Rails.new(inflections_path: inflections_path)
|
|
227
|
+
when :rspec
|
|
228
|
+
Rule::RSpec.new
|
|
229
|
+
else
|
|
230
|
+
warn "警告: 不明なルール '#{rule_name}' がスキップされました"
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# 違反を表示
|
|
236
|
+
# @param violations [Array<Violation>] 違反のリスト
|
|
237
|
+
def display_violations(violations)
|
|
238
|
+
if violations.empty?
|
|
239
|
+
puts "✓ 違反は見つかりませんでした"
|
|
240
|
+
else
|
|
241
|
+
puts "✗ #{violations.size}件の違反が見つかりました:\n\n"
|
|
242
|
+
violations.each do |violation|
|
|
243
|
+
puts violation
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Misogi
|
|
6
|
+
# 設定を管理するクラス
|
|
7
|
+
class Configuration
|
|
8
|
+
DEFAULT_CONFIG = {
|
|
9
|
+
"rules" => {
|
|
10
|
+
"ruby_standard" => {
|
|
11
|
+
"patterns" => ["lib/**/*.rb"],
|
|
12
|
+
"base_path" => "lib"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"exclude" => []
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :rules, :exclude
|
|
19
|
+
|
|
20
|
+
# @param config_path [String, nil] 設定ファイルのパス
|
|
21
|
+
def initialize(config_path: ".misogi.yml")
|
|
22
|
+
@config_path = config_path
|
|
23
|
+
@config = load_config
|
|
24
|
+
@rules = parse_rules(@config["rules"] || {})
|
|
25
|
+
@exclude = @config["exclude"] || []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# ファイルが除外対象かどうかをチェック
|
|
29
|
+
# @param file_path [String] ファイルパス
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def excluded?(file_path)
|
|
32
|
+
@exclude.any? { |pattern| File.fnmatch?(pattern, file_path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 指定されたファイルに適用すべきルールを取得
|
|
36
|
+
# @param file_path [String] ファイルパス
|
|
37
|
+
# @return [Hash] ルール名とその設定のハッシュ
|
|
38
|
+
def rules_for(file_path)
|
|
39
|
+
@rules.select do |_rule_name, rule_config|
|
|
40
|
+
rule_config[:patterns].any? do |pattern|
|
|
41
|
+
File.fnmatch?(pattern, file_path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# デフォルト設定を使用
|
|
47
|
+
# @return [Configuration]
|
|
48
|
+
def self.default
|
|
49
|
+
config = new(config_path: nil)
|
|
50
|
+
config.instance_variable_set(:@config, DEFAULT_CONFIG)
|
|
51
|
+
config.instance_variable_set(:@rules, parse_rules(DEFAULT_CONFIG["rules"]))
|
|
52
|
+
config.instance_variable_set(:@exclude, DEFAULT_CONFIG["exclude"])
|
|
53
|
+
config
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ルール設定をパース
|
|
57
|
+
# @param rules_config [Hash] ルール設定
|
|
58
|
+
# @return [Hash] パースされたルール設定
|
|
59
|
+
def self.parse_rules(rules_config)
|
|
60
|
+
rules_config.transform_keys(&:to_sym).transform_values do |config|
|
|
61
|
+
{
|
|
62
|
+
patterns: Array(config["patterns"] || config[:patterns]),
|
|
63
|
+
config: config.except("patterns", :patterns)
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# 設定ファイルを読み込む
|
|
71
|
+
# @return [Hash]
|
|
72
|
+
def load_config
|
|
73
|
+
if @config_path && File.exist?(@config_path)
|
|
74
|
+
YAML.load_file(@config_path) || {}
|
|
75
|
+
else
|
|
76
|
+
DEFAULT_CONFIG.dup
|
|
77
|
+
end
|
|
78
|
+
rescue Psych::SyntaxError => e
|
|
79
|
+
warn "警告: 設定ファイルの読み込みに失敗しました: #{e.message}"
|
|
80
|
+
warn "デフォルト設定を使用します"
|
|
81
|
+
DEFAULT_CONFIG.dup
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_rules(rules_config)
|
|
85
|
+
self.class.parse_rules(rules_config)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Misogi
|
|
4
|
+
# ファイルのパース結果を保持するクラス
|
|
5
|
+
class ParsedContent
|
|
6
|
+
attr_reader :namespaces
|
|
7
|
+
|
|
8
|
+
# @param namespaces [Array<String>] ファイル内で定義されている名前空間のリスト
|
|
9
|
+
# 例: ["Misogi::Rule::Base"] は Misogi::Rule::Base クラスが定義されていることを示す
|
|
10
|
+
def initialize(namespaces: [])
|
|
11
|
+
@namespaces = namespaces
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# 指定された名前空間が定義されているかチェック
|
|
15
|
+
# @param namespace [String] チェックする名前空間
|
|
16
|
+
# @return [Boolean]
|
|
17
|
+
def include?(namespace)
|
|
18
|
+
namespaces.include?(namespace)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# パース結果が空かどうか
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def empty?
|
|
24
|
+
namespaces.empty?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Misogi
|
|
4
|
+
module Parser
|
|
5
|
+
# すべてのパーサーの基底クラス
|
|
6
|
+
class Base
|
|
7
|
+
# ファイル内容をパースする
|
|
8
|
+
# @param content [String] ファイルの内容
|
|
9
|
+
# @return [ParsedContent] パース結果
|
|
10
|
+
def parse(content)
|
|
11
|
+
raise NotImplementedError, "#{self.class}#parseを実装してください"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# ファイルをパースできるか判定する
|
|
15
|
+
# @param file_path [String] ファイルパス
|
|
16
|
+
# @return [Boolean]
|
|
17
|
+
def parsable?(file_path)
|
|
18
|
+
raise NotImplementedError, "#{self.class}#parsable?を実装してください"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module Misogi
|
|
6
|
+
module Parser
|
|
7
|
+
# Rubyファイルのパーサー
|
|
8
|
+
# Ripperを使用してRubyコードを解析し、定義されているクラス/モジュールを抽出する
|
|
9
|
+
class Ruby < Base
|
|
10
|
+
# @param file_path [String] ファイルパス
|
|
11
|
+
# @return [Boolean] .rbファイルかどうか
|
|
12
|
+
def parsable?(file_path)
|
|
13
|
+
File.extname(file_path) == ".rb"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param content [String] Rubyファイルの内容
|
|
17
|
+
# @return [ParsedContent] パース結果
|
|
18
|
+
def parse(content)
|
|
19
|
+
namespaces = extract_namespaces(content)
|
|
20
|
+
ParsedContent.new(namespaces: namespaces)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Rubyコードから名前空間を抽出する
|
|
26
|
+
# @param content [String] Rubyコードの内容
|
|
27
|
+
# @return [Array<String>] 名前空間のリスト
|
|
28
|
+
def extract_namespaces(content)
|
|
29
|
+
sexp = Ripper.sexp(content)
|
|
30
|
+
return [] unless sexp
|
|
31
|
+
|
|
32
|
+
namespaces = []
|
|
33
|
+
traverse_sexp(sexp, [], namespaces)
|
|
34
|
+
namespaces
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# S式を再帰的に走査して、クラス/モジュール定義を見つける
|
|
38
|
+
# @param node [Array] S式のノード
|
|
39
|
+
# @param current_namespace [Array<String>] 現在の名前空間のスタック
|
|
40
|
+
# @param namespaces [Array<String>] 見つかった名前空間を格納する配列
|
|
41
|
+
def traverse_sexp(node, current_namespace, namespaces)
|
|
42
|
+
return unless node.is_a?(Array)
|
|
43
|
+
|
|
44
|
+
case node[0]
|
|
45
|
+
when :class
|
|
46
|
+
# クラス定義: [:class, [:const_ref, [:@const, "ClassName", ...]], ...]
|
|
47
|
+
class_name = extract_const_path(node[1])
|
|
48
|
+
if class_name
|
|
49
|
+
full_name = (current_namespace + [class_name]).join("::")
|
|
50
|
+
namespaces << full_name
|
|
51
|
+
# クラス本体を再帰的に処理
|
|
52
|
+
traverse_sexp(node[3], current_namespace + [class_name], namespaces) if node[3]
|
|
53
|
+
end
|
|
54
|
+
when :module
|
|
55
|
+
# モジュール定義: [:module, [:const_ref, [:@const, "ModuleName", ...]], ...]
|
|
56
|
+
module_name = extract_const_path(node[1])
|
|
57
|
+
if module_name
|
|
58
|
+
full_name = (current_namespace + [module_name]).join("::")
|
|
59
|
+
namespaces << full_name
|
|
60
|
+
# モジュール本体を再帰的に処理
|
|
61
|
+
traverse_sexp(node[2], current_namespace + [module_name], namespaces) if node[2]
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
# その他のノードは子ノードを再帰的に処理
|
|
65
|
+
node.each do |child|
|
|
66
|
+
traverse_sexp(child, current_namespace, namespaces) if child.is_a?(Array)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 定数パスから名前を抽出する
|
|
72
|
+
# @param node [Array] 定数パスのノード
|
|
73
|
+
# @return [String, nil] 定数名
|
|
74
|
+
def extract_const_path(node)
|
|
75
|
+
return nil unless node.is_a?(Array)
|
|
76
|
+
|
|
77
|
+
case node[0]
|
|
78
|
+
when :const_ref
|
|
79
|
+
# [:const_ref, [:@const, "Name", ...]]
|
|
80
|
+
node[1][1] if node[1] && node[1][0] == :@const
|
|
81
|
+
when :const_path_ref
|
|
82
|
+
# [:const_path_ref, parent, [:@const, "Name", ...]]
|
|
83
|
+
# 例: Foo::Bar の場合
|
|
84
|
+
parent = extract_const_path(node[1])
|
|
85
|
+
child = node[2][1] if node[2] && node[2][0] == :@const
|
|
86
|
+
parent && child ? "#{parent}::#{child}" : nil
|
|
87
|
+
when :const_path_field
|
|
88
|
+
# [:const_path_field, parent, [:@const, "Name", ...]]
|
|
89
|
+
# クラス定義でのコンパクト記法の場合
|
|
90
|
+
parent = extract_const_path(node[1])
|
|
91
|
+
child = node[2][1] if node[2] && node[2][0] == :@const
|
|
92
|
+
parent && child ? "#{parent}::#{child}" : nil
|
|
93
|
+
when :top_const_ref
|
|
94
|
+
# [:top_const_ref, [:@const, "Name", ...]]
|
|
95
|
+
# 例: ::Foo の場合
|
|
96
|
+
node[1][1] if node[1] && node[1][0] == :@const
|
|
97
|
+
when :top_const_field
|
|
98
|
+
# [:top_const_field, [:@const, "Name", ...]]
|
|
99
|
+
# トップレベル定数のフィールド
|
|
100
|
+
node[1][1] if node[1] && node[1][0] == :@const
|
|
101
|
+
when :var_ref
|
|
102
|
+
# [:var_ref, [:@const, "Name", ...]]
|
|
103
|
+
# 定数の参照
|
|
104
|
+
node[1][1] if node[1] && node[1][0] == :@const
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Misogi
|
|
4
|
+
module Rule
|
|
5
|
+
# すべてのルールの基底クラス
|
|
6
|
+
# カスタムルールを作成する場合は、このクラスを継承して#validateメソッドを実装する
|
|
7
|
+
class Base
|
|
8
|
+
# ルール名を返す(デフォルトはクラス名)
|
|
9
|
+
# @return [String]
|
|
10
|
+
def name
|
|
11
|
+
self.class.name.split("::").last
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# ファイルパスとパース結果を検証する
|
|
15
|
+
# @param file_path [String] 検証対象のファイルパス
|
|
16
|
+
# @param parsed_content [ParsedContent] パース結果
|
|
17
|
+
# @return [Array<Violation>] 検出された違反のリスト
|
|
18
|
+
def validate(file_path, parsed_content)
|
|
19
|
+
raise NotImplementedError, "#{self.class}#validateを実装してください"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
protected
|
|
23
|
+
|
|
24
|
+
# 違反を作成するヘルパーメソッド
|
|
25
|
+
# @param file_path [String] ファイルパス
|
|
26
|
+
# @param message [String] 違反メッセージ
|
|
27
|
+
# @return [Violation]
|
|
28
|
+
def violation(file_path:, message:)
|
|
29
|
+
Violation.new(file_path: file_path, message: message, rule_name: name)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Misogi
|
|
4
|
+
module Rule
|
|
5
|
+
# Railsの規約に従ってファイルパスとクラス/モジュール名の対応をチェックするルール
|
|
6
|
+
# 例:
|
|
7
|
+
# app/models/user.rb -> User
|
|
8
|
+
# app/controllers/users_controller.rb -> UsersController
|
|
9
|
+
# app/services/foo/bar_service.rb -> Foo::BarService
|
|
10
|
+
class Rails < Base
|
|
11
|
+
# Railsのディレクトリとその規約のマッピング
|
|
12
|
+
DIRECTORY_PATTERNS = {
|
|
13
|
+
"app/models" => { suffix: "" },
|
|
14
|
+
"app/controllers" => { suffix: "" },
|
|
15
|
+
"app/helpers" => { suffix: "" },
|
|
16
|
+
"app/mailers" => { suffix: "" },
|
|
17
|
+
"app/jobs" => { suffix: "" },
|
|
18
|
+
"app/services" => { suffix: "" },
|
|
19
|
+
"app/decorators" => { suffix: "" },
|
|
20
|
+
"app/presenters" => { suffix: "" },
|
|
21
|
+
"app/validators" => { suffix: "" },
|
|
22
|
+
"app/policies" => { suffix: "" },
|
|
23
|
+
"app/channels" => { suffix: "" },
|
|
24
|
+
"app/mailboxes" => { suffix: "" }
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(inflections_path: nil)
|
|
28
|
+
super()
|
|
29
|
+
@inflections_path = inflections_path # 後方互換性のため残すが使用しない
|
|
30
|
+
@active_support_available = check_active_support
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param file_path [String] 検証対象のファイルパス
|
|
34
|
+
# @param parsed_content [ParsedContent] パース結果
|
|
35
|
+
# @return [Array<Violation>] 検出された違反のリスト
|
|
36
|
+
def validate(file_path, parsed_content)
|
|
37
|
+
pattern_info = find_matching_pattern(file_path)
|
|
38
|
+
return [] unless pattern_info
|
|
39
|
+
|
|
40
|
+
violations = []
|
|
41
|
+
|
|
42
|
+
if parsed_content.empty?
|
|
43
|
+
violations << violation(
|
|
44
|
+
file_path: file_path,
|
|
45
|
+
message: "ファイルにクラスまたはモジュールが定義されていません"
|
|
46
|
+
)
|
|
47
|
+
return violations
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# concernsディレクトリ内のファイルでは、名前空間に"Concerns::"を含んではいけない
|
|
51
|
+
if file_path.include?("/concerns/")
|
|
52
|
+
concerns_violation = check_concerns_namespace(file_path, parsed_content)
|
|
53
|
+
violations << concerns_violation if concerns_violation
|
|
54
|
+
return violations if concerns_violation
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 定義されている各名前空間について期待されるパスを計算
|
|
58
|
+
base_path = pattern_info[:base_path]
|
|
59
|
+
expected_paths = parsed_content.namespaces.flat_map do |namespace|
|
|
60
|
+
namespace_to_paths(namespace, base_path, file_path)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# 実際のファイルパスが期待されるパスのいずれかと一致するか確認
|
|
64
|
+
unless expected_paths.include?(file_path)
|
|
65
|
+
expected_paths_str = expected_paths.map { |p| "`#{p}`" }.join(" または ")
|
|
66
|
+
defined_namespaces = parsed_content.namespaces.join(", ")
|
|
67
|
+
violations << violation(
|
|
68
|
+
file_path: file_path,
|
|
69
|
+
message: "名前空間 '#{defined_namespaces}' は #{expected_paths_str} に配置すべきです"
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
violations
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# concernsディレクトリ内のファイルの名前空間をチェック
|
|
79
|
+
# @param file_path [String] ファイルパス
|
|
80
|
+
# @param parsed_content [ParsedContent] パース結果
|
|
81
|
+
# @return [Violation, nil] 違反がある場合はViolationオブジェクト
|
|
82
|
+
def check_concerns_namespace(file_path, parsed_content)
|
|
83
|
+
# concernsディレクトリ内のファイルでは、名前空間に"Concerns::"を含んではいけない
|
|
84
|
+
invalid_namespaces = parsed_content.namespaces.select do |namespace|
|
|
85
|
+
namespace.start_with?("Concerns::") || namespace.include?("::Concerns::")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
return nil if invalid_namespaces.empty?
|
|
89
|
+
|
|
90
|
+
violation(
|
|
91
|
+
file_path: file_path,
|
|
92
|
+
message: "concernsディレクトリ内のファイルでは、名前空間に 'Concerns::' を含めるべきではありません。" \
|
|
93
|
+
"定義されている名前空間: #{invalid_namespaces.join(", ")}"
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# ファイルパスに一致するパターンを見つける
|
|
98
|
+
# @param file_path [String] ファイルパス
|
|
99
|
+
# @return [Hash, nil] パターン情報
|
|
100
|
+
def find_matching_pattern(file_path)
|
|
101
|
+
DIRECTORY_PATTERNS.each do |base_path, options|
|
|
102
|
+
return { base_path: base_path, **options } if file_path.start_with?(base_path)
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# 名前空間から期待されるファイルパスを生成する(複数の可能性を返す)
|
|
108
|
+
# @param namespace [String] 名前空間(例: "Foo::Bar")
|
|
109
|
+
# @param base_path [String] ベースパス(例: "app/models")
|
|
110
|
+
# @param current_file_path [String] 現在検証中のファイルパス
|
|
111
|
+
# @return [Array<String>] 期待されるファイルパスのリスト
|
|
112
|
+
def namespace_to_paths(namespace, base_path, current_file_path)
|
|
113
|
+
# 名前空間をパーツに分割
|
|
114
|
+
parts = namespace.split("::")
|
|
115
|
+
|
|
116
|
+
# 各パーツをスネークケースに変換
|
|
117
|
+
snake_parts = parts.map { |part| underscore(part) }
|
|
118
|
+
|
|
119
|
+
paths = []
|
|
120
|
+
|
|
121
|
+
# 通常のパス
|
|
122
|
+
paths << "#{File.join(base_path, *snake_parts)}.rb"
|
|
123
|
+
|
|
124
|
+
# concernsディレクトリ内のパス
|
|
125
|
+
# 現在のファイルがconcernsディレクトリ内にある場合、
|
|
126
|
+
# かつ名前空間が"Concerns::"で始まらない場合のみ、concernsパスも候補に含める
|
|
127
|
+
# (concernsディレクトリは名前空間に含めないRailsの規約のため)
|
|
128
|
+
if current_file_path.include?("/concerns/") && !namespace.start_with?("Concerns::")
|
|
129
|
+
paths << "#{File.join(base_path, "concerns", *snake_parts)}.rb"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
paths
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ActiveSupportが利用可能かチェック
|
|
136
|
+
# @return [Boolean] ActiveSupportが利用可能かどうか
|
|
137
|
+
def check_active_support
|
|
138
|
+
# Rails環境が読み込まれている場合は、ActiveSupportも利用可能
|
|
139
|
+
return true if defined?(::Rails)
|
|
140
|
+
|
|
141
|
+
# Rails環境がない場合は、ActiveSupportを直接読み込んでみる
|
|
142
|
+
require "active_support/inflector"
|
|
143
|
+
true
|
|
144
|
+
rescue LoadError
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# キャメルケースをスネークケースに変換
|
|
149
|
+
# @param str [String] キャメルケースの文字列
|
|
150
|
+
# @return [String] スネークケースの文字列
|
|
151
|
+
def underscore(str)
|
|
152
|
+
if @active_support_available
|
|
153
|
+
# ActiveSupportが利用可能な場合はそれを使用(inflectionsを考慮)
|
|
154
|
+
ActiveSupport::Inflector.underscore(str)
|
|
155
|
+
else
|
|
156
|
+
# フォールバック: 単純な変換
|
|
157
|
+
str
|
|
158
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
159
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
160
|
+
.downcase
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|