rubocop-metz 0.5.2

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.

Potentially problematic release.


This version of rubocop-metz might be problematic. Click here for more details.

Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/lib/metz_scan/analyzers/contextual_node_walker.rb +82 -0
  4. data/lib/metz_scan/analyzers/cross_package_reference_metadata.rb +36 -0
  5. data/lib/metz_scan/analyzers/cross_package_reference_set.rb +33 -0
  6. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_collector.rb +54 -0
  7. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_reference.rb +37 -0
  8. data/lib/metz_scan/analyzers/implicit_context_pressure/current_attribute_collector.rb +73 -0
  9. data/lib/metz_scan/analyzers/implicit_context_pressure/reference_set.rb +37 -0
  10. data/lib/metz_scan/analyzers/implicit_context_pressure/thread_current_collector.rb +76 -0
  11. data/lib/metz_scan/analyzers/implicit_context_pressure/triage.rb +109 -0
  12. data/lib/metz_scan/analyzers/implicit_context_pressure.rb +139 -0
  13. data/lib/metz_scan/analyzers/inheritance_descendants/finding.rb +18 -0
  14. data/lib/metz_scan/analyzers/inheritance_descendants/metadata.rb +23 -0
  15. data/lib/metz_scan/analyzers/inheritance_descendants/root_kind.rb +58 -0
  16. data/lib/metz_scan/analyzers/inheritance_descendants/triage.rb +30 -0
  17. data/lib/metz_scan/analyzers/inheritance_descendants.rb +137 -0
  18. data/lib/metz_scan/analyzers/namespace_leak_pressure/finding.rb +20 -0
  19. data/lib/metz_scan/analyzers/namespace_leak_pressure/metadata.rb +40 -0
  20. data/lib/metz_scan/analyzers/namespace_leak_pressure/namespace.rb +41 -0
  21. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_collector.rb +50 -0
  22. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_set.rb +12 -0
  23. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage/exception_declaration.rb +117 -0
  24. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage.rb +69 -0
  25. data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +139 -0
  26. data/lib/metz_scan/analyzers/occurrence.rb +28 -0
  27. data/lib/metz_scan/analyzers/package_dependency_pressure/finding.rb +20 -0
  28. data/lib/metz_scan/analyzers/package_dependency_pressure/metadata.rb +27 -0
  29. data/lib/metz_scan/analyzers/package_dependency_pressure/package_map.rb +11 -0
  30. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_collector.rb +38 -0
  31. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_set.rb +12 -0
  32. data/lib/metz_scan/analyzers/package_dependency_pressure/shared_dependency_triage.rb +124 -0
  33. data/lib/metz_scan/analyzers/package_dependency_pressure.rb +139 -0
  34. data/lib/metz_scan/analyzers/package_map.rb +81 -0
  35. data/lib/metz_scan/analyzers/project_analyzer_triage.rb +20 -0
  36. data/lib/metz_scan/analyzers/repeated_branching/branch_site_collector.rb +138 -0
  37. data/lib/metz_scan/analyzers/repeated_branching/branch_value.rb +27 -0
  38. data/lib/metz_scan/analyzers/repeated_branching/contextual_node_walker.rb +11 -0
  39. data/lib/metz_scan/analyzers/repeated_branching/decision_subject.rb +46 -0
  40. data/lib/metz_scan/analyzers/repeated_branching/predicate_chain.rb +58 -0
  41. data/lib/metz_scan/analyzers/repeated_branching/triage.rb +28 -0
  42. data/lib/metz_scan/analyzers/repeated_branching.rb +135 -0
  43. data/lib/metz_scan/analyzers/repeated_query_criteria/triage.rb +103 -0
  44. data/lib/metz_scan/analyzers/repeated_query_criteria.rb +285 -0
  45. data/lib/metz_scan/analyzers/ruby_file_enumerator.rb +37 -0
  46. data/lib/metz_scan/analyzers/service_soup/service_call_pattern.rb +53 -0
  47. data/lib/metz_scan/analyzers/service_soup/setup_workflow_triage.rb +48 -0
  48. data/lib/metz_scan/analyzers/service_soup/workflow_collector.rb +130 -0
  49. data/lib/metz_scan/analyzers/service_soup.rb +128 -0
  50. data/lib/metz_scan/analyzers/subclass_override_pressure/family_builder.rb +69 -0
  51. data/lib/metz_scan/analyzers/subclass_override_pressure/finding.rb +30 -0
  52. data/lib/metz_scan/analyzers/subclass_override_pressure/metadata.rb +74 -0
  53. data/lib/metz_scan/analyzers/subclass_override_pressure/method_body_facts.rb +100 -0
  54. data/lib/metz_scan/analyzers/subclass_override_pressure/triage.rb +106 -0
  55. data/lib/metz_scan/analyzers/subclass_override_pressure.rb +133 -0
  56. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/artifact_writer.rb +51 -0
  57. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/baseline_delta.rb +231 -0
  58. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/collaborators.rb +76 -0
  59. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/markdown_renderer.rb +353 -0
  60. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +123 -0
  61. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/summary.rb +333 -0
  62. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/target_set.rb +139 -0
  63. data/lib/metz_scan/calibration/project_analyzer_evidence_runner.rb +64 -0
  64. data/lib/metz_scan/calibration/rubydex_drift_formatter.rb +91 -0
  65. data/lib/metz_scan/cli.rb +120 -0
  66. data/lib/metz_scan/commands/explain.rb +128 -0
  67. data/lib/metz_scan/commands/project_analyzers.rb +105 -0
  68. data/lib/metz_scan/commands/report.rb +115 -0
  69. data/lib/metz_scan/commands/rules.rb +92 -0
  70. data/lib/metz_scan/commands/scan/auto_fix.rb +138 -0
  71. data/lib/metz_scan/commands/scan/compliance_scorecard.rb +124 -0
  72. data/lib/metz_scan/commands/scan/github_annotations_renderer.rb +55 -0
  73. data/lib/metz_scan/commands/scan/offense_extractor.rb +42 -0
  74. data/lib/metz_scan/commands/scan/project_analyzer_breakdown.rb +55 -0
  75. data/lib/metz_scan/commands/scan/project_analyzer_metadata.rb +78 -0
  76. data/lib/metz_scan/commands/scan/project_analyzer_offenses.rb +85 -0
  77. data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +148 -0
  78. data/lib/metz_scan/commands/scan/project_analyzer_summary_aggregate_formatter.rb +74 -0
  79. data/lib/metz_scan/commands/scan/project_analyzer_summary_breakdown_formatter.rb +46 -0
  80. data/lib/metz_scan/commands/scan/project_analyzer_triage_formatter.rb +40 -0
  81. data/lib/metz_scan/commands/scan/project_analyzer_triage_priority.rb +38 -0
  82. data/lib/metz_scan/commands/scan/project_config_scope.rb +231 -0
  83. data/lib/metz_scan/commands/scan/runner.rb +199 -0
  84. data/lib/metz_scan/commands/scan/sarif_renderer.rb +103 -0
  85. data/lib/metz_scan/commands/scan/sarif_rule_descriptors.rb +82 -0
  86. data/lib/metz_scan/commands/scan/sarif_severity.rb +27 -0
  87. data/lib/metz_scan/commands/scan/target_ruby_version.rb +56 -0
  88. data/lib/metz_scan/commands/scan/text_renderer.rb +145 -0
  89. data/lib/metz_scan/commands/scan.rb +201 -0
  90. data/lib/metz_scan/project_index/null_backend.rb +35 -0
  91. data/lib/metz_scan/project_index/rubydex_backend/file_discovery.rb +33 -0
  92. data/lib/metz_scan/project_index/rubydex_backend/location_formatting.rb +36 -0
  93. data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +118 -0
  94. data/lib/metz_scan/project_index/rubydex_backend.rb +133 -0
  95. data/lib/metz_scan/project_index.rb +109 -0
  96. data/lib/metz_scan/version.rb +5 -0
  97. data/lib/metz_scan.rb +5 -0
  98. metadata +185 -0
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../analyzers/repeated_branching"
4
+ require_relative "../../analyzers/service_soup"
5
+ require_relative "../../analyzers/inheritance_descendants"
6
+ require_relative "../../analyzers/package_dependency_pressure"
7
+ require_relative "../../analyzers/namespace_leak_pressure"
8
+ require_relative "../../analyzers/implicit_context_pressure"
9
+ require_relative "../../analyzers/repeated_query_criteria"
10
+ require_relative "../../analyzers/subclass_override_pressure"
11
+ require_relative "../../project_index"
12
+ require_relative "runner"
13
+ require_relative "project_analyzer_metadata"
14
+ require_relative "project_analyzer_offenses"
15
+
16
+ module MetzScan
17
+ module Commands
18
+ class Scan
19
+ # Runs wrapper-level project analyzers and merges their findings into the
20
+ # same report shape produced by RuboCop's JSON formatter.
21
+ module ProjectAnalyzerRunner
22
+ ANALYZERS = [
23
+ Analyzers::RepeatedBranching,
24
+ Analyzers::ServiceSoup,
25
+ Analyzers::InheritanceDescendants,
26
+ Analyzers::PackageDependencyPressure,
27
+ Analyzers::NamespaceLeakPressure,
28
+ Analyzers::ImplicitContextPressure,
29
+ Analyzers::RepeatedQueryCriteria,
30
+ Analyzers::SubclassOverridePressure
31
+ ].freeze
32
+ INDEX_BACKED_ANALYZERS = [Analyzers::InheritanceDescendants, Analyzers::PackageDependencyPressure,
33
+ Analyzers::NamespaceLeakPressure, Analyzers::SubclassOverridePressure].freeze
34
+ DEFAULT_OUTPUT_STATUS = "validated"
35
+ DEFAULT_OUTPUT_CONFIDENCE = "medium"
36
+ DEFAULT_OUTPUT_TRIAGE_SEVERITY = "design pressure"
37
+
38
+ module_function
39
+
40
+ def merge!(parsed, paths, **)
41
+ findings = project_findings_for(paths, **)
42
+ return parsed if findings.empty?
43
+
44
+ merge_findings(parsed, findings)
45
+ parsed
46
+ end
47
+
48
+ def project_findings_for(paths, index: nil, default_output: false, force_default_config: false)
49
+ paths = analyzer_paths(paths, index: index, force_default_config: force_default_config)
50
+ return [] if paths.empty? && !index
51
+
52
+ findings = project_findings(paths, index: index, default_output: default_output)
53
+ default_output ? findings.select { |finding| default_output_finding?(finding) } : findings
54
+ end
55
+
56
+ def merge_findings(parsed, findings)
57
+ offense_set = ProjectAnalyzerOffenses.build(findings)
58
+ merge_offenses(parsed, offense_set.by_path)
59
+ update_summary(parsed, findings, offense_set.offenses)
60
+ end
61
+
62
+ def project_findings(paths, index: nil, default_output: false)
63
+ analyzers = analyzers_for(default_output: default_output)
64
+ index = project_index_for(paths, index, analyzers)
65
+ analyzers.flat_map { |analyzer| analyzer.new(paths: paths, index: index).call }
66
+ end
67
+
68
+ def analyzers_for(default_output:)
69
+ return ANALYZERS unless default_output
70
+
71
+ ANALYZERS.select { |analyzer| default_output_analyzer?(analyzer) }
72
+ end
73
+
74
+ def default_output_analyzer?(analyzer)
75
+ analyzer.const_defined?(:DEFAULT_OUTPUT_ELIGIBLE) &&
76
+ analyzer::DEFAULT_OUTPUT_ELIGIBLE &&
77
+ analyzer.const_defined?(:PROJECT_ANALYZER_STATUS) &&
78
+ analyzer::PROJECT_ANALYZER_STATUS == DEFAULT_OUTPUT_STATUS
79
+ end
80
+
81
+ def project_index_for(paths, index, analyzers)
82
+ return index if index
83
+ return nil unless analyzers.any? { |analyzer| INDEX_BACKED_ANALYZERS.include?(analyzer) }
84
+
85
+ ProjectIndex.build(paths)
86
+ end
87
+
88
+ def default_output_finding?(finding)
89
+ finding.project_analyzer_status == DEFAULT_OUTPUT_STATUS &&
90
+ finding.confidence == DEFAULT_OUTPUT_CONFIDENCE &&
91
+ finding.triage_severity == DEFAULT_OUTPUT_TRIAGE_SEVERITY
92
+ end
93
+
94
+ def analyzer_paths(paths, index: nil, force_default_config: false)
95
+ return Array(paths) if index
96
+
97
+ rubocop_target_files(paths, force_default_config: force_default_config)
98
+ end
99
+
100
+ def rubocop_target_files(paths, force_default_config: false)
101
+ return TargetFileDiscovery.with_forced_defaults(paths) if force_default_config
102
+
103
+ TargetFileDiscovery.for_project_config(paths)
104
+ end
105
+
106
+ def merge_offenses(parsed, grouped_offenses)
107
+ grouped_offenses.each do |path, offenses|
108
+ file_for(parsed, path)["offenses"].concat(offenses)
109
+ end
110
+ end
111
+
112
+ def file_for(parsed, path)
113
+ files = parsed["files"] ||= []
114
+ files.find { |file| same_path?(file["path"], path) } || append_file(files, path)
115
+ end
116
+
117
+ def same_path?(left, right)
118
+ File.expand_path(left) == File.expand_path(right)
119
+ end
120
+
121
+ def append_file(files, path)
122
+ { "path" => display_path(path), "offenses" => [] }.tap { |file| files << file }
123
+ end
124
+
125
+ def display_path(path)
126
+ expanded_path = File.expand_path(path)
127
+ cwd = "#{File.expand_path(Dir.pwd)}#{File::SEPARATOR}"
128
+ return expanded_path.delete_prefix(cwd) if expanded_path.start_with?(cwd)
129
+
130
+ path
131
+ end
132
+
133
+ def update_summary(parsed, findings, project_offenses)
134
+ summary = parsed["summary"] ||= {}
135
+ files = Array(parsed["files"])
136
+ summary["offense_count"] = files.sum { |file| Array(file["offenses"]).size }
137
+ update_file_counts(summary, files.size)
138
+ summary["project_analyzers"] = ProjectAnalyzerMetadata.summary(findings, project_offenses)
139
+ end
140
+
141
+ def update_file_counts(summary, file_count)
142
+ summary["target_file_count"] = [summary["target_file_count"].to_i, file_count].max
143
+ summary["inspected_file_count"] = [summary["inspected_file_count"].to_i, file_count].max
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Commands
5
+ class Scan
6
+ class ProjectAnalyzerSummaryAggregateFormatter
7
+ def initialize(summary)
8
+ @summary = summary
9
+ end
10
+
11
+ def lines
12
+ [counts_line("Analyzer counts", analyzer_counts),
13
+ counts_line("Confidence counts", breakdown_counts("confidence")),
14
+ counts_line("Severity counts", breakdown_counts("triage_severity")),
15
+ counts_line("Category counts", category_counts)].compact
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :summary
21
+
22
+ def analyzer_counts
23
+ rules.to_h { |rule| [rule.fetch("cop_name"), rule.fetch("finding_count").to_i] }
24
+ end
25
+
26
+ def breakdown_counts(key)
27
+ counts = counts_from_rule_breakdowns(key)
28
+ counts.empty? ? counts_from_rule_field(key) : counts
29
+ end
30
+
31
+ def category_counts
32
+ counts = counts_from_rule_breakdowns("project_analyzer_category", metadata: true)
33
+ counts.empty? ? counts_from_all_metadata_breakdowns : counts
34
+ end
35
+
36
+ def counts_from_rule_breakdowns(key, metadata: false)
37
+ rules.each_with_object(Hash.new(0)) do |rule, counts|
38
+ entries = metadata ? rule.dig("breakdowns", "metadata", key) : rule.dig("breakdowns", key)
39
+ Array(entries).each { |entry| counts[entry.fetch("value")] += entry.fetch("finding_count").to_i }
40
+ end
41
+ end
42
+
43
+ def counts_from_all_metadata_breakdowns
44
+ rules.each_with_object(Hash.new(0)) do |rule, counts|
45
+ rule.dig("breakdowns", "metadata").to_h.each_value do |entries|
46
+ Array(entries).each { |entry| counts[entry.fetch("value")] += entry.fetch("finding_count").to_i }
47
+ end
48
+ end
49
+ end
50
+
51
+ def counts_from_rule_field(key)
52
+ rules.each_with_object(Hash.new(0)) do |rule, counts|
53
+ value = rule[key]
54
+ counts[value] += rule.fetch("finding_count").to_i if value
55
+ end
56
+ end
57
+
58
+ def counts_line(label, counts)
59
+ return if counts.empty?
60
+
61
+ " #{label}: #{sorted_counts(counts).map { |value, count| "#{value}=#{count}" }.join(', ')}"
62
+ end
63
+
64
+ def sorted_counts(counts)
65
+ counts.sort_by { |value, count| [-count, value.to_s] }
66
+ end
67
+
68
+ def rules
69
+ @rules ||= Array(summary["rules"])
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Commands
5
+ class Scan
6
+ class ProjectAnalyzerSummaryBreakdownFormatter
7
+ def initialize(rule)
8
+ @rule = rule
9
+ end
10
+
11
+ def call
12
+ return if segments.empty?
13
+
14
+ "mix: #{segments.join('; ')}"
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :rule
20
+
21
+ def segments
22
+ [mixed_segment("severity", breakdowns["triage_severity"]),
23
+ *metadata_segments].compact
24
+ end
25
+
26
+ def breakdowns
27
+ rule.fetch("breakdowns", {})
28
+ end
29
+
30
+ def metadata_segments
31
+ breakdowns.fetch("metadata", {}).map { |key, values| mixed_segment(key, values) }
32
+ end
33
+
34
+ def mixed_segment(label, values)
35
+ return unless values.to_a.size > 1
36
+
37
+ "#{label} #{values.map { |value| label_for(value) }.join(', ')}"
38
+ end
39
+
40
+ def label_for(value)
41
+ "#{value.fetch('value')}=#{value.fetch('finding_count')}"
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Commands
5
+ class Scan
6
+ module ProjectAnalyzerTriageFormatter
7
+ module_function
8
+
9
+ def line(metadata)
10
+ return unless metadata
11
+
12
+ line_for(triage_details(metadata), metadata["triage_summary"])
13
+ end
14
+
15
+ def line_for(details, summary)
16
+ return unless details.any? || !blank?(summary)
17
+ return summary.to_s if details.empty?
18
+
19
+ ["Triage: #{details.join('; ')}.", summary].compact.join(" ")
20
+ end
21
+
22
+ def triage_details(metadata)
23
+ [
24
+ labeled("status", metadata["status"]),
25
+ labeled("confidence", metadata["confidence"]),
26
+ labeled("severity", metadata["triage_severity"])
27
+ ].compact
28
+ end
29
+
30
+ def labeled(name, value)
31
+ "#{name}: #{value}" unless blank?(value)
32
+ end
33
+
34
+ def blank?(value)
35
+ value.nil? || value.to_s.empty?
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Commands
5
+ class Scan
6
+ module ProjectAnalyzerTriagePriority
7
+ PRIORITIES = {
8
+ "status" => { "validated" => 0, "candidate" => 1, "experimental" => 2 },
9
+ "confidence" => { "high" => 0, "medium" => 1, "early" => 2, "low" => 3 },
10
+ "triage_severity" => {
11
+ "design pressure" => 0,
12
+ "manual review" => 1,
13
+ "context required" => 2,
14
+ "broad base" => 3,
15
+ "shared dependency" => 4,
16
+ "shared namespace" => 5,
17
+ "setup orchestration" => 6
18
+ }
19
+ }.freeze
20
+
21
+ module_function
22
+
23
+ def sort_key(metadata)
24
+ [
25
+ priority_for("status", metadata["status"]),
26
+ priority_for("confidence", metadata["confidence"]),
27
+ priority_for("triage_severity", metadata["triage_severity"])
28
+ ]
29
+ end
30
+
31
+ def priority_for(name, value)
32
+ priorities = PRIORITIES.fetch(name)
33
+ priorities.fetch(value, priorities.size)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "rubocop"
5
+
6
+ module MetzScan
7
+ module Commands
8
+ class Scan
9
+ # DDR: docs/ddrs/2026-07-08-rubocop-scope-only-config.md explains why default mode bypasses ConfigStore here.
10
+ module ProjectConfigScope
11
+ SCOPE_KEYS = %w[Include Exclude Includes Excludes].freeze
12
+ ALL_COPS_PROJECT_KEYS = (SCOPE_KEYS + %w[RubyInterpreters TargetRubyVersion]).freeze
13
+ DEFAULT_FILE = RuboCop::ConfigLoader::DEFAULT_FILE
14
+
15
+ module_function
16
+
17
+ def store
18
+ ConfigStore.new
19
+ end
20
+
21
+ def target_ruby_version(paths)
22
+ TargetRubyVersion.new(paths).version
23
+ end
24
+
25
+ # Accumulates [config_path, gem_name] pairs whenever an `inherit_gem`
26
+ # reference can't be resolved during scope-only config loading, so
27
+ # Runner can warn once per gem after a scan (see Next Queue item 1).
28
+ def unresolved_inherit_gems
29
+ @unresolved_inherit_gems ||= []
30
+ end
31
+
32
+ def reset_unresolved_inherit_gems!
33
+ @unresolved_inherit_gems = []
34
+ end
35
+
36
+ # Default mode drops file-scope Exclude from an inherit_gem whose gem
37
+ # isn't installed (docs/ddrs/2026-07-08-rubocop-scope-only-config.md);
38
+ # this turns that silent degrade into a one-line stderr note per gem.
39
+ def warn_unresolved_inherit_gems(stderr)
40
+ unresolved_inherit_gems.uniq { |(_path, gem_name)| gem_name }.each do |(path, gem_name)|
41
+ stderr.puts(unresolved_inherit_gem_message(path, gem_name))
42
+ end
43
+ end
44
+
45
+ def unresolved_inherit_gem_message(path, gem_name)
46
+ "metz-scan: note: #{path} inherits from gem `#{gem_name}` which is not installed; " \
47
+ "its file-scope Exclude is not applied — install the gem or use --all-cops"
48
+ end
49
+
50
+ class TargetRubyVersion
51
+ def initialize(paths, store: ConfigStore.new, loader: ConfigLoader.new)
52
+ @paths = paths
53
+ @store = store
54
+ @loader = loader
55
+ end
56
+
57
+ def version
58
+ config.target_ruby_version
59
+ rescue RuboCop::Error, Psych::Exception
60
+ nil
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :paths, :store, :loader
66
+
67
+ def config
68
+ loaded = store.for(target_path)
69
+ return loaded unless loaded.loaded_path == DEFAULT_FILE
70
+
71
+ loader.default_for(target_dir)
72
+ end
73
+
74
+ def target_path
75
+ Array(paths).first || RuboCop::PathUtil.pwd
76
+ end
77
+
78
+ def target_dir
79
+ expanded = File.expand_path(target_path)
80
+ File.directory?(expanded) ? expanded : File.dirname(expanded)
81
+ end
82
+ end
83
+
84
+ class ConfigStore
85
+ def initialize(loader: ConfigLoader.new)
86
+ @loader = loader
87
+ @path_cache = {}
88
+ @object_cache = {}
89
+ end
90
+
91
+ def for_file(file)
92
+ for_dir(File.dirname(file))
93
+ end
94
+
95
+ def for_pwd
96
+ for_dir(RuboCop::PathUtil.pwd)
97
+ end
98
+
99
+ def for(file_or_dir)
100
+ for_dir(File.directory?(file_or_dir) ? file_or_dir : File.dirname(file_or_dir))
101
+ end
102
+
103
+ def for_dir(dir)
104
+ path = @path_cache[dir] ||= RuboCop::ConfigLoader.configuration_file_for(dir)
105
+ @object_cache[path] ||= @loader.load(path)
106
+ end
107
+ end
108
+
109
+ class ConfigLoader
110
+ def default_for(dir)
111
+ path = File.join(File.expand_path(dir), ".rubocop.yml")
112
+ config = RuboCop::Config.new({}, path)
113
+ RuboCop::ConfigLoader.merge_with_default(config, path)
114
+ end
115
+
116
+ def load(path)
117
+ return RuboCop::ConfigLoader.default_configuration if path == DEFAULT_FILE
118
+
119
+ config = RuboCop::Config.new(scope_hash(path), path)
120
+ config.deprecation_check { |_message| nil }
121
+ config.make_excludes_absolute
122
+ RuboCop::ConfigLoader.merge_with_default(config, path)
123
+ end
124
+
125
+ private
126
+
127
+ def scope_hash(path, seen = [])
128
+ absolute_path = File.expand_path(path)
129
+ return {} if seen.include?(absolute_path)
130
+
131
+ raw_hash = RuboCop::ConfigLoader.load_yaml_configuration(absolute_path)
132
+ inherited = inherited_scope_hash(raw_hash, absolute_path, [*seen, absolute_path])
133
+ RuboCop::ConfigLoader.merge(inherited, local_scope_hash(raw_hash))
134
+ end
135
+
136
+ def inherited_scope_hash(raw_hash, path, seen)
137
+ inherited_paths(raw_hash, path).each_with_object({}) do |inherited_path, merged|
138
+ merged.replace(RuboCop::ConfigLoader.merge(merged, scope_hash(inherited_path, seen)))
139
+ end
140
+ end
141
+
142
+ def inherited_paths(raw_hash, path)
143
+ gem_inherited_paths(raw_hash["inherit_gem"], path) + local_inherited_paths(raw_hash["inherit_from"], path)
144
+ end
145
+
146
+ def local_inherited_paths(inherit_from, path)
147
+ Array(inherit_from).compact.flat_map do |entry|
148
+ next [] if remote_config?(entry)
149
+
150
+ inherited_path = absolute_path?(entry) ? entry : File.expand_path(entry, File.dirname(path))
151
+ glob_path?(inherited_path) ? Dir.glob(inherited_path) : [inherited_path]
152
+ end
153
+ end
154
+
155
+ def gem_inherited_paths(inherit_gem, path)
156
+ return [] unless inherit_gem.is_a?(Hash)
157
+
158
+ inherit_gem.flat_map { |gem_name, config_paths| paths_for_gem(gem_name, config_paths, path) }
159
+ end
160
+
161
+ def paths_for_gem(gem_name, config_paths, path)
162
+ gem_dir = gem_dir_for(gem_name, path)
163
+ return [] unless gem_dir
164
+
165
+ Array(config_paths).map { |config_path| File.join(gem_dir, config_path) }
166
+ end
167
+
168
+ def gem_dir_for(gem_name, path)
169
+ bundled_spec = bundled_specs[gem_name].first if defined?(Bundler)
170
+ return bundled_spec.full_gem_path if bundled_spec
171
+
172
+ Gem::Specification.find_by_name(gem_name).gem_dir
173
+ rescue Gem::LoadError
174
+ record_unresolved_gem(gem_name, path)
175
+ end
176
+
177
+ def record_unresolved_gem(gem_name, path)
178
+ ProjectConfigScope.unresolved_inherit_gems << [path, gem_name]
179
+ nil
180
+ end
181
+
182
+ def bundled_specs
183
+ Bundler.load.specs
184
+ rescue StandardError
185
+ {}
186
+ end
187
+
188
+ def local_scope_hash(raw_hash)
189
+ raw_hash.each_with_object({}) do |(key, value), scope_hash|
190
+ scope = scope_for(key, value)
191
+ scope_hash[key] = scope unless scope.empty?
192
+ end
193
+ end
194
+
195
+ def scope_for(key, value)
196
+ return copy_value(value) if key == "inherit_mode" && value.is_a?(Hash)
197
+ return {} unless value.is_a?(Hash)
198
+
199
+ scoped_settings(key, value)
200
+ end
201
+
202
+ def scoped_settings(key, value)
203
+ keys = key == "AllCops" ? ALL_COPS_PROJECT_KEYS : SCOPE_KEYS
204
+ value.each_with_object({}) do |(setting, setting_value), scope|
205
+ scope[setting] = copy_value(setting_value) if keys.include?(setting)
206
+ end
207
+ end
208
+
209
+ def copy_value(value)
210
+ return value.map { |item| copy_value(item) } if value.is_a?(Array)
211
+ return value.transform_values { |item| copy_value(item) } if value.is_a?(Hash)
212
+
213
+ value
214
+ end
215
+
216
+ def remote_config?(path)
217
+ path.to_s.start_with?("http://", "https://")
218
+ end
219
+
220
+ def absolute_path?(path)
221
+ Pathname.new(path.to_s).absolute?
222
+ end
223
+
224
+ def glob_path?(path)
225
+ path.match?(/[*?{}\[\]]/)
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end