quality_gate 0.2.2 → 0.3.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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +292 -21
- data/docs/codex.md +37 -9
- data/docs/releasing.md +16 -4
- data/lib/generators/quality_gate/install/install_generator.rb +18 -3
- data/lib/generators/quality_gate/install/templates/agents_section.md.tt +13 -2
- data/lib/generators/quality_gate/install/templates/codex_fast.rb.tt +36 -0
- data/lib/generators/quality_gate/install/templates/codex_hooks.json.tt +1 -0
- data/lib/generators/quality_gate/install/templates/codex_verify_stop.rb.tt +28 -0
- data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +6 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +16 -3
- data/lib/generators/quality_gate/install/templates/quality_gate_workflow.yml.tt +28 -0
- data/lib/generators/quality_gate/install/templates/rails_quality_gate.yml.tt +45 -0
- data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +13 -2
- data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +5 -0
- data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +1 -0
- data/lib/quality_gate/adapters/bundler_audit.rb +28 -3
- data/lib/quality_gate/adapters/database_consistency.rb +51 -0
- data/lib/quality_gate/adapters/database_consistency_report.rb +61 -0
- data/lib/quality_gate/adapters/database_consistency_report_item.rb +122 -0
- data/lib/quality_gate/adapters/debride.rb +67 -0
- data/lib/quality_gate/adapters/debride_report.rb +80 -0
- data/lib/quality_gate/adapters/herb.rb +303 -0
- data/lib/quality_gate/adapters/reek.rb +21 -2
- data/lib/quality_gate/adapters/rubocop.rb +23 -9
- data/lib/quality_gate/adapters/rubycritic.rb +74 -0
- data/lib/quality_gate/adapters/rubycritic_report.rb +112 -0
- data/lib/quality_gate/adapters/test_suite.rb +12 -1
- data/lib/quality_gate/baseline.rb +269 -0
- data/lib/quality_gate/baseline_run.rb +80 -0
- data/lib/quality_gate/cli.rb +129 -6
- data/lib/quality_gate/codex_fast_hook.rb +163 -0
- data/lib/quality_gate/codex_patch_files.rb +95 -0
- data/lib/quality_gate/codex_stop_hook.rb +102 -0
- data/lib/quality_gate/config.rb +20 -6
- data/lib/quality_gate/database_consistency_runner.rb +124 -0
- data/lib/quality_gate/doctor.rb +217 -0
- data/lib/quality_gate/doctor_bounded_file.rb +74 -0
- data/lib/quality_gate/doctor_command.rb +130 -0
- data/lib/quality_gate/doctor_coverage.rb +113 -0
- data/lib/quality_gate/doctor_git.rb +121 -0
- data/lib/quality_gate/doctor_hooks.rb +99 -0
- data/lib/quality_gate/doctor_launchers.rb +300 -0
- data/lib/quality_gate/doctor_path_lookup.rb +52 -0
- data/lib/quality_gate/doctor_report.rb +38 -0
- data/lib/quality_gate/init_command.rb +3 -1
- data/lib/quality_gate/installation.rb +72 -17
- data/lib/quality_gate/installer.rb +3 -0
- data/lib/quality_gate/reporters/doctor.rb +57 -0
- data/lib/quality_gate/reporters/json.rb +18 -1
- data/lib/quality_gate/reporters/markdown.rb +11 -1
- data/lib/quality_gate/reporters/text.rb +14 -6
- data/lib/quality_gate/ruby_profile.rb +121 -2
- data/lib/quality_gate/runner.rb +15 -17
- data/lib/quality_gate/version.rb +1 -1
- data/lib/quality_gate.rb +15 -0
- data/sig/quality_gate.rbs +117 -2
- metadata +30 -1
data/lib/quality_gate/cli.rb
CHANGED
|
@@ -6,12 +6,13 @@ module QualityGate
|
|
|
6
6
|
# Parses command-line input and coordinates configured quality gates.
|
|
7
7
|
# rubocop:disable Metrics/ClassLength
|
|
8
8
|
class CLI
|
|
9
|
-
SUBCOMMANDS = %w[fast verify audit version init].map!(&:freeze).freeze
|
|
10
|
-
GATE_SUBCOMMANDS =
|
|
9
|
+
SUBCOMMANDS = %w[fast verify audit deep version init doctor].map!(&:freeze).freeze
|
|
10
|
+
GATE_SUBCOMMANDS = %w[fast verify audit deep].map!(&:freeze).freeze
|
|
11
11
|
GATE_TOOLS = {
|
|
12
12
|
"fast" => %w[rubocop].freeze,
|
|
13
13
|
"verify" => %w[reek test_suite undercover].freeze,
|
|
14
|
-
"audit" => %w[brakeman bundler_audit].freeze
|
|
14
|
+
"audit" => %w[brakeman bundler_audit].freeze,
|
|
15
|
+
"deep" => %w[rubycritic].freeze
|
|
15
16
|
}.freeze
|
|
16
17
|
|
|
17
18
|
class << self
|
|
@@ -61,6 +62,7 @@ module QualityGate
|
|
|
61
62
|
end
|
|
62
63
|
return print_version(stdout, stderr) if subcommand == "version" && arguments.empty?
|
|
63
64
|
return run_init(arguments, stdout: stdout, stderr: stderr, dir: dir) if subcommand == "init"
|
|
65
|
+
return run_doctor(arguments, stdout: stdout, stderr: stderr, dir: dir) if subcommand == "doctor"
|
|
64
66
|
|
|
65
67
|
nil
|
|
66
68
|
end
|
|
@@ -71,6 +73,10 @@ module QualityGate
|
|
|
71
73
|
InitCommand.run(arguments, stdout: stdout, stderr: stderr, dir: dir)
|
|
72
74
|
end
|
|
73
75
|
|
|
76
|
+
def run_doctor(arguments, stdout:, stderr:, dir:)
|
|
77
|
+
DoctorCommand.new(dir:, registry: registry).run(arguments, stdout:, stderr:)
|
|
78
|
+
end
|
|
79
|
+
|
|
74
80
|
def execute_command(subcommand, arguments, stdout:, stderr:, dir:, format:)
|
|
75
81
|
unless GATE_SUBCOMMANDS.include?(subcommand)
|
|
76
82
|
return invalid_command(subcommand, stdout: stdout, stderr: stderr, format: format)
|
|
@@ -87,6 +93,9 @@ module QualityGate
|
|
|
87
93
|
|
|
88
94
|
settings = config.to_h.merge(overrides)
|
|
89
95
|
validate_paths!(settings.fetch(:files), dir)
|
|
96
|
+
operation = baseline_operation(subcommand, settings, overrides, dir)
|
|
97
|
+
settings[:baseline_operation] = operation if operation
|
|
98
|
+
settings.delete(:baseline_option)
|
|
90
99
|
validate_simplecov_configuration!(subcommand, settings, config.path || File.join(dir, ".quality_gate.yml"))
|
|
91
100
|
dispatch(subcommand, settings, stdout: stdout, stderr: stderr)
|
|
92
101
|
end
|
|
@@ -161,6 +170,7 @@ module QualityGate
|
|
|
161
170
|
|
|
162
171
|
def option_parser(overrides)
|
|
163
172
|
OptionParser.new.tap do |parser|
|
|
173
|
+
add_baseline_options(parser, overrides)
|
|
164
174
|
parser.on("--files PATH") do |path|
|
|
165
175
|
raise OptionParser::InvalidOption, "--files" if overrides.key?(:files)
|
|
166
176
|
|
|
@@ -171,6 +181,54 @@ module QualityGate
|
|
|
171
181
|
end
|
|
172
182
|
end
|
|
173
183
|
|
|
184
|
+
def add_baseline_options(parser, overrides)
|
|
185
|
+
parser.on("--baseline PATH") { |path| set_baseline_option(overrides, :compare, path) }
|
|
186
|
+
parser.on("--create-baseline PATH") { |path| set_baseline_option(overrides, :create, path) }
|
|
187
|
+
parser.on("--ratchet-baseline PATH") { |path| set_baseline_option(overrides, :ratchet, path) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def set_baseline_option(overrides, mode, path)
|
|
191
|
+
raise OptionParser::InvalidOption, "baseline options are mutually exclusive" if overrides.key?(:baseline_option)
|
|
192
|
+
raise OptionParser::InvalidArgument, "baseline path must be non-empty" if path.empty?
|
|
193
|
+
|
|
194
|
+
overrides[:baseline_option] = { mode:, path: }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def baseline_operation(subcommand, settings, overrides, dir)
|
|
198
|
+
requested = overrides[:baseline_option]
|
|
199
|
+
return unless requested || settings.fetch(:baseline).key?(subcommand.to_sym)
|
|
200
|
+
|
|
201
|
+
validate_baseline_gate!(subcommand)
|
|
202
|
+
operation = requested || configured_baseline(settings, subcommand)
|
|
203
|
+
validate_baseline_scope!(operation, settings, overrides)
|
|
204
|
+
resolved_baseline(operation, dir)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def validate_baseline_gate!(subcommand)
|
|
208
|
+
return if %w[fast verify].include?(subcommand)
|
|
209
|
+
|
|
210
|
+
raise ArgumentError, "baselines are supported only for fast and verify"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def configured_baseline(settings, subcommand)
|
|
214
|
+
{ mode: :compare, path: settings.fetch(:baseline).fetch(subcommand.to_sym) }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def validate_baseline_scope!(operation, settings, overrides)
|
|
218
|
+
return unless %i[create ratchet].include?(operation.fetch(:mode))
|
|
219
|
+
return unless overrides.key?(:files) || settings.fetch(:files).any?
|
|
220
|
+
|
|
221
|
+
raise ArgumentError, "baseline creation and ratcheting require the full configured file scope"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def resolved_baseline(operation, dir)
|
|
225
|
+
{
|
|
226
|
+
mode: operation.fetch(:mode),
|
|
227
|
+
path: File.expand_path(operation.fetch(:path), dir),
|
|
228
|
+
root: File.expand_path(dir)
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
174
232
|
def append_remaining_files(overrides, arguments)
|
|
175
233
|
if overrides.key?(:files)
|
|
176
234
|
overrides[:files].concat(arguments)
|
|
@@ -204,6 +262,7 @@ module QualityGate
|
|
|
204
262
|
end
|
|
205
263
|
|
|
206
264
|
def dispatch(subcommand, settings, stdout:, stderr:)
|
|
265
|
+
operation = settings.delete(:baseline_operation)
|
|
207
266
|
config = Config.new(settings)
|
|
208
267
|
diagnostic_io = terminal_diagnostic_io(stderr)
|
|
209
268
|
result = Runner.new(
|
|
@@ -212,6 +271,8 @@ module QualityGate
|
|
|
212
271
|
diagnostic_io: diagnostic_io
|
|
213
272
|
).call
|
|
214
273
|
|
|
274
|
+
result = apply_baseline_operation(result, operation, subcommand, settings) if operation
|
|
275
|
+
|
|
215
276
|
begin
|
|
216
277
|
reporter_for(settings.fetch(:format), stdout: stdout).call(result)
|
|
217
278
|
rescue StandardError => e
|
|
@@ -222,6 +283,11 @@ module QualityGate
|
|
|
222
283
|
result.exit_code
|
|
223
284
|
end
|
|
224
285
|
|
|
286
|
+
def apply_baseline_operation(result, operation, subcommand, settings)
|
|
287
|
+
tools = settings.fetch(:adapters).fetch(subcommand.to_sym)
|
|
288
|
+
BaselineRun.new(operation:, gate: subcommand, tools:).call(result)
|
|
289
|
+
end
|
|
290
|
+
|
|
225
291
|
def adapters_for(subcommand, settings:, config:, diagnostic_io:)
|
|
226
292
|
settings.fetch(:adapters).fetch(subcommand.to_sym).map do |adapter_name|
|
|
227
293
|
build_adapter(
|
|
@@ -282,7 +348,7 @@ module QualityGate
|
|
|
282
348
|
return gate_help_text(gate) if gate
|
|
283
349
|
|
|
284
350
|
<<~TEXT
|
|
285
|
-
Usage: quality_gate <fast|verify|audit|version|init> [options]
|
|
351
|
+
Usage: quality_gate <fast|verify|audit|deep|version|init|doctor> [options]
|
|
286
352
|
|
|
287
353
|
Run one Quality Gate check for the current project.
|
|
288
354
|
|
|
@@ -290,24 +356,32 @@ module QualityGate
|
|
|
290
356
|
fast RuboCop (quick feedback for selected files)
|
|
291
357
|
verify Reek, test_suite, and Undercover (tests and coverage)
|
|
292
358
|
audit Brakeman and bundler_audit (security checks)
|
|
359
|
+
deep RubyCritic (complexity and duplication checks; optional)
|
|
293
360
|
version Print the Quality Gate version
|
|
294
361
|
init Install a plain Ruby project configuration
|
|
362
|
+
doctor Inspect bounded preflight readiness without running gates
|
|
295
363
|
|
|
296
364
|
Options:
|
|
297
365
|
--files PATH [PATH ...] Replace configured paths for this run; files and directories are allowed
|
|
298
366
|
--format FORMAT Choose text, json, or markdown output (also accepts --format=FORMAT)
|
|
367
|
+
--baseline PATH Accept recorded warnings on fast/verify
|
|
368
|
+
--create-baseline PATH Create a warning snapshot on fast/verify
|
|
369
|
+
--ratchet-baseline PATH Shrink a clean fast/verify snapshot
|
|
299
370
|
-h, --help Show this help
|
|
300
371
|
|
|
301
372
|
Scope:
|
|
302
373
|
Without --files, the configured files are used. With --files, the command-line paths replace them.
|
|
303
374
|
RuboCop and Reek scan selected paths. The test suite runs in full; Undercover checks the Git diff.
|
|
304
375
|
Brakeman scans the application and bundler-audit scans the lockfile.
|
|
376
|
+
RubyCritic and Debride scan the whole project; --files does not narrow their scans.
|
|
305
377
|
|
|
306
378
|
Examples:
|
|
307
379
|
quality_gate fast
|
|
308
380
|
quality_gate fast --files app/models/user.rb test/models
|
|
309
381
|
quality_gate verify --format json
|
|
310
382
|
quality_gate verify --format markdown
|
|
383
|
+
quality_gate doctor --format json
|
|
384
|
+
quality_gate deep --format json
|
|
311
385
|
|
|
312
386
|
Exit status:
|
|
313
387
|
0 Checks completed cleanly
|
|
@@ -318,6 +392,9 @@ module QualityGate
|
|
|
318
392
|
|
|
319
393
|
def gate_help_text(gate)
|
|
320
394
|
tools = GATE_TOOLS.fetch(gate).join(", ")
|
|
395
|
+
return deep_gate_help_text(tools) if gate == "deep"
|
|
396
|
+
|
|
397
|
+
baseline_options = baseline_help_options(gate)
|
|
321
398
|
|
|
322
399
|
<<~TEXT
|
|
323
400
|
Usage: quality_gate #{gate} [options]
|
|
@@ -327,6 +404,7 @@ module QualityGate
|
|
|
327
404
|
Options:
|
|
328
405
|
--files PATH [PATH ...] Replace configured paths for this run; files and directories are allowed
|
|
329
406
|
--format FORMAT Choose text, json, or markdown output (also accepts --format=FORMAT)
|
|
407
|
+
#{baseline_options.chomp}
|
|
330
408
|
-h, --help Show this help
|
|
331
409
|
|
|
332
410
|
Scope:
|
|
@@ -346,6 +424,44 @@ module QualityGate
|
|
|
346
424
|
TEXT
|
|
347
425
|
end
|
|
348
426
|
|
|
427
|
+
def baseline_help_options(gate)
|
|
428
|
+
return "" unless %w[fast verify].include?(gate)
|
|
429
|
+
|
|
430
|
+
[
|
|
431
|
+
"--baseline PATH Compare with a warning snapshot",
|
|
432
|
+
" --create-baseline PATH Create a warning snapshot",
|
|
433
|
+
" --ratchet-baseline PATH Shrink a clean snapshot"
|
|
434
|
+
].join("\n")
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def deep_gate_help_text(tools)
|
|
438
|
+
<<~TEXT
|
|
439
|
+
Usage: quality_gate deep [options]
|
|
440
|
+
|
|
441
|
+
Run the deep gate using these default tools: #{tools}.
|
|
442
|
+
RubyCritic is the default analyzer, optional and installed separately.
|
|
443
|
+
Enable optional Debride with adapters.deep: [debride]; install it separately.
|
|
444
|
+
|
|
445
|
+
Options:
|
|
446
|
+
--files PATH [PATH ...] Validate paths for this run; deep analyzers scan the whole project
|
|
447
|
+
--format FORMAT Choose text, json, or markdown output (also accepts --format=FORMAT)
|
|
448
|
+
-h, --help Show this help
|
|
449
|
+
|
|
450
|
+
Scope:
|
|
451
|
+
Deep analyzers scan the whole project. --files paths are validated but do not narrow their scans.
|
|
452
|
+
|
|
453
|
+
Examples:
|
|
454
|
+
quality_gate deep
|
|
455
|
+
quality_gate deep --format json
|
|
456
|
+
# Configure adapters.deep: [debride] to run Debride
|
|
457
|
+
|
|
458
|
+
Exit status:
|
|
459
|
+
0 Checks completed cleanly
|
|
460
|
+
1 Checks completed with findings
|
|
461
|
+
2 Quality Gate could not complete (input, config, or tool failure)
|
|
462
|
+
TEXT
|
|
463
|
+
end
|
|
464
|
+
|
|
349
465
|
def init_help_text
|
|
350
466
|
<<~TEXT
|
|
351
467
|
Usage: quality_gate init [options]
|
|
@@ -358,7 +474,9 @@ module QualityGate
|
|
|
358
474
|
--test-helper PATH Use a custom test helper
|
|
359
475
|
--test-command COMMAND Override the test command
|
|
360
476
|
--skip-coverage Omit coverage and Undercover setup
|
|
361
|
-
--agents Install optional
|
|
477
|
+
--agents Install optional Claude Code hooks and contracts
|
|
478
|
+
--codex Install optional Codex patch feedback and Stop verification
|
|
479
|
+
--ci Generate an optional GitHub Actions workflow
|
|
362
480
|
--pretend Preview changes without writing them
|
|
363
481
|
-h, --help Show this help
|
|
364
482
|
|
|
@@ -367,6 +485,7 @@ module QualityGate
|
|
|
367
485
|
Examples:
|
|
368
486
|
quality_gate init
|
|
369
487
|
quality_gate init --test-framework rspec
|
|
488
|
+
quality_gate init --codex
|
|
370
489
|
quality_gate init --pretend
|
|
371
490
|
TEXT
|
|
372
491
|
end
|
|
@@ -430,12 +549,16 @@ module QualityGate
|
|
|
430
549
|
def registry
|
|
431
550
|
{
|
|
432
551
|
"reek" => QualityGate::Adapters::Reek,
|
|
552
|
+
"herb" => QualityGate::Adapters::Herb,
|
|
433
553
|
"rubocop" => QualityGate::Adapters::RuboCop,
|
|
434
554
|
"simplecov" => QualityGate::Adapters::SimpleCov,
|
|
435
555
|
"test_suite" => QualityGate::Adapters::TestSuite,
|
|
436
556
|
"undercover" => QualityGate::Adapters::Undercover,
|
|
437
557
|
"brakeman" => QualityGate::Adapters::Brakeman,
|
|
438
|
-
"bundler_audit" => QualityGate::Adapters::BundlerAudit
|
|
558
|
+
"bundler_audit" => QualityGate::Adapters::BundlerAudit,
|
|
559
|
+
"database_consistency" => QualityGate::Adapters::DatabaseConsistency,
|
|
560
|
+
"rubycritic" => QualityGate::Adapters::RubyCritic,
|
|
561
|
+
"debride" => QualityGate::Adapters::Debride
|
|
439
562
|
}.freeze
|
|
440
563
|
end
|
|
441
564
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require_relative "codex_patch_files"
|
|
7
|
+
|
|
8
|
+
module QualityGate
|
|
9
|
+
# Runs file-scoped fast checks after Codex applies a native patch.
|
|
10
|
+
class CodexFastHook
|
|
11
|
+
FINDING_STRING_FIELDS = %w[tool file rule severity message].freeze
|
|
12
|
+
private_constant :FINDING_STRING_FIELDS
|
|
13
|
+
|
|
14
|
+
class Report
|
|
15
|
+
def self.parse(output)
|
|
16
|
+
report = JSON.parse(output)
|
|
17
|
+
raise TypeError unless valid?(report)
|
|
18
|
+
|
|
19
|
+
report
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.valid?(report)
|
|
23
|
+
report.is_a?(Hash) && valid_findings?(report["findings"]) &&
|
|
24
|
+
valid_summary?(report["summary"], report.fetch("findings").length)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.valid_findings?(findings)
|
|
28
|
+
findings.is_a?(Array) && findings.all? { valid_finding?(_1) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.valid_finding?(finding)
|
|
32
|
+
return false unless finding.is_a?(Hash) && FINDING_STRING_FIELDS.all? { finding[_1].is_a?(String) }
|
|
33
|
+
|
|
34
|
+
line = finding["line"]
|
|
35
|
+
line.is_a?(Integer) && line >= 0 && Finding::SEVERITIES.any? { _1.to_s == finding["severity"] } &&
|
|
36
|
+
finding["rule"] != Finding::TOOL_FAILURE_RULE
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.valid_summary?(summary, count)
|
|
40
|
+
return false unless summary.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
findings, failures = summary.values_at("findings", "tool_failures")
|
|
43
|
+
findings.is_a?(Integer) && findings == count && failures.is_a?(Integer) &&
|
|
44
|
+
failures.zero? && summary["failed_tools"] == []
|
|
45
|
+
end
|
|
46
|
+
private_class_method :valid_findings?, :valid_finding?, :valid_summary?
|
|
47
|
+
end
|
|
48
|
+
private_constant :Report
|
|
49
|
+
|
|
50
|
+
def initialize(dir:)
|
|
51
|
+
@dir = File.expand_path(dir)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def call(input)
|
|
55
|
+
return unavailable unless valid_input?(input)
|
|
56
|
+
|
|
57
|
+
evaluate(input)
|
|
58
|
+
rescue StandardError
|
|
59
|
+
unavailable
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
attr_reader :dir
|
|
65
|
+
|
|
66
|
+
def valid_input?(input)
|
|
67
|
+
input.is_a?(Hash) && input["hook_event_name"] == "PostToolUse" && input["tool_name"] == "apply_patch" &&
|
|
68
|
+
input.dig("tool_input", "command").is_a?(String) && absolute_existing_path?(input["cwd"])
|
|
69
|
+
rescue TypeError, NoMethodError
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def evaluate(input)
|
|
74
|
+
root = File.realpath(dir)
|
|
75
|
+
cwd = File.realpath(input.fetch("cwd"))
|
|
76
|
+
return unavailable unless inside?(cwd, root) && File.directory?(cwd)
|
|
77
|
+
|
|
78
|
+
files = selected_files(input.fetch("tool_input").fetch("command"), cwd, root)
|
|
79
|
+
files.empty? ? {} : run_fast(files)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def absolute_existing_path?(path)
|
|
83
|
+
path.is_a?(String) && Pathname.new(path).absolute? && File.directory?(path)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inside?(path, root)
|
|
87
|
+
path == root || path.start_with?("#{root}#{File::SEPARATOR}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def selected_files(patch, cwd, root)
|
|
91
|
+
paths = CodexPatchFiles.parse(patch)
|
|
92
|
+
ruby_files = resolved_files(paths, ".rb", cwd, root)
|
|
93
|
+
erb_files = resolved_files(paths, ".erb", cwd, root)
|
|
94
|
+
return ruby_files if erb_files.empty? || !herb_enabled?(root)
|
|
95
|
+
|
|
96
|
+
ruby_files + erb_files
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def resolved_files(paths, extension, cwd, root)
|
|
100
|
+
paths.filter_map { resolved_file(_1, cwd, root) if File.extname(_1) == extension }.uniq
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def herb_enabled?(root)
|
|
104
|
+
Config.load(dir: root).fetch(:adapters).fetch(:fast).include?("herb")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def resolved_file(path, cwd, root)
|
|
108
|
+
expanded = File.expand_path(path, cwd)
|
|
109
|
+
return unless File.file?(expanded)
|
|
110
|
+
|
|
111
|
+
canonical_file(expanded, root)
|
|
112
|
+
rescue SystemCallError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def canonical_file(path, root)
|
|
117
|
+
canonical = File.realpath(path)
|
|
118
|
+
canonical if inside?(canonical, root) && File.file?(canonical)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def run_fast(files)
|
|
122
|
+
stdout = StringIO.new
|
|
123
|
+
status = CLI.run(["fast", "--format", "json", "--files", *files], stdout:, stderr: StringIO.new, dir:)
|
|
124
|
+
respond_to_report(status, Report.parse(stdout.string))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def respond_to_report(status, report)
|
|
128
|
+
findings = report.fetch("findings")
|
|
129
|
+
return {} if status.zero? && findings.empty?
|
|
130
|
+
return unavailable unless status == 1 && findings.any?
|
|
131
|
+
|
|
132
|
+
additional_context("Fix these Quality Gate findings after this edit:\n#{format_findings(findings)}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def format_findings(findings)
|
|
136
|
+
findings.map { format_finding(_1) }.join("\n")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def format_finding(finding)
|
|
140
|
+
"#{finding.fetch("file")}:#{finding.fetch("line")} " \
|
|
141
|
+
"[#{finding.fetch("tool")}/#{finding.fetch("rule")}] #{finding.fetch("message")}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def unavailable
|
|
145
|
+
message = "Quality Gate fast feedback unavailable; run `bundle exec quality_gate fast` manually."
|
|
146
|
+
{ "systemMessage" => message }.merge(
|
|
147
|
+
"hookSpecificOutput" => hook_output(unavailable_context)
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def unavailable_context
|
|
152
|
+
"Quality Gate fast feedback is unavailable. Run `bundle exec quality_gate fast` manually."
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def additional_context(context)
|
|
156
|
+
{ "hookSpecificOutput" => hook_output(context) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def hook_output(context)
|
|
160
|
+
{ "hookEventName" => "PostToolUse", "additionalContext" => context }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QualityGate
|
|
4
|
+
# Extracts native apply_patch file operations without interpreting hunk content as paths.
|
|
5
|
+
class CodexPatchFiles
|
|
6
|
+
BEGIN_MARKER = "*** Begin Patch"
|
|
7
|
+
END_MARKER = "*** End Patch"
|
|
8
|
+
OPERATIONS = %w[Add Update Delete].freeze
|
|
9
|
+
private_constant :BEGIN_MARKER, :END_MARKER, :OPERATIONS
|
|
10
|
+
|
|
11
|
+
def self.parse(patch)
|
|
12
|
+
new(patch).call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(patch)
|
|
16
|
+
@lines = patch.lines(chomp: true).map { _1.delete_suffix("\r") }
|
|
17
|
+
@paths = []
|
|
18
|
+
@section = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
validate_patch!
|
|
23
|
+
lines[1...-1].each { process_line(_1) }
|
|
24
|
+
paths
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :lines, :paths, :section
|
|
30
|
+
|
|
31
|
+
def validate_patch!
|
|
32
|
+
raise ArgumentError unless lines.length >= 3 && lines.first == BEGIN_MARKER && lines.last == END_MARKER
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def process_line(line)
|
|
36
|
+
process_operation(line) || process_move(line) || process_end_of_file(line) || process_content(line)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def process_operation(line)
|
|
40
|
+
match = line.match(/\A\*\*\* (#{OPERATIONS.join("|")}) File: (.+)\z/)
|
|
41
|
+
return false unless match
|
|
42
|
+
|
|
43
|
+
record_operation(match)
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record_operation(match)
|
|
48
|
+
@section = { operation: match[1], moved: false, in_hunk: false }
|
|
49
|
+
paths << match[2] unless match[1] == "Delete"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def process_move(line)
|
|
53
|
+
return false unless line.start_with?("*** Move to: ")
|
|
54
|
+
|
|
55
|
+
apply_move(line.delete_prefix("*** Move to: "))
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def apply_move(destination)
|
|
60
|
+
valid_move!(destination)
|
|
61
|
+
paths[-1] = destination
|
|
62
|
+
section[:moved] = true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def valid_move!(destination)
|
|
66
|
+
valid = section && section.fetch(:operation) == "Update" && !section.fetch(:moved) &&
|
|
67
|
+
!section.fetch(:in_hunk) && !destination.empty?
|
|
68
|
+
raise ArgumentError unless valid
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def process_end_of_file(line)
|
|
72
|
+
return false unless line == "*** End of File"
|
|
73
|
+
|
|
74
|
+
valid = section && section.fetch(:operation) == "Update" && section.fetch(:in_hunk)
|
|
75
|
+
raise ArgumentError unless valid
|
|
76
|
+
|
|
77
|
+
section[:in_hunk] = true
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def process_content(line)
|
|
82
|
+
raise ArgumentError unless valid_content_line?(line)
|
|
83
|
+
|
|
84
|
+
section[:in_hunk] = true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def valid_content_line?(line)
|
|
88
|
+
section && valid_content?(line)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def valid_content?(line)
|
|
92
|
+
line.start_with?("+", "-", " ", "@@", "\")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module QualityGate
|
|
7
|
+
# Runs the full verification gate at a native Codex Stop boundary.
|
|
8
|
+
class CodexStopHook
|
|
9
|
+
VERIFY_ARGUMENTS = %w[verify --format json].freeze
|
|
10
|
+
STRING_FIELDS = %w[tool file rule severity message].freeze
|
|
11
|
+
private_constant :VERIFY_ARGUMENTS, :STRING_FIELDS
|
|
12
|
+
|
|
13
|
+
def initialize(dir:)
|
|
14
|
+
@dir = File.expand_path(dir)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(input)
|
|
18
|
+
return unavailable unless valid_input?(input)
|
|
19
|
+
|
|
20
|
+
run_verify(input.fetch("stop_hook_active"))
|
|
21
|
+
rescue StandardError
|
|
22
|
+
unavailable
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :dir
|
|
28
|
+
|
|
29
|
+
def valid_input?(input)
|
|
30
|
+
input.is_a?(Hash) && input["hook_event_name"] == "Stop" &&
|
|
31
|
+
[true, false].include?(input["stop_hook_active"])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run_verify(active)
|
|
35
|
+
stdout = StringIO.new
|
|
36
|
+
status = CLI.run(VERIFY_ARGUMENTS, stdout:, stderr: StringIO.new, dir:)
|
|
37
|
+
respond_to_report(status, parse_report(stdout.string), active)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parse_report(output)
|
|
41
|
+
report = JSON.parse(output)
|
|
42
|
+
raise TypeError unless valid_report?(report)
|
|
43
|
+
|
|
44
|
+
report
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def valid_report?(report)
|
|
48
|
+
return false unless report.is_a?(Hash) && valid_findings?(report["findings"])
|
|
49
|
+
|
|
50
|
+
valid_summary?(report["summary"], report["findings"].length)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def valid_findings?(findings)
|
|
54
|
+
findings.is_a?(Array) && findings.all? { valid_finding?(_1) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def valid_finding?(finding)
|
|
58
|
+
finding.is_a?(Hash) && STRING_FIELDS.all? { finding[_1].is_a?(String) } && finding["line"].is_a?(Integer) &&
|
|
59
|
+
finding["line"] >= 0 && Finding::SEVERITIES.any? { _1.to_s == finding["severity"] } &&
|
|
60
|
+
finding["rule"] != Finding::TOOL_FAILURE_RULE
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def valid_summary?(summary, count)
|
|
64
|
+
return false unless summary.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
findings, failures = summary.values_at("findings", "tool_failures")
|
|
67
|
+
findings.is_a?(Integer) && findings == count && failures.is_a?(Integer) &&
|
|
68
|
+
failures.zero? && summary["failed_tools"] == []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def respond_to_report(status, report, active)
|
|
72
|
+
findings = report.fetch("findings")
|
|
73
|
+
return {} if status.zero? && findings.empty?
|
|
74
|
+
return unavailable unless status == 1 && findings.any?
|
|
75
|
+
return capped(findings) if active
|
|
76
|
+
|
|
77
|
+
{ "decision" => "block", "reason" => "Fix these findings before finishing:\n#{format_findings(findings)}" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def format_findings(findings)
|
|
81
|
+
findings.map { format_finding(_1) }.join("\n")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def format_finding(finding)
|
|
85
|
+
file = finding.fetch("file")
|
|
86
|
+
line = finding.fetch("line")
|
|
87
|
+
tool, rule, message = finding.values_at("tool", "rule", "message")
|
|
88
|
+
"#{file}:#{line} [#{tool}/#{rule}] #{message}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def capped(findings)
|
|
92
|
+
message = "Quality Gate continuation limit reached; remaining findings:\n#{format_findings(findings)}"
|
|
93
|
+
{ "systemMessage" => message }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def unavailable
|
|
97
|
+
message = "Quality Gate verification unavailable; run " \
|
|
98
|
+
"`bundle exec quality_gate verify --format json` manually."
|
|
99
|
+
{ "systemMessage" => message }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|