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,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
|