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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +571 -0
- data/config/37signals.yml +26 -0
- data/config/cops.yml +37 -0
- data/config/reek.yml +6 -0
- data/config/rubocop.yml +60 -0
- data/config/ruby.yml +8 -0
- data/docs/codex.md +31 -0
- data/docs/dogfood-log.md +47 -0
- data/docs/incidents.md +18 -0
- data/docs/releasing.md +46 -0
- data/exe/quality_gate +6 -0
- data/lib/generators/quality_gate/install/install_generator.rb +66 -0
- data/lib/generators/quality_gate/install/templates/agents_section.md.tt +15 -0
- data/lib/generators/quality_gate/install/templates/bullet.rb.tt +10 -0
- data/lib/generators/quality_gate/install/templates/claude_settings.json.tt +29 -0
- data/lib/generators/quality_gate/install/templates/hook_log_filesystem.rb.tt +76 -0
- data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +42 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +248 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_verify_stop.rb.tt +466 -0
- data/lib/generators/quality_gate/install/templates/rubocop.yml.tt +1 -0
- data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +15 -0
- data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +40 -0
- data/lib/generators/quality_gate/install/templates/ruby_rubocop.yml.tt +1 -0
- data/lib/generators/quality_gate/install/templates/ruby_simplecov.rb.tt +11 -0
- data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +10 -0
- data/lib/generators/quality_gate/install/templates/strong_migrations.rb.tt +10 -0
- data/lib/quality_gate/adapter.rb +328 -0
- data/lib/quality_gate/adapters/brakeman.rb +125 -0
- data/lib/quality_gate/adapters/bundler_audit.rb +235 -0
- data/lib/quality_gate/adapters/reek.rb +108 -0
- data/lib/quality_gate/adapters/rubocop.rb +142 -0
- data/lib/quality_gate/adapters/simplecov.rb +103 -0
- data/lib/quality_gate/adapters/test_suite.rb +81 -0
- data/lib/quality_gate/adapters/undercover.rb +357 -0
- data/lib/quality_gate/cli.rb +451 -0
- data/lib/quality_gate/config.rb +290 -0
- data/lib/quality_gate/exit_code.rb +14 -0
- data/lib/quality_gate/finding.rb +50 -0
- data/lib/quality_gate/hook_log.rb +131 -0
- data/lib/quality_gate/init_command.rb +90 -0
- data/lib/quality_gate/installation.rb +1577 -0
- data/lib/quality_gate/installer.rb +104 -0
- data/lib/quality_gate/railtie.rb +16 -0
- data/lib/quality_gate/reporters/field_sanitizer.rb +42 -0
- data/lib/quality_gate/reporters/json.rb +59 -0
- data/lib/quality_gate/reporters/text.rb +45 -0
- data/lib/quality_gate/rubocop.rb +34 -0
- data/lib/quality_gate/ruby_profile.rb +170 -0
- data/lib/quality_gate/runner.rb +150 -0
- data/lib/quality_gate/version.rb +5 -0
- data/lib/quality_gate.rb +27 -0
- data/lib/rubocop/cop/quality_gate/association_default_block_value.rb +46 -0
- data/lib/rubocop/cop/quality_gate/broadcast_in_controller.rb +64 -0
- data/lib/rubocop/cop/quality_gate/controller_instance_variables.rb +47 -0
- data/lib/rubocop/cop/quality_gate/prefer_after_save_commit.rb +84 -0
- data/lib/rubocop/cop/quality_gate/private_only_concern.rb +77 -0
- data/llm.txt +13 -0
- data/sig/quality_gate.rbs +226 -0
- metadata +260 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
begin
|
|
9
|
+
require "fiddle/import"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
<%= hook_log_filesystem_source -%>
|
|
15
|
+
|
|
16
|
+
HOOK_LOG_MAX_SCAN_BYTES = 1024 * 1024
|
|
17
|
+
HOOK_LOG_MAX_LINE_BYTES = 64 * 1024
|
|
18
|
+
HOOK_LOG_OUTCOMES = %w[
|
|
19
|
+
no_file skipped deleted clean findings unavailable
|
|
20
|
+
verify_skipped verify_debounced verify_clean verify_blocked verify_unavailable verify_cap
|
|
21
|
+
].freeze
|
|
22
|
+
UNAVAILABLE_NOTICE = "quality_gate fast could not run; " \
|
|
23
|
+
"run `bundle exec quality_gate fast` and check log/quality_gate_hooks.jsonl\n"
|
|
24
|
+
|
|
25
|
+
def quality_gate_outcome(stdout, status)
|
|
26
|
+
report = JSON.parse(stdout)
|
|
27
|
+
return unless valid_report?(report)
|
|
28
|
+
|
|
29
|
+
outcome_for(status.exitstatus, report.fetch("findings"))
|
|
30
|
+
rescue JSON::ParserError, EncodingError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def valid_report?(report)
|
|
35
|
+
return false unless report.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
findings = report["findings"]
|
|
38
|
+
summary = report["summary"]
|
|
39
|
+
findings.is_a?(Array) &&
|
|
40
|
+
findings.all? { valid_finding?(_1) } &&
|
|
41
|
+
summary.is_a?(Hash) &&
|
|
42
|
+
valid_summary?(summary, findings.length)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def valid_finding?(finding)
|
|
46
|
+
return false unless finding.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
string_fields = %w[tool file rule severity message]
|
|
49
|
+
string_fields.all? { finding[_1].is_a?(String) } &&
|
|
50
|
+
finding["line"].is_a?(Integer) &&
|
|
51
|
+
finding["line"] >= 0 &&
|
|
52
|
+
%w[error warning info].include?(finding["severity"]) &&
|
|
53
|
+
finding["rule"] != "tool_failure"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def valid_summary?(summary, finding_count)
|
|
57
|
+
summary_findings = summary["findings"]
|
|
58
|
+
tool_failures = summary["tool_failures"]
|
|
59
|
+
failed_tools = summary["failed_tools"]
|
|
60
|
+
|
|
61
|
+
summary_findings.is_a?(Integer) &&
|
|
62
|
+
summary_findings == finding_count &&
|
|
63
|
+
tool_failures.is_a?(Integer) &&
|
|
64
|
+
tool_failures.zero? &&
|
|
65
|
+
failed_tools == []
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def outcome_for(exit_status, findings)
|
|
69
|
+
return "clean" if exit_status&.zero? && findings.empty?
|
|
70
|
+
|
|
71
|
+
"findings" if exit_status == 1 && findings.any?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initial_outcome(file)
|
|
75
|
+
return "no_file" unless file
|
|
76
|
+
return "skipped" unless file.end_with?(".rb")
|
|
77
|
+
return "deleted" unless File.file?(file)
|
|
78
|
+
|
|
79
|
+
"unavailable"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def close_log_descriptor(io)
|
|
83
|
+
io&.close
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def last_hook_log_outcome(project_root)
|
|
89
|
+
root_flags = File::RDONLY | File::NOFOLLOW | HookLogFilesystem::O_NONBLOCK |
|
|
90
|
+
HookLogFilesystem::O_DIRECTORY
|
|
91
|
+
root = File.open(project_root, root_flags)
|
|
92
|
+
root.close_on_exec = true if root.respond_to?(:close_on_exec=)
|
|
93
|
+
return unless root.stat.directory?
|
|
94
|
+
|
|
95
|
+
directory = HookLogFilesystem.open_existing_log_directory(root)
|
|
96
|
+
return unless directory.stat.directory?
|
|
97
|
+
|
|
98
|
+
log = HookLogFilesystem.open_log_file_for_read(directory)
|
|
99
|
+
log_stat = log.stat
|
|
100
|
+
return unless log_stat.file? && log_stat.nlink == 1
|
|
101
|
+
|
|
102
|
+
log.binmode
|
|
103
|
+
last_valid_hook_log_outcome(log)
|
|
104
|
+
rescue LoadError, NotImplementedError, StandardError
|
|
105
|
+
nil
|
|
106
|
+
ensure
|
|
107
|
+
close_log_descriptor(log)
|
|
108
|
+
close_log_descriptor(directory)
|
|
109
|
+
close_log_descriptor(root)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def last_valid_hook_log_outcome(log)
|
|
113
|
+
hook_log_tail(log).split("\n".b).reverse_each do |line|
|
|
114
|
+
outcome = parse_hook_log_outcome(line)
|
|
115
|
+
return outcome if outcome
|
|
116
|
+
end
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def hook_log_tail(log)
|
|
121
|
+
size = log.stat.size
|
|
122
|
+
length = [size, HOOK_LOG_MAX_SCAN_BYTES].min
|
|
123
|
+
offset = size - length
|
|
124
|
+
starts_at_boundary = hook_log_line_boundary?(log, offset)
|
|
125
|
+
log.seek(offset, IO::SEEK_SET)
|
|
126
|
+
bytes = log.read(length) || "".b
|
|
127
|
+
return bytes if starts_at_boundary
|
|
128
|
+
|
|
129
|
+
newline = bytes.index("\n".b)
|
|
130
|
+
newline ? bytes.byteslice(newline + 1, bytes.bytesize - newline - 1) : "".b
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def hook_log_line_boundary?(log, offset)
|
|
134
|
+
return true if offset.zero?
|
|
135
|
+
|
|
136
|
+
log.seek(offset - 1, IO::SEEK_SET)
|
|
137
|
+
log.read(1) == "\n".b
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def parse_hook_log_outcome(line)
|
|
141
|
+
return if line.bytesize > HOOK_LOG_MAX_LINE_BYTES
|
|
142
|
+
|
|
143
|
+
line = line.dup.force_encoding(Encoding::UTF_8)
|
|
144
|
+
return unless line.valid_encoding?
|
|
145
|
+
|
|
146
|
+
record = JSON.parse(line)
|
|
147
|
+
record["outcome"] if valid_hook_log_record?(record)
|
|
148
|
+
rescue JSON::ParserError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def valid_hook_log_record?(record)
|
|
153
|
+
record.is_a?(Hash) &&
|
|
154
|
+
record["ts"].is_a?(String) &&
|
|
155
|
+
record.key?("file") &&
|
|
156
|
+
(record["file"].nil? || record["file"].is_a?(String)) &&
|
|
157
|
+
HOOK_LOG_OUTCOMES.include?(record["outcome"]) &&
|
|
158
|
+
valid_hook_log_duration?(record["duration_ms"])
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def valid_hook_log_duration?(duration)
|
|
162
|
+
duration.is_a?(Numeric) && duration.finite? && duration >= 0
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def append_hook_log(project_root, entry)
|
|
166
|
+
root_flags = File::RDONLY | File::NOFOLLOW | HookLogFilesystem::O_NONBLOCK |
|
|
167
|
+
HookLogFilesystem::O_DIRECTORY
|
|
168
|
+
root = File.open(project_root, root_flags)
|
|
169
|
+
root.close_on_exec = true if root.respond_to?(:close_on_exec=)
|
|
170
|
+
return unless root.stat.directory?
|
|
171
|
+
|
|
172
|
+
directory = HookLogFilesystem.open_log_directory(root)
|
|
173
|
+
return unless directory.stat.directory?
|
|
174
|
+
|
|
175
|
+
log, created = HookLogFilesystem.open_log_file(directory)
|
|
176
|
+
log_stat = log.stat
|
|
177
|
+
return unless log_stat.file? && log_stat.nlink == 1
|
|
178
|
+
|
|
179
|
+
if created
|
|
180
|
+
mask = File.umask
|
|
181
|
+
begin
|
|
182
|
+
log.chmod(0o644 & ~mask)
|
|
183
|
+
ensure
|
|
184
|
+
File.umask(mask)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
line = "#{entry}\n"
|
|
188
|
+
written = log.syswrite(line)
|
|
189
|
+
fail IOError, "short hook log write" unless written == line.bytesize
|
|
190
|
+
rescue LoadError, NotImplementedError, StandardError
|
|
191
|
+
nil
|
|
192
|
+
ensure
|
|
193
|
+
close_log_descriptor(log)
|
|
194
|
+
close_log_descriptor(directory)
|
|
195
|
+
close_log_descriptor(root)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
199
|
+
input = begin
|
|
200
|
+
parsed = JSON.parse($stdin.read)
|
|
201
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
202
|
+
rescue JSON::ParserError
|
|
203
|
+
{}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
project_root = input["cwd"]
|
|
207
|
+
project_root = Dir.pwd unless project_root.is_a?(String) && !project_root.empty?
|
|
208
|
+
tool_input = input["tool_input"]
|
|
209
|
+
file = tool_input["file_path"] if tool_input.is_a?(Hash)
|
|
210
|
+
file = nil unless file.is_a?(String) && !file.empty?
|
|
211
|
+
outcome = initial_outcome(file)
|
|
212
|
+
attempt_was_made = outcome == "unavailable"
|
|
213
|
+
exit_code = 0
|
|
214
|
+
feedback = nil
|
|
215
|
+
|
|
216
|
+
if outcome == "unavailable"
|
|
217
|
+
begin
|
|
218
|
+
stdout, _stderr, status = Open3.capture3(
|
|
219
|
+
"bundle", "exec", "quality_gate", "fast", "--files", file, "--format", "json",
|
|
220
|
+
chdir: project_root
|
|
221
|
+
)
|
|
222
|
+
outcome = quality_gate_outcome(stdout, status) || "unavailable"
|
|
223
|
+
if outcome == "findings"
|
|
224
|
+
exit_code = 2
|
|
225
|
+
feedback = stdout
|
|
226
|
+
end
|
|
227
|
+
rescue StandardError
|
|
228
|
+
outcome = "unavailable"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
if attempt_was_made && outcome == "unavailable" && last_hook_log_outcome(project_root) != "unavailable"
|
|
233
|
+
exit_code = 2
|
|
234
|
+
feedback = UNAVAILABLE_NOTICE
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
238
|
+
entry = JSON.generate(
|
|
239
|
+
"ts" => Time.now.utc.iso8601(6),
|
|
240
|
+
"file" => file,
|
|
241
|
+
"outcome" => outcome,
|
|
242
|
+
"duration_ms" => duration_ms
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
append_hook_log(project_root, entry)
|
|
246
|
+
|
|
247
|
+
$stderr.write(feedback) if feedback
|
|
248
|
+
exit exit_code
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The standalone boundary code stays explicit so every fail-open branch is visible.
|
|
5
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
6
|
+
# rubocop:disable Metrics/PerceivedComplexity, Style/SignalException
|
|
7
|
+
|
|
8
|
+
require "json"
|
|
9
|
+
require "open3"
|
|
10
|
+
require "time"
|
|
11
|
+
|
|
12
|
+
begin
|
|
13
|
+
require "fiddle/import"
|
|
14
|
+
rescue LoadError
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
<%= hook_log_filesystem_source -%>
|
|
19
|
+
|
|
20
|
+
HOOK_LOG_MAX_SCAN_BYTES = 1024 * 1024
|
|
21
|
+
HOOK_LOG_MAX_LINE_BYTES = 64 * 1024
|
|
22
|
+
HOOK_LOG_OUTCOMES = %w[
|
|
23
|
+
no_file skipped deleted clean findings unavailable
|
|
24
|
+
verify_skipped verify_debounced verify_clean verify_blocked verify_unavailable verify_cap
|
|
25
|
+
].freeze
|
|
26
|
+
VERIFY_OUTCOMES = %w[
|
|
27
|
+
verify_skipped verify_debounced verify_clean verify_blocked verify_unavailable verify_cap
|
|
28
|
+
].freeze
|
|
29
|
+
DEBOUNCE_DECISIVE_OUTCOMES = %w[
|
|
30
|
+
verify_clean verify_blocked verify_unavailable verify_cap
|
|
31
|
+
].freeze
|
|
32
|
+
FAST_OUTCOMES = %w[no_file skipped deleted clean findings unavailable].freeze
|
|
33
|
+
BLOCK_LIMIT = 3
|
|
34
|
+
STATUS_CODES = " MADRCUT?!".bytes.freeze
|
|
35
|
+
C_ESCAPES = {
|
|
36
|
+
"a".ord => "\a".b,
|
|
37
|
+
"b".ord => "\b".b,
|
|
38
|
+
"t".ord => "\t".b,
|
|
39
|
+
"n".ord => "\n".b,
|
|
40
|
+
"v".ord => "\v".b,
|
|
41
|
+
"f".ord => "\f".b,
|
|
42
|
+
"r".ord => "\r".b,
|
|
43
|
+
'"'.ord => '"'.b,
|
|
44
|
+
"\\".ord => "\\".b
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
def quality_gate_outcome(stdout, status)
|
|
48
|
+
report = JSON.parse(stdout)
|
|
49
|
+
return unless valid_report?(report)
|
|
50
|
+
|
|
51
|
+
outcome_for(status.exitstatus, report.fetch("findings"))
|
|
52
|
+
rescue JSON::ParserError, EncodingError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def valid_report?(report)
|
|
57
|
+
return false unless report.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
findings = report["findings"]
|
|
60
|
+
summary = report["summary"]
|
|
61
|
+
findings.is_a?(Array) &&
|
|
62
|
+
findings.all? { valid_finding?(_1) } &&
|
|
63
|
+
summary.is_a?(Hash) &&
|
|
64
|
+
valid_summary?(summary, findings.length)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def valid_finding?(finding)
|
|
68
|
+
return false unless finding.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
string_fields = %w[tool file rule severity message]
|
|
71
|
+
string_fields.all? { finding[_1].is_a?(String) } &&
|
|
72
|
+
finding["line"].is_a?(Integer) &&
|
|
73
|
+
finding["line"] >= 0 &&
|
|
74
|
+
%w[error warning info].include?(finding["severity"]) &&
|
|
75
|
+
finding["rule"] != "tool_failure"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def valid_summary?(summary, finding_count)
|
|
79
|
+
summary_findings = summary["findings"]
|
|
80
|
+
tool_failures = summary["tool_failures"]
|
|
81
|
+
failed_tools = summary["failed_tools"]
|
|
82
|
+
|
|
83
|
+
summary_findings.is_a?(Integer) &&
|
|
84
|
+
summary_findings == finding_count &&
|
|
85
|
+
tool_failures.is_a?(Integer) &&
|
|
86
|
+
tool_failures.zero? &&
|
|
87
|
+
failed_tools == []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def outcome_for(exit_status, findings)
|
|
91
|
+
return "clean" if exit_status&.zero? && findings.empty?
|
|
92
|
+
|
|
93
|
+
"findings" if exit_status == 1 && findings.any?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ruby_changes?(project_root)
|
|
97
|
+
stdout, _stderr, status = Open3.capture3(
|
|
98
|
+
"git", "status", "--porcelain", "--untracked-files=all",
|
|
99
|
+
chdir: project_root
|
|
100
|
+
)
|
|
101
|
+
return true unless status.success?
|
|
102
|
+
|
|
103
|
+
ruby_path_in_status?(stdout)
|
|
104
|
+
rescue StandardError
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def ruby_path_in_status?(status_output)
|
|
109
|
+
lines = status_output.b.split("\n".b, -1)
|
|
110
|
+
lines.pop if lines.last == "".b
|
|
111
|
+
|
|
112
|
+
lines.each do |line|
|
|
113
|
+
paths = status_paths(line)
|
|
114
|
+
return true unless paths
|
|
115
|
+
return true if paths.any? { _1.end_with?(".rb".b) }
|
|
116
|
+
end
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def status_paths(line)
|
|
121
|
+
return unless line.bytesize >= 4
|
|
122
|
+
|
|
123
|
+
index_status = line.getbyte(0)
|
|
124
|
+
worktree_status = line.getbyte(1)
|
|
125
|
+
return unless valid_status_pair?(index_status, worktree_status) && line.getbyte(2) == " ".ord
|
|
126
|
+
|
|
127
|
+
field = line.byteslice(3, line.bytesize - 3)
|
|
128
|
+
return unless field && !field.empty?
|
|
129
|
+
|
|
130
|
+
paths = rename_status?(index_status, worktree_status) ? renamed_paths(field) : ordinary_path(field)
|
|
131
|
+
return unless paths
|
|
132
|
+
|
|
133
|
+
index_status == "!".ord && worktree_status == "!".ord ? [] : paths
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def valid_status_pair?(index_status, worktree_status)
|
|
137
|
+
return false unless STATUS_CODES.include?(index_status) && STATUS_CODES.include?(worktree_status)
|
|
138
|
+
return index_status == "?".ord && worktree_status == "?".ord if [index_status, worktree_status].include?("?".ord)
|
|
139
|
+
return index_status == "!".ord && worktree_status == "!".ord if [index_status, worktree_status].include?("!".ord)
|
|
140
|
+
|
|
141
|
+
index_status != " ".ord || worktree_status != " ".ord
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def rename_status?(index_status, worktree_status)
|
|
145
|
+
[index_status, worktree_status].any? { _1 == "R".ord || _1 == "C".ord }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ordinary_path(field)
|
|
149
|
+
return if rename_separator_offsets(field).any?
|
|
150
|
+
|
|
151
|
+
path = decoded_status_path(field)
|
|
152
|
+
[path] if path
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def renamed_paths(field)
|
|
156
|
+
offsets = rename_separator_offsets(field)
|
|
157
|
+
return unless offsets.one?
|
|
158
|
+
|
|
159
|
+
separator = offsets.first
|
|
160
|
+
old_path = decoded_status_path(field.byteslice(0, separator))
|
|
161
|
+
new_offset = separator + 4
|
|
162
|
+
new_path = decoded_status_path(field.byteslice(new_offset, field.bytesize - new_offset))
|
|
163
|
+
[old_path, new_path] if old_path && new_path
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def rename_separator_offsets(field)
|
|
167
|
+
offsets = []
|
|
168
|
+
quoted = false
|
|
169
|
+
index = 0
|
|
170
|
+
|
|
171
|
+
while index < field.bytesize
|
|
172
|
+
byte = field.getbyte(index)
|
|
173
|
+
if quoted && byte == "\\".ord
|
|
174
|
+
return [] if index + 1 >= field.bytesize
|
|
175
|
+
|
|
176
|
+
index += 2
|
|
177
|
+
next
|
|
178
|
+
end
|
|
179
|
+
quoted = !quoted if byte == '"'.ord
|
|
180
|
+
if !quoted && field.byteslice(index, 4) == " -> ".b
|
|
181
|
+
offsets << index
|
|
182
|
+
index += 4
|
|
183
|
+
next
|
|
184
|
+
end
|
|
185
|
+
index += 1
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
quoted ? [] : offsets
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def decoded_status_path(field)
|
|
192
|
+
return unless field && !field.empty?
|
|
193
|
+
return field unless field.start_with?('"'.b)
|
|
194
|
+
|
|
195
|
+
decode_quoted_status_path(field)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def decode_quoted_status_path(field)
|
|
199
|
+
return unless field.bytesize >= 2 && field.end_with?('"'.b)
|
|
200
|
+
|
|
201
|
+
decoded = String.new(encoding: Encoding::BINARY)
|
|
202
|
+
index = 1
|
|
203
|
+
finish = field.bytesize - 1
|
|
204
|
+
while index < finish
|
|
205
|
+
byte = field.getbyte(index)
|
|
206
|
+
return if byte == '"'.ord
|
|
207
|
+
|
|
208
|
+
if byte == "\\".ord
|
|
209
|
+
escaped, index = decode_status_escape(field, index + 1, finish)
|
|
210
|
+
return unless escaped
|
|
211
|
+
|
|
212
|
+
decoded << escaped
|
|
213
|
+
else
|
|
214
|
+
decoded << byte
|
|
215
|
+
index += 1
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
decoded
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def decode_status_escape(field, index, finish)
|
|
222
|
+
return [nil, index] if index >= finish
|
|
223
|
+
|
|
224
|
+
byte = field.getbyte(index)
|
|
225
|
+
return [C_ESCAPES.fetch(byte), index + 1] if C_ESCAPES.key?(byte)
|
|
226
|
+
return [nil, index] unless byte.between?("0".ord, "7".ord)
|
|
227
|
+
|
|
228
|
+
digits = String.new(encoding: Encoding::BINARY)
|
|
229
|
+
3.times do
|
|
230
|
+
break if index >= finish || !field.getbyte(index).between?("0".ord, "7".ord)
|
|
231
|
+
|
|
232
|
+
digits << field.getbyte(index)
|
|
233
|
+
index += 1
|
|
234
|
+
end
|
|
235
|
+
[digits.to_i(8).chr(Encoding::BINARY), index]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def close_log_descriptor(io)
|
|
239
|
+
io&.close
|
|
240
|
+
rescue StandardError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def hook_log_history(project_root)
|
|
245
|
+
root_flags = File::RDONLY | File::NOFOLLOW | HookLogFilesystem::O_NONBLOCK |
|
|
246
|
+
HookLogFilesystem::O_DIRECTORY
|
|
247
|
+
root = File.open(project_root, root_flags)
|
|
248
|
+
root.close_on_exec = true if root.respond_to?(:close_on_exec=)
|
|
249
|
+
return [[], false] unless root.stat.directory?
|
|
250
|
+
|
|
251
|
+
begin
|
|
252
|
+
directory = HookLogFilesystem.open_existing_log_directory(root)
|
|
253
|
+
rescue Errno::ENOENT
|
|
254
|
+
return [[], true]
|
|
255
|
+
end
|
|
256
|
+
return [[], false] unless directory.stat.directory?
|
|
257
|
+
|
|
258
|
+
begin
|
|
259
|
+
log = HookLogFilesystem.open_log_file_for_read(directory)
|
|
260
|
+
rescue Errno::ENOENT
|
|
261
|
+
return [[], true]
|
|
262
|
+
end
|
|
263
|
+
log_stat = log.stat
|
|
264
|
+
return [[], false] unless log_stat.file? && log_stat.nlink == 1
|
|
265
|
+
|
|
266
|
+
log.binmode
|
|
267
|
+
[valid_hook_log_records(log), true]
|
|
268
|
+
rescue LoadError, NotImplementedError, StandardError
|
|
269
|
+
[[], false]
|
|
270
|
+
ensure
|
|
271
|
+
close_log_descriptor(log)
|
|
272
|
+
close_log_descriptor(directory)
|
|
273
|
+
close_log_descriptor(root)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def valid_hook_log_records(log)
|
|
277
|
+
hook_log_tail(log).split("\n".b).filter_map { parse_hook_log_record(_1) }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def hook_log_tail(log)
|
|
281
|
+
size = log.stat.size
|
|
282
|
+
length = [size, HOOK_LOG_MAX_SCAN_BYTES].min
|
|
283
|
+
offset = size - length
|
|
284
|
+
starts_at_boundary = hook_log_line_boundary?(log, offset)
|
|
285
|
+
log.seek(offset, IO::SEEK_SET)
|
|
286
|
+
bytes = log.read(length) || "".b
|
|
287
|
+
return bytes if starts_at_boundary
|
|
288
|
+
|
|
289
|
+
newline = bytes.index("\n".b)
|
|
290
|
+
newline ? bytes.byteslice(newline + 1, bytes.bytesize - newline - 1) : "".b
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def hook_log_line_boundary?(log, offset)
|
|
294
|
+
return true if offset.zero?
|
|
295
|
+
|
|
296
|
+
log.seek(offset - 1, IO::SEEK_SET)
|
|
297
|
+
log.read(1) == "\n".b
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def parse_hook_log_record(line)
|
|
301
|
+
return if line.bytesize > HOOK_LOG_MAX_LINE_BYTES
|
|
302
|
+
|
|
303
|
+
line = line.dup.force_encoding(Encoding::UTF_8)
|
|
304
|
+
return unless line.valid_encoding?
|
|
305
|
+
|
|
306
|
+
record = JSON.parse(line)
|
|
307
|
+
record if valid_hook_log_record?(record)
|
|
308
|
+
rescue JSON::ParserError
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def valid_hook_log_record?(record)
|
|
313
|
+
record.is_a?(Hash) &&
|
|
314
|
+
record["ts"].is_a?(String) &&
|
|
315
|
+
record.key?("file") &&
|
|
316
|
+
(record["file"].nil? || record["file"].is_a?(String)) &&
|
|
317
|
+
HOOK_LOG_OUTCOMES.include?(record["outcome"]) &&
|
|
318
|
+
valid_hook_log_duration?(record["duration_ms"])
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def valid_hook_log_duration?(duration)
|
|
322
|
+
duration.is_a?(Numeric) && duration.finite? && duration >= 0
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def debounced?(records, session_id)
|
|
326
|
+
latest_verify = records.reverse_each.find do |record|
|
|
327
|
+
record["session_id"] == session_id && DEBOUNCE_DECISIVE_OUTCOMES.include?(record["outcome"])
|
|
328
|
+
end
|
|
329
|
+
return false unless latest_verify&.fetch("outcome") == "verify_clean"
|
|
330
|
+
|
|
331
|
+
clean_time = Time.iso8601(latest_verify.fetch("ts"))
|
|
332
|
+
records.none? { ruby_edit_at_or_after?(_1, clean_time) }
|
|
333
|
+
rescue ArgumentError
|
|
334
|
+
false
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def ruby_edit_at_or_after?(record, clean_time)
|
|
338
|
+
file = record["file"]
|
|
339
|
+
return false unless file&.end_with?(".rb") && FAST_OUTCOMES.include?(record["outcome"])
|
|
340
|
+
|
|
341
|
+
Time.iso8601(record.fetch("ts")) >= clean_time
|
|
342
|
+
rescue ArgumentError
|
|
343
|
+
true
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def consecutive_blocks(records, session_id)
|
|
347
|
+
count = 0
|
|
348
|
+
records.reverse_each do |record|
|
|
349
|
+
next unless record["session_id"] == session_id && VERIFY_OUTCOMES.include?(record["outcome"])
|
|
350
|
+
|
|
351
|
+
break unless record["outcome"] == "verify_blocked"
|
|
352
|
+
|
|
353
|
+
count += 1
|
|
354
|
+
end
|
|
355
|
+
count
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def append_hook_log(project_root, entry)
|
|
359
|
+
root_flags = File::RDONLY | File::NOFOLLOW | HookLogFilesystem::O_NONBLOCK |
|
|
360
|
+
HookLogFilesystem::O_DIRECTORY
|
|
361
|
+
root = File.open(project_root, root_flags)
|
|
362
|
+
root.close_on_exec = true if root.respond_to?(:close_on_exec=)
|
|
363
|
+
return false unless root.stat.directory?
|
|
364
|
+
|
|
365
|
+
directory = HookLogFilesystem.open_log_directory(root)
|
|
366
|
+
return false unless directory.stat.directory?
|
|
367
|
+
|
|
368
|
+
log, created = HookLogFilesystem.open_log_file(directory)
|
|
369
|
+
log_stat = log.stat
|
|
370
|
+
return false unless log_stat.file? && log_stat.nlink == 1
|
|
371
|
+
|
|
372
|
+
if created
|
|
373
|
+
mask = File.umask
|
|
374
|
+
begin
|
|
375
|
+
log.chmod(0o644 & ~mask)
|
|
376
|
+
ensure
|
|
377
|
+
File.umask(mask)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
line = "#{entry}\n"
|
|
381
|
+
written = log.syswrite(line)
|
|
382
|
+
fail IOError, "short hook log write" unless written == line.bytesize
|
|
383
|
+
|
|
384
|
+
true
|
|
385
|
+
rescue LoadError, NotImplementedError, StandardError
|
|
386
|
+
false
|
|
387
|
+
ensure
|
|
388
|
+
close_log_descriptor(log)
|
|
389
|
+
close_log_descriptor(directory)
|
|
390
|
+
close_log_descriptor(root)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
394
|
+
input = begin
|
|
395
|
+
parsed = JSON.parse($stdin.read)
|
|
396
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
397
|
+
rescue JSON::ParserError, EncodingError
|
|
398
|
+
{}
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
project_root = input["cwd"]
|
|
402
|
+
unless project_root.is_a?(String) && !project_root.empty?
|
|
403
|
+
project_root = ENV["CLAUDE_PROJECT_DIR"]
|
|
404
|
+
project_root = Dir.pwd unless project_root.is_a?(String) && !project_root.empty?
|
|
405
|
+
end
|
|
406
|
+
session_id = input["session_id"]
|
|
407
|
+
session_id = "unknown" unless session_id.is_a?(String) && !session_id.empty?
|
|
408
|
+
|
|
409
|
+
exit_code = 0
|
|
410
|
+
feedback = nil
|
|
411
|
+
if !ruby_changes?(project_root)
|
|
412
|
+
outcome = "verify_skipped"
|
|
413
|
+
else
|
|
414
|
+
records, history_available = hook_log_history(project_root)
|
|
415
|
+
if debounced?(records, session_id)
|
|
416
|
+
outcome = "verify_debounced"
|
|
417
|
+
elsif consecutive_blocks(records, session_id) >= BLOCK_LIMIT
|
|
418
|
+
outcome = "verify_cap"
|
|
419
|
+
else
|
|
420
|
+
begin
|
|
421
|
+
stdout, _stderr, status = Open3.capture3(
|
|
422
|
+
"bundle", "exec", "quality_gate", "verify", "--format", "json",
|
|
423
|
+
chdir: project_root
|
|
424
|
+
)
|
|
425
|
+
verify_outcome = quality_gate_outcome(stdout, status)
|
|
426
|
+
if verify_outcome == "clean"
|
|
427
|
+
outcome = "verify_clean"
|
|
428
|
+
elsif verify_outcome == "findings"
|
|
429
|
+
outcome = "verify_blocked"
|
|
430
|
+
exit_code = 2
|
|
431
|
+
feedback = stdout
|
|
432
|
+
else
|
|
433
|
+
outcome = "verify_unavailable"
|
|
434
|
+
end
|
|
435
|
+
rescue StandardError
|
|
436
|
+
outcome = "verify_unavailable"
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
if outcome == "verify_blocked" && !history_available
|
|
442
|
+
outcome = "verify_unavailable"
|
|
443
|
+
feedback = nil
|
|
444
|
+
exit_code = 0
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
448
|
+
entry = JSON.generate(
|
|
449
|
+
"ts" => Time.now.utc.iso8601(6),
|
|
450
|
+
"file" => nil,
|
|
451
|
+
"outcome" => outcome,
|
|
452
|
+
"duration_ms" => duration_ms,
|
|
453
|
+
"session_id" => session_id
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
persisted = append_hook_log(project_root, entry)
|
|
457
|
+
if outcome == "verify_blocked" && !persisted
|
|
458
|
+
feedback = nil
|
|
459
|
+
exit_code = 0
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
$stderr.write(feedback) if feedback
|
|
463
|
+
exit exit_code
|
|
464
|
+
|
|
465
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
466
|
+
# rubocop:enable Metrics/PerceivedComplexity, Style/SignalException
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
inherit_gem: { quality_gate: config/rubocop.yml }
|