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,1577 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "pathname"
6
+ require "rbconfig"
7
+ require "tempfile"
8
+
9
+ module QualityGate
10
+ module Installation # rubocop:disable Metrics/ModuleLength
11
+ FILES = %w[
12
+ .quality_gate.yml
13
+ .rubocop.yml
14
+ config/initializers/bullet.rb
15
+ config/initializers/strong_migrations.rb
16
+ test/test_helper.rb
17
+ .claude/hooks/quality_gate_fast.rb
18
+ .claude/hooks/quality_gate_verify_stop.rb
19
+ .claude/settings.json
20
+ CLAUDE.md
21
+ AGENTS.md
22
+ ].freeze
23
+ MARKER_START = "# quality_gate coverage — start"
24
+ MARKER_END = "# quality_gate coverage — end"
25
+ AGENT_CONTRACT_START = "<!-- quality_gate agent contract — start -->"
26
+ AGENT_CONTRACT_END = "<!-- quality_gate agent contract — end -->"
27
+ AGENT_TEMPLATE_PATHS = {
28
+ ".claude/hooks/quality_gate_fast.rb" => "quality_gate_fast.rb.tt",
29
+ ".claude/hooks/quality_gate_verify_stop.rb" => "quality_gate_verify_stop.rb.tt",
30
+ ".claude/settings.json" => "claude_settings.json.tt"
31
+ }.freeze
32
+ AGENT_CONTRACT_PATHS = %w[CLAUDE.md AGENTS.md].freeze
33
+ PRIOR_CLAUDE_SETTINGS = [
34
+ <<~'JSON'.b.freeze
35
+ {
36
+ "hooks": {
37
+ "PostToolUse": [
38
+ {
39
+ "matcher": "Edit|Write",
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/quality_gate_fast.rb",
44
+ "args": [],
45
+ "timeout": 30
46
+ }
47
+ ]
48
+ }
49
+ ]
50
+ }
51
+ }
52
+ JSON
53
+ ].freeze
54
+ STRONG_MIGRATIONS_PROVENANCE = "# Generated by Quality Gate."
55
+ HOOK_MODE = 0o755
56
+ DEFAULT_AGENT_ARTIFACT_MODE = 0o644
57
+ TEMPFILE_MODE = 0o600
58
+ # Descriptor-relative filesystem wrappers used to avoid cwd-sensitive publication.
59
+ module DirectoryOperations # rubocop:disable Metrics/ModuleLength
60
+ extend self
61
+
62
+ class Unsupported < StandardError; end
63
+
64
+ IMPORTER_MUTEX = Mutex.new
65
+ BUNDLED_REQUIRE_MUTEX = Mutex.new
66
+ LINK_FLAGS = 0
67
+ UNLINK_FLAGS = 0
68
+ O_DIRECTORY = File.const_defined?(:DIRECTORY) ? File::DIRECTORY : 0
69
+ O_NONBLOCK = File.const_defined?(:NONBLOCK) ? File::NONBLOCK : 0
70
+ O_CLOEXEC = File.const_defined?(:CLOEXEC) ? File::CLOEXEC : 0
71
+ RENAME_EXCLUSIVE_FLAGS = if RUBY_PLATFORM.include?("darwin")
72
+ { name: :renameatx_np, value: 0x0004 }
73
+ elsif RUBY_PLATFORM.include?("linux")
74
+ { name: :renameat2, value: 0x0001 }
75
+ end
76
+
77
+ def open_file(directory_io, entry_name, flags, mode = 0)
78
+ fd = syscall(:openat, "int openat(int, const char*, int, int)", directory_io.fileno, entry_name, flags, mode)
79
+ File.for_fd(fd).tap do |io|
80
+ io.close_on_exec = true if io.respond_to?(:close_on_exec=)
81
+ end
82
+ end
83
+
84
+ def open_directory(directory_io, entry_name, flags)
85
+ open_file(directory_io, entry_name, flags | O_DIRECTORY)
86
+ end
87
+
88
+ def mkdir(directory_io, entry_name, mode)
89
+ syscall(:mkdirat, "int mkdirat(int, const char*, unsigned short)", directory_io.fileno, entry_name, mode)
90
+ end
91
+
92
+ def link(directory_io, source_name, destination_name)
93
+ syscall(
94
+ :linkat,
95
+ "int linkat(int, const char*, int, const char*, int)",
96
+ directory_io.fileno,
97
+ source_name,
98
+ directory_io.fileno,
99
+ destination_name,
100
+ LINK_FLAGS
101
+ )
102
+ end
103
+
104
+ def rename_noreplace(directory_io, source_name, destination_name)
105
+ rename_flags = RENAME_EXCLUSIVE_FLAGS or raise Unsupported, "exclusive rename unavailable"
106
+
107
+ syscall(
108
+ rename_flags.fetch(:name),
109
+ rename_signature(rename_flags.fetch(:name)),
110
+ directory_io.fileno,
111
+ source_name,
112
+ directory_io.fileno,
113
+ destination_name,
114
+ rename_flags.fetch(:value)
115
+ )
116
+ end
117
+
118
+ def unlink(directory_io, entry_name)
119
+ syscall(:unlinkat, "int unlinkat(int, const char*, int)", directory_io.fileno, entry_name, UNLINK_FLAGS)
120
+ end
121
+
122
+ def fsync(io)
123
+ syscall(:fsync, "int fsync(int)", io.fileno)
124
+ end
125
+
126
+ def fchmod(io, mode)
127
+ syscall(:fchmod, "int fchmod(int, unsigned short)", io.fileno, mode)
128
+ end
129
+
130
+ private
131
+
132
+ def rename_signature(function_name)
133
+ return "int renameatx_np(int, const char*, int, const char*, unsigned int)" if function_name == :renameatx_np
134
+
135
+ "int renameat2(int, const char*, int, const char*, unsigned int)"
136
+ end
137
+
138
+ def syscall(function_name, signature, *arguments)
139
+ bind(function_name, signature)
140
+ result = importer.public_send(function_name, *arguments)
141
+ return result unless result == -1
142
+
143
+ raise SystemCallError.new(function_name.to_s, Fiddle.last_error)
144
+ rescue Unsupported
145
+ raise
146
+ rescue Fiddle::DLError, NotImplementedError => e
147
+ raise Unsupported, e.message
148
+ end
149
+
150
+ def bind(function_name, signature)
151
+ return if importer.respond_to?(function_name)
152
+
153
+ importer.extern(signature)
154
+ end
155
+
156
+ def importer
157
+ return @importer if defined?(@importer) && @importer
158
+
159
+ IMPORTER_MUTEX.synchronize do
160
+ @importer ||= begin
161
+ load_fiddle_importer!
162
+ Module.new.tap do |mod|
163
+ mod.extend(Fiddle::Importer)
164
+ mod.dlload(Fiddle.dlopen(nil))
165
+ end
166
+ end
167
+ end
168
+ end
169
+
170
+ def load_fiddle_importer!
171
+ return if defined?(Fiddle::Importer)
172
+ return load_bundled_fiddle_importer! if ruby4_bundle_fiddle?
173
+
174
+ require "fiddle/import"
175
+ rescue LoadError => e
176
+ raise Unsupported, e.message
177
+ end
178
+
179
+ def ruby4_bundle_fiddle?
180
+ RUBY_VERSION.start_with?("4.") && defined?(Bundler)
181
+ end
182
+
183
+ def load_bundled_fiddle_importer!
184
+ with_bundled_fiddle_require { load(fiddle_feature_paths.fetch("fiddle/import")) }
185
+ end
186
+
187
+ def with_bundled_fiddle_require
188
+ BUNDLED_REQUIRE_MUTEX.synchronize do
189
+ original_require = Kernel.instance_method(:require)
190
+ original_verbose = $VERBOSE
191
+ feature_paths = fiddle_feature_paths
192
+ $VERBOSE = nil
193
+ Kernel.module_eval do
194
+ define_method(:require) do |feature|
195
+ path = feature_paths[feature]
196
+ if path
197
+ return false if $LOADED_FEATURES.include?(path)
198
+
199
+ loaded = path.end_with?(".rb") ? load(path) : original_require.bind_call(self, path)
200
+ $LOADED_FEATURES << path unless $LOADED_FEATURES.include?(path)
201
+ loaded || true
202
+ else
203
+ original_require.bind_call(self, feature)
204
+ end
205
+ end
206
+ end
207
+ yield
208
+ ensure
209
+ Kernel.module_eval { define_method(:require, original_require) } if original_require
210
+ $VERBOSE = original_verbose
211
+ end
212
+ end
213
+
214
+ def fiddle_feature_paths
215
+ root = bundled_fiddle_root
216
+ {
217
+ "fiddle" => File.join(root, "fiddle.rb"),
218
+ "fiddle.so" => File.join(bundled_fiddle_extension_root, "fiddle.#{RbConfig::CONFIG.fetch("DLEXT")}"),
219
+ "fiddle/closure" => File.join(root, "fiddle", "closure.rb"),
220
+ "fiddle/function" => File.join(root, "fiddle", "function.rb"),
221
+ "fiddle/version" => File.join(root, "fiddle", "version.rb"),
222
+ "fiddle/import" => File.join(root, "fiddle", "import.rb"),
223
+ "fiddle/struct" => File.join(root, "fiddle", "struct.rb"),
224
+ "fiddle/cparser" => File.join(root, "fiddle", "cparser.rb"),
225
+ "fiddle/types" => File.join(root, "fiddle", "types.rb"),
226
+ "fiddle/value" => File.join(root, "fiddle", "value.rb"),
227
+ "fiddle/pack" => File.join(root, "fiddle", "pack.rb")
228
+ }
229
+ end
230
+
231
+ def bundled_fiddle_root
232
+ Dir[File.join(RbConfig::CONFIG["prefix"], "lib/ruby/gems/**/gems/fiddle-*/lib")].max or
233
+ raise Unsupported, "fiddle support is unavailable"
234
+ end
235
+
236
+ def bundled_fiddle_extension_root
237
+ root = bundled_fiddle_root
238
+ extension = File.join(root, "fiddle.#{RbConfig::CONFIG.fetch("DLEXT")}")
239
+ return root if File.file?(extension)
240
+
241
+ gem_name = File.basename(File.dirname(root))
242
+ gem_home = File.expand_path("../../..", root)
243
+ extension_api = if defined?(Gem) && Gem.respond_to?(:extension_api_version)
244
+ Gem.extension_api_version
245
+ else
246
+ RbConfig::CONFIG.fetch("ruby_version")
247
+ end
248
+ pattern = File.join(gem_home, "extensions", RbConfig::CONFIG.fetch("arch"), extension_api, gem_name)
249
+ native_root = pattern if File.file?(File.join(pattern, "fiddle.#{RbConfig::CONFIG.fetch("DLEXT")}"))
250
+ native_root or raise Unsupported, "fiddle native extension is unavailable"
251
+ end
252
+ end
253
+ PreparedTempfile = Data.define(
254
+ :file,
255
+ :name,
256
+ :path,
257
+ :relative_path,
258
+ :leaf_name,
259
+ :directory_io,
260
+ :directory_relative_path
261
+ ) do
262
+ def binmode = file.binmode
263
+ def write(content) = file.write(content)
264
+ def flush = file.flush
265
+ def fsync = file.fsync
266
+ def chmod(mode) = file.chmod(mode)
267
+ def stat = file.stat
268
+ def close = file.close
269
+ end
270
+ private_constant :STRONG_MIGRATIONS_PROVENANCE
271
+ private_constant :PreparedTempfile
272
+ private_constant :DirectoryOperations
273
+ private_constant :AGENT_TEMPLATE_PATHS, :AGENT_CONTRACT_PATHS
274
+ private_constant :PRIOR_CLAUDE_SETTINGS
275
+ private_constant :AGENT_CONTRACT_START, :AGENT_CONTRACT_END
276
+ private_constant :HOOK_MODE, :DEFAULT_AGENT_ARTIFACT_MODE, :TEMPFILE_MODE
277
+
278
+ def create_settings_file
279
+ return unless invoke_behavior?
280
+
281
+ install_template("quality_gate.yml.tt", ".quality_gate.yml")
282
+ end
283
+
284
+ def create_rules_file
285
+ return unless invoke_behavior?
286
+
287
+ install_template("rubocop.yml.tt", ".rubocop.yml")
288
+ end
289
+
290
+ def create_initializers
291
+ return unless invoke_behavior?
292
+ return if options[:skip_initializers]
293
+
294
+ install_template("bullet.rb.tt", "config/initializers/bullet.rb")
295
+ install_template("strong_migrations.rb.tt", "config/initializers/strong_migrations.rb")
296
+ end
297
+
298
+ def inject_coverage
299
+ return unless invoke_behavior?
300
+ return if options[:skip_coverage]
301
+
302
+ relative_path = test_helper_path
303
+ path = safe_destination_path(relative_path)
304
+ return unless path
305
+ return skip_missing_test_helper unless File.file?(path)
306
+
307
+ install_coverage(path, relative_path)
308
+ end
309
+
310
+ def create_agent_integration
311
+ return unless invoke_behavior?
312
+
313
+ unless options[:agents]
314
+ record(:skipped, "agent integration (pass --agents to install Claude hooks and contracts)")
315
+ return
316
+ end
317
+
318
+ install_template("quality_gate_fast.rb.tt", ".claude/hooks/quality_gate_fast.rb", mode: HOOK_MODE)
319
+ install_template(
320
+ "quality_gate_verify_stop.rb.tt",
321
+ ".claude/hooks/quality_gate_verify_stop.rb",
322
+ mode: HOOK_MODE
323
+ )
324
+ install_template("claude_settings.json.tt", ".claude/settings.json")
325
+ install_agent_contract("CLAUDE.md")
326
+ install_agent_contract("AGENTS.md")
327
+ end
328
+
329
+ def print_summary
330
+ return print_unsupported_behavior unless invoke_behavior?
331
+
332
+ say "Quality Gate install summary:"
333
+ print_group("Written", :written)
334
+ print_group("Unchanged", :unchanged)
335
+ print_group("Needs a person", :needs_person)
336
+ print_group("Skipped", :skipped)
337
+ say "Next: bundle exec quality_gate fast"
338
+ end
339
+
340
+ private
341
+
342
+ def invoke_behavior?
343
+ behavior == :invoke
344
+ end
345
+
346
+ def print_unsupported_behavior
347
+ say "Automatic removal is not supported. See the README installation undo instructions for manual removal."
348
+ end
349
+
350
+ def install_template(template_name, relative_path, mode: nil)
351
+ path = safe_destination_path(relative_path)
352
+ return unless path
353
+
354
+ install_existing_or_new_template(path, template_name, relative_path, mode:)
355
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported => e
356
+ raise unless agent_template_path?(relative_path)
357
+
358
+ handle_agent_artifact_failure(relative_path, rendered_template(template_name), e)
359
+ end
360
+
361
+ def compare_existing_template(path, content, relative_path, existing_file:, mode: nil)
362
+ existing = existing_file.fetch(:content)
363
+ if existing == content.b
364
+ record_existing_template(path, relative_path, existing:, mode:, status: existing_file.fetch(:status))
365
+ elsif prior_claude_settings?(relative_path, existing)
366
+ return record_pretend_skip(relative_path) if options[:pretend]
367
+
368
+ replace_content(path, content, relative_path, expected: existing_file)
369
+ else
370
+ say manual_instructions(relative_path, content)
371
+ record(:needs_person, relative_path)
372
+ end
373
+ end
374
+
375
+ def strong_migrations_provenance
376
+ STRONG_MIGRATIONS_PROVENANCE
377
+ end
378
+
379
+ def prior_claude_settings?(relative_path, content)
380
+ relative_path == ".claude/settings.json" && PRIOR_CLAUDE_SETTINGS.include?(content)
381
+ end
382
+
383
+ def install_agent_contract(relative_path)
384
+ path = safe_destination_path(relative_path)
385
+ return unless path
386
+
387
+ install_existing_or_new_agent_contract(path, relative_path)
388
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported => e
389
+ raise unless agent_contract_path?(relative_path)
390
+
391
+ handle_agent_artifact_failure(relative_path, agent_contract_section, e)
392
+ end
393
+
394
+ def strong_migrations_baseline
395
+ existing = @strong_migrations_existing_content
396
+ return newest_migration_version unless existing
397
+
398
+ baseline = existing[
399
+ /^[ \t]*StrongMigrations\.start_after[ \t]*=[ \t]*([0-9]+(?:_[0-9]+)*)[ \t]*(?:#.*)?\r?$/,
400
+ 1
401
+ ]
402
+ return baseline if baseline
403
+ return if quality_gate_provenance?(existing)
404
+
405
+ newest_migration_version
406
+ end
407
+
408
+ def quality_gate_provenance?(content)
409
+ generated_headers = [
410
+ "#{STRONG_MIGRATIONS_PROVENANCE}\n",
411
+ "require \"strong_migrations\"\n\n#{STRONG_MIGRATIONS_PROVENANCE}\n",
412
+ "# frozen_string_literal: true\n\nrequire 'strong_migrations'\n\n#{STRONG_MIGRATIONS_PROVENANCE}\n"
413
+ ]
414
+
415
+ generated_headers.any? { content.start_with?(_1) }
416
+ end
417
+
418
+ def migration_version_literal(version)
419
+ digits = Integer(version.to_s.delete("_"), 10).to_s
420
+
421
+ digits.reverse.scan(/\d{1,3}/).join("_").reverse
422
+ end
423
+
424
+ def install_coverage(path, relative_path)
425
+ original = File.binread(path)
426
+ return record(:unchanged, relative_path) if original.include?(MARKER_START.b)
427
+ return record_pretend_skip(relative_path) if options[:pretend]
428
+
429
+ content = content_with_coverage(original)
430
+ return unless replace_coverage(path, content, relative_path)
431
+
432
+ record_coverage_postcondition(path, content, relative_path)
433
+ end
434
+
435
+ def replace_coverage(path, content, relative_path)
436
+ begin
437
+ atomic_replace(path, content)
438
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported => e
439
+ say "Warning: #{relative_path} could not be replaced safely: #{e.message}"
440
+ record(:needs_person, relative_path)
441
+ return false
442
+ end
443
+
444
+ true
445
+ end
446
+
447
+ def atomic_replace(path, content, mode: nil, expected: nil)
448
+ publish_mode = mode || File.lstat(path).mode & 0o7777
449
+ return atomic_replace_file(path, content, publish_mode) unless expected
450
+
451
+ with_verified_existing_file(path, expected, lock: File::LOCK_EX) do |io|
452
+ atomic_replace_verified(path, content, publish_mode, expected, io)
453
+ end
454
+ end
455
+
456
+ def record_coverage_postcondition(path, content, relative_path)
457
+ if lstat(path)&.file? && File.binread(path) == content
458
+ record(:written, relative_path)
459
+ else
460
+ say "Warning: #{relative_path} could not be written safely."
461
+ record(:needs_person, relative_path)
462
+ end
463
+ end
464
+
465
+ def content_with_coverage(original)
466
+ newline = host_newline(original)
467
+ bom, body = split_utf8_bom(original)
468
+ prologue, remainder = split_coverage_prologue(body)
469
+
470
+ bom + prologue + coverage_separator(prologue, newline) + coverage_block(newline) + remainder
471
+ end
472
+
473
+ def host_newline(content)
474
+ content.include?("\r\n".b) ? "\r\n".b : "\n".b
475
+ end
476
+
477
+ def split_coverage_prologue(content)
478
+ offset = coverage_insertion_offset(content)
479
+ [content.byteslice(0, offset), content.byteslice(offset..)]
480
+ end
481
+
482
+ def coverage_separator(prologue, newline)
483
+ prologue.empty? || prologue.end_with?("\n".b) ? "".b : newline
484
+ end
485
+
486
+ def split_utf8_bom(content)
487
+ bom = "\xEF\xBB\xBF".b
488
+ content.start_with?(bom) ? [bom, content.byteslice(bom.bytesize..)] : ["".b, content]
489
+ end
490
+
491
+ def coverage_insertion_offset(content)
492
+ lines = content.lines
493
+ index = lines.first&.start_with?("#!".b) ? 1 : 0
494
+ index = next_prologue_index(lines, index) while lines[index] && prologue_construct?(lines, index)
495
+ lines.first(index).sum(&:bytesize)
496
+ end
497
+
498
+ def prologue_construct?(lines, index)
499
+ prologue_line?(lines[index]) || complete_embedded_document?(lines, index)
500
+ end
501
+
502
+ def next_prologue_index(lines, index)
503
+ return index + 1 if prologue_line?(lines[index])
504
+
505
+ embedded_document_end(lines, index) + 1
506
+ end
507
+
508
+ def prologue_line?(line)
509
+ line.match?(/\A[ \t]*(?:#|\r?\n?\z)/)
510
+ end
511
+
512
+ def complete_embedded_document?(lines, index)
513
+ embedded_document_start?(lines[index]) && embedded_document_end(lines, index)
514
+ end
515
+
516
+ def embedded_document_start?(line)
517
+ line.match?(/\A=begin(?:[ \t]|\r?\n|\z)/)
518
+ end
519
+
520
+ def embedded_document_end(lines, index)
521
+ ((index + 1)...lines.length).find { lines[_1].match?(/\A=end(?:[ \t]|\r?\n|\z)/) }
522
+ end
523
+
524
+ def coverage_block(newline)
525
+ template = rendered_template("simplecov.rb.tt").b.gsub("\n".b, newline)
526
+ "#{MARKER_START}#{newline}#{template}#{MARKER_END}#{newline}".b
527
+ end
528
+
529
+ def create_agent_contract(path, relative_path)
530
+ content = agent_contract_section
531
+ create_template(path, content, relative_path)
532
+ end
533
+
534
+ def update_agent_contract(path, relative_path, existing_file:)
535
+ original = existing_file.fetch(:content)
536
+ newline = host_newline(original)
537
+ section = agent_contract_section(newline)
538
+ markers = agent_contract_markers(original, newline)
539
+ return record(:unchanged, relative_path) if markers == :exact
540
+ if markers.is_a?(Array)
541
+ return replace_agent_contract_markers(path, relative_path, original, section, existing_file)
542
+ end
543
+
544
+ say manual_instructions(relative_path, section)
545
+ record(:needs_person, relative_path)
546
+ end
547
+
548
+ def replace_agent_contract_markers(path, relative_path, original, section, existing_file)
549
+ replace_or_append_agent_contract(
550
+ path,
551
+ relative_path,
552
+ original:,
553
+ section:,
554
+ status: existing_file.fetch(:status)
555
+ )
556
+ end
557
+
558
+ def replace_or_append_agent_contract(path, relative_path, original:, section:, status:)
559
+ content = if original.empty?
560
+ section
561
+ elsif (marker = single_agent_contract_marker(original))
562
+ replace_agent_contract_content(original, section, marker)
563
+ else
564
+ append_agent_contract(original, section)
565
+ end
566
+ return record_pretend_skip(relative_path) if options[:pretend]
567
+
568
+ expected = { content: original, status: status }
569
+ replace_content(path, content, relative_path, manual_content: section, expected:)
570
+ end
571
+
572
+ def append_agent_contract(original, section)
573
+ newline = host_newline(original)
574
+ separator = original.end_with?(newline) ? newline : "#{newline}#{newline}"
575
+
576
+ original + separator + section
577
+ end
578
+
579
+ def replace_agent_contract_content(original, section, marker)
580
+ prefix = original.byteslice(0, marker.fetch(:start))
581
+ suffix = original.byteslice(marker.fetch(:finish), original.bytesize - marker.fetch(:finish))
582
+
583
+ prefix + section + suffix.to_s
584
+ end
585
+
586
+ def replace_content(path, content, relative_path, manual_content: content, expected: nil)
587
+ begin
588
+ replaced = atomic_replace(path, content, mode: replacement_mode(expected), expected:)
589
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported => e
590
+ say "Warning: #{relative_path} could not be replaced safely: #{e.message}"
591
+ say manual_instructions(relative_path, manual_content)
592
+ record(:needs_person, relative_path)
593
+ return
594
+ end
595
+
596
+ return report_changed_agent_artifact(relative_path, manual_content) if replaced == false
597
+
598
+ record_template_postcondition(path, content, relative_path, manual_content:)
599
+ end
600
+
601
+ def agent_contract_section(newline = "\n".b)
602
+ rendered_template("agents_section.md.tt").b.gsub("\n".b, newline)
603
+ end
604
+
605
+ def agent_contract_markers(content, newline)
606
+ markers = all_agent_contract_markers(content)
607
+ return [] if markers.empty?
608
+ return :invalid if markers == :invalid
609
+
610
+ current = agent_contract_section(newline)
611
+ return :exact if markers.length == 1 && markers.first.fetch(:block) == current
612
+ return markers if markers.length == 1
613
+
614
+ :invalid
615
+ end
616
+
617
+ def single_agent_contract_marker(content)
618
+ all_agent_contract_markers(content).then do |markers|
619
+ markers.is_a?(Array) && markers.length == 1 ? markers.first : nil
620
+ end
621
+ end
622
+
623
+ def all_agent_contract_markers(content)
624
+ starts = exact_line_markers(content, AGENT_CONTRACT_START)
625
+ finishes = exact_line_markers(content, AGENT_CONTRACT_END)
626
+ return [] if starts.empty? && finishes.empty?
627
+ return :invalid unless starts.length == 1 && finishes.length == 1
628
+
629
+ [build_agent_contract_marker(content, starts.first, finishes.first)]
630
+ rescue ArgumentError
631
+ :invalid
632
+ end
633
+
634
+ def build_agent_contract_marker(content, start_marker, finish_marker)
635
+ raise ArgumentError unless start_marker.fetch(:start) < finish_marker.fetch(:start)
636
+
637
+ block_end = finish_marker.fetch(:finish)
638
+ {
639
+ start: start_marker.fetch(:start),
640
+ finish: block_end,
641
+ block: content.byteslice(start_marker.fetch(:start), block_end - start_marker.fetch(:start))
642
+ }
643
+ end
644
+
645
+ def exact_line_markers(content, marker)
646
+ markers = []
647
+ offset = 0
648
+ content.lines.each do |line|
649
+ text = line.delete_suffix("\n".b).delete_suffix("\r".b)
650
+ markers << { start: offset, finish: offset + line.bytesize } if text == marker.b
651
+ offset += line.bytesize
652
+ end
653
+
654
+ markers
655
+ end
656
+
657
+ def skip_missing_test_helper
658
+ say "Warning: Minitest coverage wiring skipped; test/test_helper.rb is missing."
659
+ record(:skipped, "test/test_helper.rb (missing; Minitest coverage wiring skipped)")
660
+ end
661
+
662
+ def record_pretend_skip(relative_path)
663
+ record(:skipped, "#{relative_path} (pretend)")
664
+ end
665
+
666
+ def create_template(path, content, relative_path, mode: nil)
667
+ return record_pretend_skip(relative_path) if options[:pretend]
668
+ return create_agent_artifact(path, content, relative_path, mode:) if agent_artifact_path?(relative_path)
669
+
670
+ create_new_file(relative_path, content)
671
+ record_template_postcondition(path, content, relative_path, mode:)
672
+ end
673
+
674
+ def install_existing_or_new_template(path, template_name, relative_path, mode: nil)
675
+ status = lstat(path)
676
+ return unsafe_destination(relative_path) if status && !status.file?
677
+
678
+ existing_file = existing_template_file(path, relative_path, status)
679
+ return unless existing_file
680
+
681
+ content = rendered_template(template_name, existing_content: existing_file.fetch(:content))
682
+
683
+ return compare_existing_template(path, content, relative_path, existing_file:, mode:) if status
684
+
685
+ create_template(path, content, relative_path, mode:)
686
+ end
687
+
688
+ def install_existing_or_new_agent_contract(path, relative_path)
689
+ status = lstat(path)
690
+ return unsafe_destination(relative_path) if status && !status.file?
691
+ return create_agent_contract(path, relative_path) unless status
692
+
693
+ existing_file = read_existing_agent_artifact(path, relative_path, status, agent_contract_section)
694
+ return unless existing_file
695
+
696
+ update_agent_contract(path, relative_path, existing_file:)
697
+ end
698
+
699
+ def print_group(heading, status)
700
+ say "#{heading}:"
701
+ results.fetch(status, []).each { say " - #{_1}" }
702
+ end
703
+
704
+ def hook_log_filesystem_source
705
+ File.binread(template_path("hook_log_filesystem.rb.tt"))
706
+ end
707
+
708
+ def rendered_template(template_name, existing_content: nil)
709
+ source = template_path(template_name)
710
+ @strong_migrations_existing_content = existing_content
711
+ ERB.new(File.binread(source), trim_mode: "-").result(binding)
712
+ ensure
713
+ remove_instance_variable(:@strong_migrations_existing_content)
714
+ end
715
+
716
+ def record(status, relative_path)
717
+ results[status] << relative_path
718
+ end
719
+
720
+ def lstat(path)
721
+ File.lstat(path)
722
+ rescue Errno::ENOENT, Errno::ENOTDIR
723
+ nil
724
+ end
725
+
726
+ def record_template_postcondition(path, content, relative_path, mode: nil, manual_content: content)
727
+ apply_mode(path, mode) if mode && !agent_artifact_path?(relative_path)
728
+
729
+ if safe_postcondition_match?(path, content, mode)
730
+ record(:written, relative_path)
731
+ else
732
+ report_postcondition_mismatch(relative_path, manual_content)
733
+ end
734
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported => e
735
+ raise unless agent_artifact_path?(relative_path)
736
+
737
+ handle_agent_artifact_failure(relative_path, manual_content, e)
738
+ end
739
+
740
+ def safe_destination_path(relative_path)
741
+ root = Pathname.new(destination_root).expand_path
742
+ return unsafe_destination(relative_path) unless lstat(root.to_s)&.directory?
743
+
744
+ candidate = root.join(relative_path).cleanpath
745
+ return unsafe_destination(relative_path) unless contained_by?(candidate, root)
746
+ return unsafe_destination(relative_path) unless safe_path_components?(root, relative_path)
747
+
748
+ candidate.to_s
749
+ rescue Errno::ELOOP, Errno::ENOENT, Errno::ENOTDIR
750
+ unsafe_destination(relative_path)
751
+ end
752
+
753
+ def contained_by?(path, root)
754
+ path == root || path.to_s.start_with?("#{root}#{File::SEPARATOR}")
755
+ end
756
+
757
+ def safe_path_components?(root, relative_path)
758
+ cursor = root
759
+ segments = Pathname.new(relative_path).each_filename.to_a
760
+ segments.each_with_index.all? do |segment, index|
761
+ cursor = cursor.join(segment)
762
+ valid_path_component?(lstat(cursor.to_s), destination: index == segments.length - 1)
763
+ end
764
+ end
765
+
766
+ def valid_path_component?(status, destination:)
767
+ !status || (!status.symlink? && (destination ? status.file? : status.directory?))
768
+ end
769
+
770
+ def unsafe_destination(relative_path)
771
+ say "Warning: #{relative_path} is not a safe path inside the destination root and was not changed."
772
+ record(:needs_person, relative_path)
773
+ nil
774
+ end
775
+
776
+ def record_existing_template(path, relative_path, existing:, mode: nil, status: nil)
777
+ return record(:unchanged, relative_path) unless mode_repair_needed?(status, mode)
778
+ return record_pretend_skip(relative_path) if options[:pretend]
779
+ return unless apply_mode_safely(path, mode, existing, status, relative_path)
780
+
781
+ return record(:written, relative_path) if mode_matches?(path, mode)
782
+
783
+ say "Warning: #{relative_path} could not be written safely."
784
+ record(:needs_person, relative_path)
785
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported => e
786
+ handle_agent_artifact_failure(relative_path, agent_template_content(relative_path), e)
787
+ end
788
+
789
+ def apply_mode_safely(path, mode, existing, status, relative_path)
790
+ expected = { content: existing, status: status }
791
+
792
+ verify_existing_content(
793
+ path,
794
+ relative_path,
795
+ agent_template_content(relative_path),
796
+ expected:,
797
+ lock: File::LOCK_EX
798
+ ) { repair_existing_mode(path, io: _1, mode:) }
799
+ end
800
+
801
+ def apply_mode(path, mode)
802
+ File.chmod(mode, path)
803
+ end
804
+
805
+ def mode_matches?(path, mode)
806
+ return true unless mode
807
+
808
+ (File.stat(path).mode & 0o777) == mode
809
+ end
810
+
811
+ def exact_mode?(status, mode)
812
+ status && (status.mode & 0o777) == mode
813
+ end
814
+
815
+ def verify_existing_content(path, relative_path, manual_content, expected:, lock: File::LOCK_SH)
816
+ matched = with_verified_existing_file(path, expected, lock:) do |io|
817
+ block_given? ? yield(io) : true
818
+ end
819
+ return matched unless matched == false
820
+
821
+ report_changed_agent_artifact(relative_path, manual_content)
822
+ false
823
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported => e
824
+ handle_agent_artifact_failure(relative_path, manual_content, e)
825
+ false
826
+ end
827
+
828
+ def with_verified_existing_file(path, expected, lock:)
829
+ context = verified_existing_context(path)
830
+ expected_content = expected.fetch(:content)
831
+ expected_status = expected.fetch(:status)
832
+
833
+ with_anchored_parent_directory(context, create_missing: false) do |anchored_context|
834
+ with_locked_verified_io(anchored_context, expected_content, expected_status, lock) do |io|
835
+ yield io if block_given?
836
+ end
837
+ end
838
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ELOOP
839
+ false
840
+ end
841
+
842
+ def verified_existing_context(path)
843
+ relative_path_from_destination(path)
844
+ end
845
+
846
+ def with_locked_verified_io(context, expected_content, expected_status, lock)
847
+ io = open_anchored_file(context, context.fetch(:leaf_name))
848
+ io.binmode
849
+ io.flock(lock)
850
+ return false unless verified_existing_file?(context, io, expected_content, expected_status)
851
+
852
+ block_given? ? yield(io) : true
853
+ ensure
854
+ unlock_file(io) if io
855
+ io&.close
856
+ end
857
+
858
+ def verify_read_flags
859
+ flags = File::RDONLY
860
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
861
+ flags |= DirectoryOperations::O_NONBLOCK
862
+ flags |= DirectoryOperations::O_CLOEXEC
863
+ flags
864
+ end
865
+
866
+ def same_file_identity?(*stats)
867
+ stats.all? { identity_target?(_1) } && stats.map { file_identity(_1) }.uniq.one?
868
+ end
869
+
870
+ def same_file_state?(status, expected_status)
871
+ file_state(status) == file_state(expected_status)
872
+ end
873
+
874
+ def file_identity(status)
875
+ [status.dev, status.ino]
876
+ end
877
+
878
+ def file_state(status)
879
+ [status.mode, status.size, status.mtime.to_r]
880
+ end
881
+
882
+ def handle_agent_artifact_failure(relative_path, manual_content, error)
883
+ say "Warning: #{relative_path} could not be read safely: #{error.message}"
884
+ say manual_instructions(relative_path, manual_content) if manual_content
885
+ record(:needs_person, relative_path)
886
+ end
887
+
888
+ def repair_existing_mode(path, io:, mode:)
889
+ directory_operations.fchmod(io, mode)
890
+ updated_status = io.stat
891
+ current_status = lstat(path)
892
+
893
+ same_file_identity?(updated_status, current_status) && exact_mode?(updated_status, mode)
894
+ end
895
+
896
+ def report_changed_agent_artifact(relative_path, manual_content)
897
+ say "Warning: #{relative_path} changed before it could be updated safely."
898
+ say manual_instructions(relative_path, manual_content)
899
+ record(:needs_person, relative_path)
900
+ end
901
+
902
+ def verified_existing_file?(context, io, expected_content, expected_status)
903
+ opened_status = io.stat
904
+ current_status = anchored_lstat(context, context.fetch(:leaf_name))
905
+
906
+ same_file_identity?(opened_status, current_status, expected_status) &&
907
+ same_file_state?(opened_status, expected_status) &&
908
+ io.tap(&:rewind).read == expected_content.b
909
+ end
910
+
911
+ def identity_target?(status)
912
+ status && (status.file? || status.directory?)
913
+ end
914
+
915
+ def read_existing_content(path, status)
916
+ File.binread(path) if status
917
+ end
918
+
919
+ def existing_template_file(path, relative_path, status)
920
+ unless status && agent_artifact_path?(relative_path)
921
+ return { content: read_existing_content(path, status), status: status }
922
+ end
923
+
924
+ read_existing_agent_artifact(path, relative_path, status, agent_template_content(relative_path))
925
+ end
926
+
927
+ def read_existing_agent_artifact(_path, relative_path, status, manual_content)
928
+ content = anchored_existing_agent_artifact_content(relative_path, status, manual_content)
929
+ { content:, status: } if content
930
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ELOOP
931
+ report_changed_agent_artifact(relative_path, manual_content)
932
+ nil
933
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported => e
934
+ handle_agent_artifact_failure(relative_path, manual_content, e)
935
+ nil
936
+ end
937
+
938
+ def anchored_existing_agent_artifact_content(relative_path, status, manual_content)
939
+ with_anchored_parent_directory(relative_path, create_missing: false) do |context|
940
+ read_existing_agent_artifact_content(context, relative_path, status, manual_content)
941
+ end
942
+ end
943
+
944
+ def read_existing_agent_artifact_content(context, relative_path, status, manual_content)
945
+ parent_status = context.fetch(:directory_io).stat
946
+ unless current_parent_matches?(context, parent_status)
947
+ return changed_agent_artifact(relative_path, manual_content)
948
+ end
949
+
950
+ read_existing_agent_file_content(context, relative_path, status, manual_content, parent_status)
951
+ end
952
+
953
+ def read_existing_agent_file_content(context, relative_path, status, manual_content, parent_status)
954
+ open_anchored_file(context, context.fetch(:leaf_name)) do |io|
955
+ io.binmode
956
+ content = io.tap(&:rewind).read
957
+ unless final_existing_agent_artifact_snapshot_valid?(context, io, content, status, parent_status)
958
+ return changed_agent_artifact(relative_path, manual_content)
959
+ end
960
+
961
+ content
962
+ end
963
+ end
964
+
965
+ def final_existing_agent_artifact_snapshot_valid?(context, io, content, status, parent_status)
966
+ current_parent_matches?(context, parent_status) &&
967
+ existing_agent_artifact_matches?(context, io, status) &&
968
+ io.tap(&:rewind).read == content.b
969
+ end
970
+
971
+ def existing_agent_artifact_matches?(context, io, status)
972
+ opened_status = io.stat
973
+ current_status = anchored_lstat(context, context.fetch(:leaf_name))
974
+
975
+ same_file_identity?(opened_status, current_status, status) && same_file_state?(opened_status, status)
976
+ end
977
+
978
+ def changed_agent_artifact(relative_path, manual_content)
979
+ report_changed_agent_artifact(relative_path, manual_content)
980
+ nil
981
+ end
982
+
983
+ def agent_template_content(relative_path)
984
+ rendered_template(AGENT_TEMPLATE_PATHS.fetch(relative_path))
985
+ end
986
+
987
+ def mode_repair_needed?(status, mode)
988
+ mode && !exact_mode?(status, mode)
989
+ end
990
+
991
+ def create_agent_artifact(path, content, relative_path, mode: nil)
992
+ created = atomic_create(path, content, relative_path, mode)
993
+ return report_postcondition_mismatch(relative_path, content) if created == false
994
+
995
+ record_template_postcondition(path, content, relative_path, mode:)
996
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported => e
997
+ handle_agent_artifact_write_failure(relative_path, content, e)
998
+ end
999
+
1000
+ def atomic_create(path, content, relative_path, mode)
1001
+ with_prepared_tempfile(path, content, relative_path, mode) do |tempfile|
1002
+ return false unless current_parent_directory?(tempfile)
1003
+
1004
+ published = publish_new_tempfile(path, tempfile, relative_path, content)
1005
+ return true if visible_postcondition_matches?(path, content, mode, tempfile.directory_io.stat)
1006
+
1007
+ cleanup_published_leaf(tempfile, published)
1008
+ false
1009
+ end
1010
+ end
1011
+
1012
+ def atomic_replace_verified(path, content, mode, expected, io)
1013
+ with_prepared_tempfile(path, content, relative_path_from_destination(path), mode) do |tempfile|
1014
+ return claim_and_publish_existing_file(path, tempfile, expected, io, content, mode)
1015
+ end
1016
+ end
1017
+
1018
+ def with_prepared_tempfile(_path, content, relative_path, mode)
1019
+ with_anchored_parent_directory(relative_path, create_missing: true) do |context|
1020
+ tempfile = create_anchored_tempfile(context, relative_path)
1021
+ prepare_tempfile(tempfile, content, relative_path, mode)
1022
+ yield tempfile
1023
+ ensure
1024
+ cleanup_error = cleanup_tempfile(tempfile)
1025
+ raise cleanup_error if cleanup_error && !$ERROR_INFO
1026
+ end
1027
+ end
1028
+
1029
+ def prepare_tempfile(tempfile, content, relative_path, mode)
1030
+ tempfile.binmode
1031
+ write_tempfile_content(tempfile, content, relative_path)
1032
+ tempfile.flush
1033
+ directory_operations.fsync(tempfile.file)
1034
+ directory_operations.fchmod(tempfile.file, published_mode(mode))
1035
+ directory_operations.fsync(tempfile.file)
1036
+ end
1037
+
1038
+ def write_tempfile_content(tempfile, content, _relative_path)
1039
+ tempfile.write(content)
1040
+ end
1041
+
1042
+ def publish_new_tempfile(_path, tempfile, _relative_path, content)
1043
+ linked = false
1044
+ expected = prepared_entry_token(tempfile, content)
1045
+ directory_operations.link(tempfile.directory_io, tempfile.name, tempfile.leaf_name)
1046
+ linked = true
1047
+ verify_published_leaf!(tempfile, expected)
1048
+ directory_operations.fsync(tempfile.directory_io)
1049
+ expected
1050
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported
1051
+ cleanup_published_leaf(tempfile, expected) if linked && expected
1052
+ raise
1053
+ end
1054
+
1055
+ def cleanup_tempfile(tempfile)
1056
+ return unless tempfile
1057
+
1058
+ temp_status = nil
1059
+ close_error = nil
1060
+
1061
+ begin
1062
+ temp_status = tempfile.stat
1063
+ rescue IOError, SystemCallError, NotImplementedError, DirectoryOperations::Unsupported
1064
+ nil
1065
+ ensure
1066
+ begin
1067
+ tempfile.close
1068
+ rescue IOError, SystemCallError => e
1069
+ close_error = e
1070
+ end
1071
+ end
1072
+
1073
+ safe_unlink_tempfile(tempfile, temp_status)
1074
+ close_error
1075
+ end
1076
+
1077
+ def safe_unlink_tempfile(tempfile, expected_status)
1078
+ cleanup_owned_entry(
1079
+ tempfile,
1080
+ entry_name: tempfile.name,
1081
+ expected: { status: expected_status },
1082
+ restore_to: tempfile.name,
1083
+ relative_path: tempfile.relative_path,
1084
+ validate_identity: false,
1085
+ result: nil,
1086
+ report_failures: false
1087
+ )
1088
+ end
1089
+
1090
+ def atomic_replace_file(path, content, mode)
1091
+ Tempfile.create([".quality_gate-", ".tmp"], File.dirname(path)) do |tempfile|
1092
+ tempfile.binmode
1093
+ tempfile.write(content)
1094
+ tempfile.flush
1095
+ tempfile.fsync
1096
+ tempfile.chmod(mode)
1097
+ File.rename(tempfile.path, path)
1098
+ end
1099
+ end
1100
+
1101
+ def replacement_mode(expected)
1102
+ expected && (expected.fetch(:status).mode & 0o7777)
1103
+ end
1104
+
1105
+ def safe_postcondition_match?(path, content, mode)
1106
+ lstat(path)&.file? && File.binread(path) == content.b && mode_matches?(path, mode)
1107
+ end
1108
+
1109
+ def report_postcondition_mismatch(relative_path, manual_content)
1110
+ say "Warning: #{relative_path} could not be written safely."
1111
+ say manual_instructions(relative_path, manual_content) if agent_artifact_path?(relative_path)
1112
+ record(:needs_person, relative_path)
1113
+ end
1114
+
1115
+ def handle_agent_artifact_write_failure(relative_path, manual_content, error)
1116
+ say "Warning: #{relative_path} could not be written safely: #{error.message}"
1117
+ say manual_instructions(relative_path, manual_content)
1118
+ record(:needs_person, relative_path)
1119
+ end
1120
+
1121
+ def claim_and_publish_existing_file(path, tempfile, expected, io, content, mode)
1122
+ claim_name = claim_entry_name(tempfile.leaf_name)
1123
+ claimed = false
1124
+ return false unless current_parent_directory?(tempfile)
1125
+
1126
+ claimed = claim_current_leaf(tempfile, claim_name)
1127
+ return false unless claimed_current_leaf?(tempfile, expected, claim_name)
1128
+
1129
+ published_status = publish_new_tempfile(path, tempfile, tempfile.relative_path, content)
1130
+ if visible_postcondition_matches?(path, content, mode, tempfile.directory_io.stat)
1131
+ return cleanup_claim(tempfile, claim_name, expected, true)
1132
+ end
1133
+
1134
+ cleanup_published_leaf(tempfile, published_status)
1135
+ rollback_claim(tempfile, claim_name, expected)
1136
+ rescue Errno::EEXIST
1137
+ rollback_claim(tempfile, claim_name, expected) if claimed
1138
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported
1139
+ rollback_claim(tempfile, claim_name, expected) if claimed
1140
+ raise
1141
+ ensure
1142
+ io&.tap(&:rewind)
1143
+ end
1144
+
1145
+ def claimed_current_leaf?(tempfile, expected, claim_name)
1146
+ directory_operations.fsync(tempfile.directory_io)
1147
+ unless claimed_entry_matches?(tempfile, claim_name, expected)
1148
+ return restore_claimed_current_leaf(tempfile, claim_name)
1149
+ end
1150
+
1151
+ return restore_claimed_current_leaf(tempfile, claim_name) unless current_parent_directory?(tempfile)
1152
+
1153
+ true
1154
+ end
1155
+
1156
+ def claim_current_leaf(tempfile, claim_name)
1157
+ directory_operations.rename_noreplace(tempfile.directory_io, tempfile.leaf_name, claim_name)
1158
+ true
1159
+ end
1160
+
1161
+ def rollback_claim(tempfile, claim_name, expected)
1162
+ return retain_recovery_copy(tempfile, claim_name) unless current_parent_directory?(tempfile)
1163
+ return retain_recovery_copy(tempfile, claim_name) if anchored_lstat(tempfile, tempfile.leaf_name)
1164
+
1165
+ quarantine_name = quarantine_entry_name(claim_name)
1166
+ quarantine = quarantine_entry(tempfile, claim_name, quarantine_name)
1167
+ return false unless quarantine
1168
+ return retain_recovery_copy(tempfile, quarantine) unless entry_matches_expected?(tempfile, quarantine, expected)
1169
+
1170
+ restore_quarantined_entry(tempfile, quarantine, tempfile.leaf_name, tempfile.relative_path, report: false)
1171
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported
1172
+ retain_recovery_copy(tempfile, current_recovery_name(tempfile, quarantine_name, claim_name))
1173
+ end
1174
+
1175
+ def cleanup_claim(tempfile, claim_name, expected, result)
1176
+ cleanup_owned_entry(
1177
+ tempfile,
1178
+ entry_name: claim_name,
1179
+ expected:,
1180
+ restore_to: claim_name,
1181
+ relative_path: tempfile.relative_path,
1182
+ validate_identity: true,
1183
+ result:,
1184
+ report_failures: true
1185
+ )
1186
+ end
1187
+
1188
+ def restore_claimed_current_leaf(tempfile, claim_name)
1189
+ current_name = claim_name
1190
+ return retain_recovery_copy(tempfile, claim_name) unless current_parent_directory?(tempfile)
1191
+ return retain_recovery_copy(tempfile, claim_name) if anchored_lstat(tempfile, tempfile.leaf_name)
1192
+
1193
+ directory_operations.rename_noreplace(tempfile.directory_io, claim_name, tempfile.leaf_name)
1194
+ current_name = tempfile.leaf_name
1195
+ directory_operations.fsync(tempfile.directory_io)
1196
+ false
1197
+ rescue Errno::EEXIST
1198
+ retain_recovery_copy(tempfile, claim_name)
1199
+ rescue IOError, SystemCallError, NotImplementedError, DirectoryOperations::Unsupported
1200
+ retain_recovery_copy(tempfile, current_recovery_name(tempfile, current_name, claim_name))
1201
+ end
1202
+
1203
+ def claimed_entry_matches?(tempfile, claim_name, expected)
1204
+ claim_status = anchored_lstat(tempfile, claim_name)
1205
+ return false unless same_file_identity?(claim_status, expected.fetch(:status))
1206
+ return false unless same_file_state?(claim_status, expected.fetch(:status))
1207
+
1208
+ open_anchored_file(tempfile, claim_name) do |claim_io|
1209
+ claim_io.binmode
1210
+ claim_io.read == expected.fetch(:content).b
1211
+ end
1212
+ end
1213
+
1214
+ def current_parent_directory?(tempfile)
1215
+ current = lstat(parent_directory_path(tempfile.directory_relative_path))
1216
+ return false unless current&.directory?
1217
+
1218
+ same_file_identity?(current, tempfile.directory_io.stat)
1219
+ end
1220
+
1221
+ def create_anchored_tempfile(context, relative_path)
1222
+ temp_name = temporary_entry_name
1223
+ file = directory_operations.open_file(
1224
+ context.fetch(:directory_io),
1225
+ temp_name,
1226
+ tempfile_write_flags,
1227
+ TEMPFILE_MODE
1228
+ )
1229
+ tempfile = PreparedTempfile.new(**anchored_tempfile_attributes(context, file, temp_name, relative_path))
1230
+ directory_operations.fchmod(file, TEMPFILE_MODE)
1231
+ tempfile
1232
+ rescue SystemCallError, IOError, NotImplementedError, DirectoryOperations::Unsupported
1233
+ cleanup_tempfile(tempfile) if tempfile
1234
+ raise
1235
+ end
1236
+
1237
+ def anchored_tempfile_attributes(context, file, temp_name, relative_path)
1238
+ {
1239
+ file:,
1240
+ name: temp_name,
1241
+ path: File.join(parent_directory_path(context.fetch(:directory_relative_path)), temp_name),
1242
+ relative_path:,
1243
+ leaf_name: context.fetch(:leaf_name),
1244
+ directory_io: context.fetch(:directory_io),
1245
+ directory_relative_path: context.fetch(:directory_relative_path)
1246
+ }
1247
+ end
1248
+
1249
+ def with_anchored_parent_directory(relative_path, create_missing:)
1250
+ segments = Pathname.new(relative_path).each_filename.to_a
1251
+ leaf_name = segments.pop
1252
+
1253
+ File.open(destination_root, directory_read_flags) do |root_io|
1254
+ raise Errno::ENOTDIR, destination_root unless root_io.stat.directory?
1255
+
1256
+ traverse_anchored_directories(root_io, segments, create_missing:, traversed: []) do |directory_io, traversed|
1257
+ yield({ directory_io:, directory_relative_path: traversed.join(File::SEPARATOR), leaf_name: })
1258
+ end
1259
+ end
1260
+ end
1261
+
1262
+ def traverse_anchored_directories(directory_io, segments, create_missing:, traversed:, &block)
1263
+ return yield(directory_io, traversed) if segments.empty?
1264
+
1265
+ next_segment = segments.first
1266
+ child_io = open_or_create_child_directory(directory_io, next_segment, create_missing:)
1267
+ traverse_next_anchored_directory(child_io, segments, create_missing:, traversed:, next_segment:, &block)
1268
+ ensure
1269
+ child_io&.close
1270
+ end
1271
+
1272
+ def traverse_next_anchored_directory(child_io, segments, create_missing:, traversed:, next_segment:, &block)
1273
+ traverse_anchored_directories(
1274
+ child_io,
1275
+ segments.drop(1),
1276
+ create_missing:,
1277
+ traversed: traversed + [next_segment],
1278
+ &block
1279
+ )
1280
+ end
1281
+
1282
+ def open_or_create_child_directory(directory_io, segment, create_missing:)
1283
+ directory_operations.open_directory(directory_io, segment, verify_read_flags)
1284
+ rescue Errno::ENOENT
1285
+ raise unless create_missing
1286
+
1287
+ directory_operations.mkdir(directory_io, segment, 0o755)
1288
+ directory_operations.fsync(directory_io)
1289
+ retry
1290
+ end
1291
+
1292
+ def open_anchored_file(context, entry_name)
1293
+ io = directory_operations.open_file(directory_io_for(context), entry_name, verify_read_flags)
1294
+ return io unless block_given?
1295
+
1296
+ yield io
1297
+ ensure
1298
+ io&.close if block_given?
1299
+ end
1300
+
1301
+ def anchored_lstat(context, entry_name)
1302
+ open_anchored_file(context, entry_name, &:stat)
1303
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ELOOP
1304
+ nil
1305
+ end
1306
+
1307
+ def parent_directory_path(relative_directory)
1308
+ return destination_root if relative_directory.empty?
1309
+
1310
+ File.join(destination_root, relative_directory)
1311
+ end
1312
+
1313
+ def relative_path_from_destination(path)
1314
+ Pathname.new(path).relative_path_from(Pathname.new(destination_root)).to_s
1315
+ end
1316
+
1317
+ def temporary_entry_name
1318
+ ".quality_gate-#{Process.pid}-#{rand(1_000_000)}.tmp"
1319
+ end
1320
+
1321
+ def claim_entry_name(leaf_name)
1322
+ ".quality_gate-#{leaf_name.tr(File::SEPARATOR, "_")}-#{Process.pid}-#{rand(1_000_000)}.claim"
1323
+ end
1324
+
1325
+ def directory_operations
1326
+ DirectoryOperations
1327
+ end
1328
+
1329
+ def directory_read_flags
1330
+ verify_read_flags | DirectoryOperations::O_DIRECTORY
1331
+ end
1332
+
1333
+ def tempfile_write_flags
1334
+ flags = File::WRONLY | File::CREAT | File::EXCL
1335
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
1336
+ flags |= DirectoryOperations::O_CLOEXEC
1337
+ flags
1338
+ end
1339
+
1340
+ def published_mode(mode)
1341
+ mode || DEFAULT_AGENT_ARTIFACT_MODE
1342
+ end
1343
+
1344
+ def visible_postcondition_matches?(path, content, mode, expected_parent_status = nil)
1345
+ current_parent_directory_for?(path, expected_parent_status) && safe_postcondition_match?(path, content, mode)
1346
+ end
1347
+
1348
+ def current_parent_matches?(context, expected_parent_status)
1349
+ current_status = lstat(parent_directory_path(context.fetch(:directory_relative_path)))
1350
+ same_file_identity?(current_status, expected_parent_status)
1351
+ end
1352
+
1353
+ def current_parent_directory_for?(path, expected_parent_status = nil)
1354
+ current = lstat(File.dirname(path))
1355
+ return false unless current&.directory?
1356
+
1357
+ expected_parent_status ? same_file_identity?(current, expected_parent_status) : true
1358
+ end
1359
+
1360
+ def cleanup_published_leaf(tempfile, expected)
1361
+ return unless expected
1362
+
1363
+ cleanup_owned_entry(
1364
+ tempfile,
1365
+ entry_name: tempfile.leaf_name,
1366
+ expected:,
1367
+ restore_to: tempfile.leaf_name,
1368
+ relative_path: tempfile.relative_path,
1369
+ validate_identity: true,
1370
+ result: nil,
1371
+ report_failures: true
1372
+ )
1373
+ end
1374
+
1375
+ def cleanup_owned_entry(
1376
+ tempfile,
1377
+ entry_name:,
1378
+ expected:,
1379
+ restore_to:,
1380
+ relative_path:,
1381
+ validate_identity:,
1382
+ result:,
1383
+ report_failures: true
1384
+ )
1385
+ quarantine_name = quarantine_entry_name(entry_name)
1386
+ quarantine = quarantine_entry(tempfile, entry_name, quarantine_name)
1387
+ return result unless quarantine
1388
+ return unlink_quarantined_entry(tempfile, quarantine, relative_path, result) unless validate_identity
1389
+
1390
+ if entry_matches_expected?(tempfile, quarantine, expected)
1391
+ return delete_quarantined_entry(tempfile, quarantine, expected, relative_path, result)
1392
+ end
1393
+
1394
+ cleanup_identity_mismatch(
1395
+ tempfile,
1396
+ quarantine,
1397
+ restore_to,
1398
+ relative_path,
1399
+ result
1400
+ )
1401
+ rescue Errno::ENOENT
1402
+ result
1403
+ rescue IOError, SystemCallError, NotImplementedError, DirectoryOperations::Unsupported => e
1404
+ current_name = current_recovery_name(tempfile, quarantine_name, entry_name)
1405
+ return handle_cleanup_failure(tempfile, current_name, relative_path, e, result, report_failures) if current_name
1406
+
1407
+ raise unless report_failures
1408
+
1409
+ report_cleanup_failure(relative_path, e)
1410
+ cleanup_failure_result(result)
1411
+ end
1412
+
1413
+ def cleanup_identity_mismatch(tempfile, quarantine, restore_to, relative_path, result)
1414
+ restored = restore_quarantined_entry(tempfile, quarantine, restore_to, relative_path, report: false)
1415
+ return cleanup_failure_result(result) if restored == false
1416
+
1417
+ restored
1418
+ end
1419
+
1420
+ def quarantine_entry(tempfile, entry_name, quarantine_name = quarantine_entry_name(entry_name))
1421
+ directory_operations.rename_noreplace(tempfile.directory_io, entry_name, quarantine_name)
1422
+ directory_operations.fsync(tempfile.directory_io)
1423
+ quarantine_name
1424
+ rescue Errno::ENOENT
1425
+ nil
1426
+ end
1427
+
1428
+ def delete_quarantined_entry(tempfile, quarantine_name, expected, relative_path, result)
1429
+ unless entry_matches_expected?(tempfile, quarantine_name, expected)
1430
+ return retain_recovery_copy(tempfile, quarantine_name)
1431
+ end
1432
+
1433
+ unlink_quarantined_entry(tempfile, quarantine_name, relative_path, result)
1434
+ end
1435
+
1436
+ def unlink_quarantined_entry(tempfile, quarantine_name, _relative_path, result)
1437
+ directory_operations.unlink(tempfile.directory_io, quarantine_name)
1438
+ directory_operations.fsync(tempfile.directory_io)
1439
+ result
1440
+ end
1441
+
1442
+ def restore_quarantined_entry(tempfile, quarantine_name, restore_to, relative_path, report:)
1443
+ current_name = quarantine_name
1444
+ if anchored_lstat(tempfile, restore_to)
1445
+ retain_recovery_copy(tempfile, quarantine_name)
1446
+ else
1447
+ directory_operations.rename_noreplace(tempfile.directory_io, quarantine_name, restore_to)
1448
+ current_name = restore_to
1449
+ directory_operations.fsync(tempfile.directory_io)
1450
+ report_cleanup_failure(relative_path, RuntimeError.new("#{restore_to} changed before cleanup")) if report
1451
+ false
1452
+ end
1453
+ rescue Errno::EEXIST
1454
+ retain_recovery_copy(tempfile, quarantine_name)
1455
+ rescue IOError, SystemCallError, NotImplementedError, DirectoryOperations::Unsupported
1456
+ retain_recovery_copy(tempfile, current_recovery_name(tempfile, current_name, quarantine_name))
1457
+ end
1458
+
1459
+ def entry_matches_expected?(context, entry_name, expected)
1460
+ entry_status = anchored_lstat(context, entry_name)
1461
+ return false unless same_file_identity?(entry_status, expected.fetch(:status))
1462
+ return false unless same_file_state?(entry_status, expected.fetch(:status))
1463
+ return true unless expected[:content]
1464
+
1465
+ open_anchored_file(context, entry_name) do |entry_io|
1466
+ entry_io.binmode
1467
+ entry_io.read == expected.fetch(:content).b
1468
+ end
1469
+ end
1470
+
1471
+ def entry_matches_expected_identity?(context, entry_name, expected_status)
1472
+ entry_status = anchored_lstat(context, entry_name)
1473
+ same_file_identity?(entry_status, expected_status)
1474
+ end
1475
+
1476
+ def prepared_entry_token(tempfile, content)
1477
+ { status: tempfile.stat, content: content.b }
1478
+ end
1479
+
1480
+ def verify_published_leaf!(tempfile, expected)
1481
+ return if entry_matches_expected?(tempfile, tempfile.leaf_name, expected)
1482
+
1483
+ raise IOError, "#{tempfile.relative_path} changed before it could be published safely"
1484
+ end
1485
+
1486
+ def retain_recovery_copy(tempfile, claim_name)
1487
+ status = anchored_lstat(tempfile, claim_name)
1488
+ say recovery_warning(tempfile.relative_path, claim_name, status)
1489
+ false
1490
+ end
1491
+
1492
+ def current_recovery_name(tempfile, current_name, fallback_name)
1493
+ return current_name if anchored_lstat(tempfile, current_name)
1494
+
1495
+ return fallback_name if anchored_lstat(tempfile, fallback_name)
1496
+
1497
+ nil
1498
+ end
1499
+
1500
+ def handle_cleanup_failure(tempfile, current_name, relative_path, error, result, report_failures)
1501
+ retain_recovery_copy(tempfile, current_name)
1502
+ raise error unless report_failures
1503
+
1504
+ cleanup_failure_result(result)
1505
+ rescue IOError, SystemCallError, NotImplementedError, DirectoryOperations::Unsupported
1506
+ raise error unless report_failures
1507
+
1508
+ report_cleanup_failure(relative_path, error)
1509
+ cleanup_failure_result(result)
1510
+ end
1511
+
1512
+ def cleanup_failure_result(result)
1513
+ result == true ? false : result
1514
+ end
1515
+
1516
+ def recovery_warning(relative_path, claim_name, status)
1517
+ identity = status ? " (dev=#{status.dev} ino=#{status.ino})" : ""
1518
+ "Warning: recovery copy for #{relative_path} was retained as #{claim_name}#{identity}."
1519
+ end
1520
+
1521
+ def report_cleanup_failure(relative_path, error)
1522
+ say "Warning: temporary cleanup for #{relative_path} could not be completed safely: #{error.message}"
1523
+ record(:needs_person, relative_path)
1524
+ end
1525
+
1526
+ def quarantine_entry_name(entry_name)
1527
+ ".quality_gate-#{entry_name.tr(File::SEPARATOR, "_")}-#{Process.pid}-#{rand(1_000_000)}.quarantine"
1528
+ end
1529
+
1530
+ def directory_io_for(context)
1531
+ context.respond_to?(:directory_io) ? context.directory_io : context.fetch(:directory_io)
1532
+ end
1533
+
1534
+ def unlock_file(io)
1535
+ io.flock(File::LOCK_UN)
1536
+ rescue SystemCallError, IOError
1537
+ nil
1538
+ end
1539
+
1540
+ def agent_template_path?(relative_path)
1541
+ AGENT_TEMPLATE_PATHS.key?(relative_path)
1542
+ end
1543
+
1544
+ def agent_contract_path?(relative_path)
1545
+ AGENT_CONTRACT_PATHS.include?(relative_path)
1546
+ end
1547
+
1548
+ def agent_artifact_path?(relative_path)
1549
+ agent_template_path?(relative_path) || agent_contract_path?(relative_path)
1550
+ end
1551
+
1552
+ def manual_instructions(relative_path, content)
1553
+ "Use these lines manually for #{relative_path}:\n#{content}"
1554
+ end
1555
+
1556
+ def results
1557
+ @results ||= Hash.new { |statuses, status| statuses[status] = [] }
1558
+ end
1559
+
1560
+ def newest_migration_version
1561
+ migration_directory = File.join(destination_root, "db/migrate")
1562
+ versions = Dir.glob("*.rb", base: migration_directory).filter_map do |filename|
1563
+ path = File.join(migration_directory, filename)
1564
+ next unless File.file?(path)
1565
+
1566
+ prefix = filename[/\A(\d+)_/, 1]
1567
+ Integer(prefix, 10) if prefix
1568
+ end
1569
+
1570
+ versions.max
1571
+ end
1572
+
1573
+ def template_path(template_name)
1574
+ File.join(template_source_root, template_for(template_name))
1575
+ end
1576
+ end
1577
+ end