quality_gate 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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +571 -0
  5. data/config/37signals.yml +26 -0
  6. data/config/cops.yml +37 -0
  7. data/config/reek.yml +6 -0
  8. data/config/rubocop.yml +60 -0
  9. data/config/ruby.yml +8 -0
  10. data/docs/codex.md +31 -0
  11. data/docs/dogfood-log.md +47 -0
  12. data/docs/incidents.md +18 -0
  13. data/docs/releasing.md +46 -0
  14. data/exe/quality_gate +6 -0
  15. data/lib/generators/quality_gate/install/install_generator.rb +66 -0
  16. data/lib/generators/quality_gate/install/templates/agents_section.md.tt +15 -0
  17. data/lib/generators/quality_gate/install/templates/bullet.rb.tt +10 -0
  18. data/lib/generators/quality_gate/install/templates/claude_settings.json.tt +29 -0
  19. data/lib/generators/quality_gate/install/templates/hook_log_filesystem.rb.tt +76 -0
  20. data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +42 -0
  21. data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +248 -0
  22. data/lib/generators/quality_gate/install/templates/quality_gate_verify_stop.rb.tt +466 -0
  23. data/lib/generators/quality_gate/install/templates/rubocop.yml.tt +1 -0
  24. data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +15 -0
  25. data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +40 -0
  26. data/lib/generators/quality_gate/install/templates/ruby_rubocop.yml.tt +1 -0
  27. data/lib/generators/quality_gate/install/templates/ruby_simplecov.rb.tt +11 -0
  28. data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +10 -0
  29. data/lib/generators/quality_gate/install/templates/strong_migrations.rb.tt +10 -0
  30. data/lib/quality_gate/adapter.rb +328 -0
  31. data/lib/quality_gate/adapters/brakeman.rb +125 -0
  32. data/lib/quality_gate/adapters/bundler_audit.rb +235 -0
  33. data/lib/quality_gate/adapters/reek.rb +108 -0
  34. data/lib/quality_gate/adapters/rubocop.rb +142 -0
  35. data/lib/quality_gate/adapters/simplecov.rb +103 -0
  36. data/lib/quality_gate/adapters/test_suite.rb +81 -0
  37. data/lib/quality_gate/adapters/undercover.rb +357 -0
  38. data/lib/quality_gate/cli.rb +451 -0
  39. data/lib/quality_gate/config.rb +290 -0
  40. data/lib/quality_gate/exit_code.rb +14 -0
  41. data/lib/quality_gate/finding.rb +50 -0
  42. data/lib/quality_gate/hook_log.rb +131 -0
  43. data/lib/quality_gate/init_command.rb +90 -0
  44. data/lib/quality_gate/installation.rb +1577 -0
  45. data/lib/quality_gate/installer.rb +104 -0
  46. data/lib/quality_gate/railtie.rb +16 -0
  47. data/lib/quality_gate/reporters/field_sanitizer.rb +42 -0
  48. data/lib/quality_gate/reporters/json.rb +59 -0
  49. data/lib/quality_gate/reporters/text.rb +45 -0
  50. data/lib/quality_gate/rubocop.rb +34 -0
  51. data/lib/quality_gate/ruby_profile.rb +170 -0
  52. data/lib/quality_gate/runner.rb +150 -0
  53. data/lib/quality_gate/version.rb +5 -0
  54. data/lib/quality_gate.rb +27 -0
  55. data/lib/rubocop/cop/quality_gate/association_default_block_value.rb +46 -0
  56. data/lib/rubocop/cop/quality_gate/broadcast_in_controller.rb +64 -0
  57. data/lib/rubocop/cop/quality_gate/controller_instance_variables.rb +47 -0
  58. data/lib/rubocop/cop/quality_gate/prefer_after_save_commit.rb +84 -0
  59. data/lib/rubocop/cop/quality_gate/private_only_concern.rb +77 -0
  60. data/llm.txt +13 -0
  61. data/sig/quality_gate.rbs +226 -0
  62. metadata +260 -0
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module QualityGate
6
+ # Defers recognizable request-context defaults until association initialization.
7
+ class AssociationDefaultBlockValue < Base
8
+ MSG = "Wrap the association default in a lambda so Current is read when the record is initialized."
9
+ RESTRICT_ON_SEND = %i[belongs_to].freeze
10
+
11
+ def on_send(node)
12
+ return if node.receiver && !node.receiver.self_type?
13
+
14
+ value = default_value(node.last_argument)
15
+ return unless value && current_read?(value)
16
+
17
+ add_offense(value)
18
+ end
19
+
20
+ private
21
+
22
+ def default_value(options)
23
+ return unless literal_options?(options)
24
+
25
+ defaults = options.pairs.select { |pair| pair.key.sym_type? && pair.key.value == :default }
26
+ defaults.first.value if defaults.one?
27
+ end
28
+
29
+ def literal_options?(options)
30
+ options&.hash_type? && options.children.all?(&:pair_type?)
31
+ end
32
+
33
+ def current_read?(node)
34
+ return false unless node.send_type? || node.csend_type?
35
+ return false if %i[method public_method to_proc].include?(node.method_name)
36
+
37
+ receiver = node.receiver
38
+ return false unless receiver
39
+ return receiver.const_name == "Current" if receiver.const_type?
40
+
41
+ current_read?(receiver)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module QualityGate
6
+ # Opt-in convention for moving explicit broadcasts into domain behavior.
7
+ class BroadcastInController < Base
8
+ MSG = "Consider moving this broadcast from the controller into model-owned behavior."
9
+ BROADCAST_METHODS = %i[
10
+ broadcast_action broadcast_action_to broadcast_action_later broadcast_action_later_to
11
+ broadcast_append broadcast_append_to broadcast_append_later broadcast_append_later_to
12
+ broadcast_prepend broadcast_prepend_to broadcast_prepend_later broadcast_prepend_later_to
13
+ broadcast_replace broadcast_replace_to broadcast_replace_later broadcast_replace_later_to
14
+ broadcast_update broadcast_update_to broadcast_update_later broadcast_update_later_to
15
+ broadcast_remove broadcast_remove_to
16
+ broadcast_before broadcast_before_to broadcast_before_later broadcast_before_later_to
17
+ broadcast_after broadcast_after_to broadcast_after_later broadcast_after_later_to
18
+ broadcast_render broadcast_render_to broadcast_render_later broadcast_render_later_to
19
+ broadcast_refresh broadcast_refresh_to broadcast_refresh_later broadcast_refresh_later_to
20
+ broadcast_stream_to
21
+ ].freeze
22
+
23
+ def on_send(node)
24
+ return unless controller?(node)
25
+ return if Array(cop_config["AllowedMethods"]).include?(node.method_name.to_s)
26
+ return unless broadcast?(node)
27
+
28
+ add_offense(node.loc.selector)
29
+ end
30
+ alias on_csend on_send
31
+
32
+ private
33
+
34
+ def controller?(node)
35
+ enclosing_class = node.each_ancestor(:class).first
36
+ if enclosing_class
37
+ enclosing_class.identifier.const_name.end_with?("Controller")
38
+ else
39
+ processed_source.file_path.include?("/app/controllers/")
40
+ end
41
+ end
42
+
43
+ def broadcast?(node)
44
+ BROADCAST_METHODS.include?(node.method_name) || channel_broadcast?(node) || cable_broadcast?(node)
45
+ end
46
+
47
+ def channel_broadcast?(node)
48
+ node.method?(:broadcast_to) && node.receiver&.const_type? &&
49
+ node.receiver.const_name.end_with?("Channel")
50
+ end
51
+
52
+ def cable_broadcast?(node)
53
+ node.method?(:broadcast) && action_cable_server?(node.receiver)
54
+ end
55
+
56
+ def action_cable_server?(receiver)
57
+ receiver&.send_type? && receiver.method?(:server) &&
58
+ receiver.arguments.empty? && receiver.receiver&.const_type? &&
59
+ receiver.receiver.const_name == "ActionCable"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module QualityGate
6
+ # Sandi Metz rule four: a controller action instantiates one object.
7
+ # Only public instance methods of a *Controller class count as actions.
8
+ class ControllerInstanceVariables < Base
9
+ include VisibilityHelp
10
+ extend ExcludeLimit
11
+
12
+ MSG = "Action `%<action>s` assigns %<count>d instance variables (%<names>s); the limit is %<max>d."
13
+ OWNER_TYPES = %i[class sclass module def defs].freeze
14
+
15
+ exclude_limit "Max"
16
+
17
+ def on_def(node)
18
+ return unless action?(node)
19
+
20
+ names = assigned_names(node)
21
+ return if names.size <= max
22
+
23
+ add_offense(node.loc.name, message: message_for(node, names)) { self.max = names.size }
24
+ end
25
+
26
+ private
27
+
28
+ def action?(node) = controller?(node) && node_visibility(node) == :public
29
+
30
+ def controller?(node)
31
+ owner = node.each_ancestor(*OWNER_TYPES).first
32
+ return false unless owner&.class_type?
33
+
34
+ owner.identifier.const_name.to_s.end_with?("Controller")
35
+ end
36
+
37
+ def assigned_names(node) = node.each_descendant(:ivasgn).map { _1.name.to_s }.uniq
38
+
39
+ def message_for(node, names)
40
+ format(MSG, action: node.method_name, count: names.size, names: names.join(", "), max: max)
41
+ end
42
+
43
+ def max = cop_config.fetch("Max", 1)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module QualityGate
6
+ # Uses Rails' shorthand for callbacks after both creation and update commits.
7
+ class PreferAfterSaveCommit < Base
8
+ extend AutoCorrector
9
+
10
+ MSG = "Use after_save_commit for callbacks on both create and update."
11
+ RESTRICT_ON_SEND = %i[after_commit].freeze
12
+
13
+ def on_send(node)
14
+ return if node.receiver && !node.receiver.self_type?
15
+
16
+ pair = save_option(node.last_argument)
17
+ return unless pair
18
+
19
+ removal = option_range(node, pair)
20
+ add_offense(node.loc.selector) do |corrector|
21
+ next if commented?(removal)
22
+
23
+ corrector.replace(node.loc.selector, "after_save_commit")
24
+ corrector.remove(removal)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def save_option(options)
31
+ return unless literal_options?(options)
32
+
33
+ pairs = options.pairs.select { |pair| pair.key.sym_type? && pair.key.value == :on }
34
+ pairs.first if pairs.one? && save_events?(pairs.first.value)
35
+ end
36
+
37
+ def literal_options?(options)
38
+ options&.hash_type? && options.children.all?(&:pair_type?)
39
+ end
40
+
41
+ def commented?(range)
42
+ processed_source.comments.any? { |comment| range.overlaps?(comment.loc.expression) }
43
+ end
44
+
45
+ def save_events?(node)
46
+ node.array_type? && node.values.all?(&:sym_type?) &&
47
+ node.values.map(&:value).sort == %i[create update]
48
+ end
49
+
50
+ def option_range(node, pair)
51
+ options = node.last_argument
52
+ return argument_range(node, options) if options.pairs.one?
53
+
54
+ pair_range(options.pairs, pair)
55
+ end
56
+
57
+ def pair_range(pairs, pair)
58
+ index = pairs.index(pair)
59
+ following = pairs[index + 1]
60
+ if following
61
+ pair.source_range.with(end_pos: following.source_range.begin_pos)
62
+ else
63
+ pair.source_range.with(begin_pos: pairs[index - 1].source_range.end_pos)
64
+ end
65
+ end
66
+
67
+ def argument_range(node, options)
68
+ previous = node.arguments[-2]
69
+ return options.source_range.with(end_pos: node.loc.end.begin_pos) if !previous && node.parenthesized?
70
+
71
+ start = argument_start(node, options, previous)
72
+ options.source_range.with(begin_pos: start)
73
+ end
74
+
75
+ def argument_start(node, options, previous)
76
+ return previous.source_range.end_pos if previous
77
+ return node.loc.selector.end_pos unless node.parenthesized?
78
+
79
+ options.source_range.begin_pos
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module QualityGate
6
+ # Opt-in convention: concerns should expose behavior to their consumers.
7
+ # Unknown declarations are deliberately exempt because they may add behavior.
8
+ class PrivateOnlyConcern < Base
9
+ MSG = "This concern only defines private instance methods; consider keeping them in its consumer."
10
+ VISIBILITIES = %i[public protected private].freeze
11
+
12
+ def on_module(node)
13
+ body = node.body
14
+ return unless body
15
+
16
+ statements = body.begin_type? ? body.children : [body]
17
+ return unless concern?(statements)
18
+ return unless private_methods_only?(statements)
19
+
20
+ add_offense(node.identifier)
21
+ end
22
+
23
+ private
24
+
25
+ def concern?(statements)
26
+ statements.any? { |statement| concern_extension?(statement) } ||
27
+ Array(cop_config["ConcernPaths"]).any? do |pattern|
28
+ File.fnmatch?(pattern, processed_source.file_path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
29
+ end
30
+ end
31
+
32
+ def concern_extension?(node)
33
+ node.send_type? && node.receiver.nil? && node.method?(:extend) &&
34
+ node.arguments.one? && node.first_argument.const_type? &&
35
+ node.first_argument.const_name == "ActiveSupport::Concern"
36
+ end
37
+
38
+ def private_methods_only?(statements)
39
+ methods = {}
40
+ visibility = :public
41
+ statements.each do |statement|
42
+ next if concern_extension?(statement)
43
+
44
+ visibility = record_statement(statement, methods, visibility)
45
+ return false unless visibility
46
+ end
47
+ !methods.empty? && methods.values.all?(:private)
48
+ end
49
+
50
+ def record_statement(statement, methods, visibility)
51
+ if statement.def_type?
52
+ methods[statement.method_name] = visibility
53
+ elsif visibility_declaration?(statement) && apply_visibility(statement, methods)
54
+ statement.arguments.empty? ? statement.method_name : visibility
55
+ end
56
+ end
57
+
58
+ def visibility_declaration?(node)
59
+ node.send_type? && node.receiver.nil? && VISIBILITIES.include?(node.method_name)
60
+ end
61
+
62
+ def apply_visibility(node, methods)
63
+ node.arguments.each do |argument|
64
+ if argument.def_type?
65
+ methods[argument.method_name] = node.method_name
66
+ elsif argument.sym_type? && methods.key?(argument.value)
67
+ methods[argument.value] = node.method_name
68
+ else
69
+ return false
70
+ end
71
+ end
72
+ true
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
data/llm.txt ADDED
@@ -0,0 +1,13 @@
1
+ # Quality Gate
2
+
3
+ Quality Gate provides staged Ruby code-quality checks through one CLI and
4
+ configuration contract. See the README for installation, gate commands,
5
+ configuration, and optional RuboCop conventions.
6
+
7
+ ## Documentation
8
+
9
+ - [README](README.md)
10
+ - [Codex integration](docs/codex.md)
11
+ - [Validation and limitations](docs/dogfood-log.md)
12
+ - [Incident workflow](docs/incidents.md)
13
+ - [Releasing](docs/releasing.md)
@@ -0,0 +1,226 @@
1
+ module QualityGate
2
+ VERSION: String
3
+
4
+ interface _Output
5
+ def puts: (*untyped objects) -> nil
6
+ end
7
+
8
+ class Error < StandardError
9
+ end
10
+
11
+ class ParseError < Error
12
+ attr_reader tool: String
13
+
14
+ def initialize: (tool: String, reason: String) -> void
15
+ end
16
+
17
+ class ConfigError < Error
18
+ attr_reader path: String
19
+
20
+ def initialize: (path: String, cause_message: String) -> void
21
+ end
22
+
23
+ class Railtie
24
+ end
25
+
26
+ class InstallGenerator
27
+ FILES: Array[String]
28
+ MARKER_START: String
29
+ MARKER_END: String
30
+
31
+ def create_settings_file: () -> void
32
+ def create_rules_file: () -> void
33
+ def create_initializers: () -> void
34
+ def inject_coverage: () -> void
35
+ def create_agent_integration: () -> void
36
+ def print_summary: () -> void
37
+ end
38
+
39
+ class Installer
40
+ def initialize: (destination_root: String, ?options: Hash[Symbol, untyped], ?stdout: _Output) -> void
41
+ def call: () -> (0 | 1)
42
+ end
43
+
44
+ class RubyProfile
45
+ attr_reader destination_root: String
46
+ attr_reader test_helper: String
47
+ attr_reader test_command: Array[String]
48
+
49
+ def initialize: (destination_root: String, ?options: Hash[Symbol, untyped]) -> void
50
+ def coverage?: () -> bool
51
+ def template_for: (String name) -> String
52
+ end
53
+
54
+ class Config
55
+ DEFAULTS: Hash[Symbol, untyped]
56
+
57
+ def self.defaults: () -> Hash[Symbol, untyped]
58
+ def self.load: (dir: String) -> Config
59
+
60
+ attr_reader path: String?
61
+ attr_reader unknown_keys: Array[String]
62
+
63
+ def initialize: (Hash[Symbol, untyped] settings, ?path: String?, ?unknown_keys: Array[String]) -> void
64
+ def to_h: () -> Hash[Symbol, untyped]
65
+ def fetch: (Symbol key) -> untyped
66
+ end
67
+
68
+ class HookLog
69
+ DEFAULT_PATH: String
70
+
71
+ attr_reader path: String
72
+
73
+ def initialize: (path: String) -> void
74
+ def recent: (?limit: Integer) -> Array[Hash[String, untyped]]
75
+ def unavailable_count: (?limit: Integer) -> Integer
76
+ def warning_line: (?limit: Integer) -> String?
77
+ end
78
+
79
+ class Adapter
80
+ def initialize: (config: Config, ?files: Array[String], ?diagnostic_io: _Output) -> void
81
+ def call: () -> Array[Finding]
82
+ def name: () -> String
83
+ def command: () -> Array[String]
84
+ def parse: (String stdout) -> Array[Finding]
85
+ def timeout: () -> Integer
86
+ end
87
+
88
+ module Adapters
89
+ class Brakeman < Adapter
90
+ CONFIDENCE_MAP: Hash[String, finding_severity]
91
+
92
+ def initialize: (config: Config, ?files: Array[String], ?diagnostic_io: _Output) -> void
93
+ def call: () -> Array[Finding]
94
+ def name: () -> String
95
+ def command: () -> Array[String]
96
+ def parse: (String stdout) -> Array[Finding]
97
+ def timeout: () -> Integer
98
+ end
99
+
100
+ class BundlerAudit < Adapter
101
+ LOCK_FILE: String
102
+
103
+ def initialize: (config: Config, ?files: Array[String], ?diagnostic_io: _Output) -> void
104
+ def call: () -> Array[Finding]
105
+ def name: () -> String
106
+ def command: () -> Array[String]
107
+ def parse: (String stdout) -> Array[Finding]
108
+ def timeout: () -> Integer
109
+ end
110
+
111
+ class RuboCop < Adapter
112
+ SEVERITY_MAP: Hash[String, finding_severity]
113
+ CONFIG_PATH: String
114
+
115
+ def initialize: (config: Config, ?files: Array[String], ?diagnostic_io: _Output) -> void
116
+ def call: () -> Array[Finding]
117
+ def name: () -> String
118
+ def command: () -> Array[String]
119
+ def parse: (String stdout) -> Array[Finding]
120
+ def timeout: () -> Integer
121
+ end
122
+
123
+ class SimpleCov < Adapter
124
+ COVERAGE_PATH: String
125
+
126
+ def initialize: (config: Config, ?files: Array[String], ?diagnostic_io: _Output) -> void
127
+ def call: () -> Array[Finding]
128
+ def name: () -> String
129
+ def timeout: () -> Integer
130
+ end
131
+
132
+ class TestSuite < Adapter
133
+ TAIL_LINES: Integer
134
+
135
+ def initialize: (config: Config, ?files: Array[String], ?diagnostic_io: _Output) -> void
136
+ def call: () -> Array[Finding]
137
+ def name: () -> String
138
+ def command: () -> Array[String]
139
+ def env: () -> Hash[String, String]
140
+ def timeout: () -> Integer
141
+ end
142
+
143
+ class Undercover < Adapter
144
+ COVERAGE_PATH: String
145
+ SKIP_RULE: String
146
+
147
+ def initialize: (config: Config, ?files: Array[String], ?diagnostic_io: _Output) -> void
148
+ def call: () -> Array[Finding]
149
+ def name: () -> String
150
+ def command: () -> Array[String]
151
+ def compare_point: () -> String?
152
+ def parse: (String stdout) -> Array[Finding]
153
+ def timeout: () -> Integer
154
+ end
155
+ end
156
+
157
+ class Runner
158
+ def initialize: (adapters: Array[Adapter], config: Config, ?diagnostic_io: _Output?) -> void
159
+ def call: () -> Runner::Result
160
+
161
+ class Result < Data
162
+ attr_reader findings: Array[Finding]
163
+ attr_reader failed_tools: Array[String]
164
+ attr_reader checks: Array[Hash[Symbol, untyped]]
165
+
166
+ def initialize: (findings: Array[Finding], ?checks: Array[Hash[Symbol, untyped]]) -> void
167
+ def with: (?findings: Array[Finding], ?checks: Array[Hash[Symbol, untyped]]) -> Result
168
+ def exit_code: () -> (0 | 1 | 2)
169
+ end
170
+ end
171
+
172
+ class CLI
173
+ SUBCOMMANDS: Array[String]
174
+
175
+ def self.run: (Array[String] argv, ?stdout: _Output, ?stderr: _Output, ?dir: String) -> (0 | 1 | 2)
176
+ end
177
+
178
+ class InitCommand
179
+ def self.run: (Array[String] arguments, stdout: _Output, stderr: _Output, dir: String) -> (0 | 1 | 2)
180
+ end
181
+
182
+ module Reporters
183
+ class Text
184
+ def initialize: (io: _Output) -> void
185
+ def call: (Runner::Result result) -> nil
186
+ end
187
+
188
+ class Json
189
+ def initialize: (io: _Output) -> void
190
+ def call: (Runner::Result result) -> nil
191
+ end
192
+ end
193
+
194
+ type finding_severity = :error | :warning | :info
195
+
196
+ class Finding < Data
197
+ SEVERITIES: Array[finding_severity]
198
+ TOOL_FAILURE_RULE: String
199
+
200
+ attr_reader tool: String
201
+ attr_reader file: String
202
+ attr_reader line: Integer
203
+ attr_reader rule: String
204
+ attr_reader severity: finding_severity
205
+ attr_reader message: String
206
+
207
+ def initialize: (
208
+ tool: String,
209
+ file: String,
210
+ line: Integer,
211
+ rule: String,
212
+ severity: finding_severity,
213
+ message: String
214
+ ) -> void
215
+ def self.tool_failure: (tool: String, message: String) -> Finding
216
+ def tool_failure?: () -> bool
217
+ end
218
+
219
+ module ExitCode
220
+ CLEAN: 0
221
+ FINDINGS: 1
222
+ TOOL_FAILURE: 2
223
+
224
+ def self.all: () -> Array[0 | 1 | 2]
225
+ end
226
+ end