ast-merge-git 7.1.1 → 7.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/README.md +24 -14
- data/exe/ast-merge-git +149 -0
- data/lib/ast/merge/git/corpus.rb +580 -0
- data/lib/ast/merge/git/local_benchmark.rb +1199 -0
- data/lib/ast/merge/git/version.rb +1 -1
- data/lib/ast/merge/git.rb +196 -343
- data/sig/ast/merge/git.rbs +106 -0
- data.tar.gz.sig +0 -0
- metadata +26 -37
- metadata.gz.sig +3 -2
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'open3'
|
|
7
|
+
require 'shellwords'
|
|
8
|
+
|
|
9
|
+
module Ast
|
|
10
|
+
module Merge
|
|
11
|
+
module Git
|
|
12
|
+
# rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- evidence validation and raw-result assembly remain explicit and auditable
|
|
13
|
+
# Validates and executes pinned Git-history corpus cases.
|
|
14
|
+
class Corpus
|
|
15
|
+
Error = Class.new(StandardError)
|
|
16
|
+
CLASSIFICATIONS = %w[
|
|
17
|
+
exact_automatic_resolution
|
|
18
|
+
structurally_equivalent_resolution
|
|
19
|
+
conflict_expected
|
|
20
|
+
ambiguous_manual_review
|
|
21
|
+
excluded
|
|
22
|
+
].freeze
|
|
23
|
+
BACKLOG_STATUSES = %w[blocked admitted resolved].freeze
|
|
24
|
+
SHA_PATTERN = /\A[0-9a-f]{40}\z/
|
|
25
|
+
CASE_ID_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
26
|
+
REQUIRED_CASE_KEYS = %w[
|
|
27
|
+
case_id merge_commit base_commit parent_commits path blob_oids selector
|
|
28
|
+
capability_tags stratum oracle
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :manifest
|
|
32
|
+
|
|
33
|
+
def self.load(path)
|
|
34
|
+
new(JSON.parse(File.binread(path)))
|
|
35
|
+
rescue JSON::ParserError => e
|
|
36
|
+
raise Error, "invalid manifest JSON: #{e.message}"
|
|
37
|
+
rescue SystemCallError => e
|
|
38
|
+
raise Error, "cannot read manifest: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(manifest)
|
|
42
|
+
@manifest = manifest
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate!
|
|
46
|
+
require_keys(manifest, %w[schema_version corpus_id source claim_policy admission_backlog cases], 'manifest')
|
|
47
|
+
raise Error, 'schema_version must be 1' unless manifest['schema_version'] == 1
|
|
48
|
+
|
|
49
|
+
validate_source!
|
|
50
|
+
validate_backlog!
|
|
51
|
+
raise Error, 'cases must be a non-empty array' unless manifest['cases'].is_a?(Array) && manifest['cases'].any?
|
|
52
|
+
|
|
53
|
+
ids = manifest['cases'].map { |item| validate_case!(item) }
|
|
54
|
+
raise Error, 'case_id values must be unique' unless ids.uniq.length == ids.length
|
|
55
|
+
|
|
56
|
+
validate_admitted_backlog!(ids)
|
|
57
|
+
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def cases(case_id = nil)
|
|
62
|
+
validate!
|
|
63
|
+
return manifest['cases'] unless case_id
|
|
64
|
+
|
|
65
|
+
[manifest['cases'].find { |item| item['case_id'] == case_id } ||
|
|
66
|
+
raise(Error, "unknown case: #{case_id}")]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def validate_source!
|
|
72
|
+
source = manifest['source']
|
|
73
|
+
require_keys(source, %w[repository remote_url revision spdx_license license_evidence_url oracle_rationale],
|
|
74
|
+
'source')
|
|
75
|
+
validate_sha!(source['revision'], 'source.revision')
|
|
76
|
+
raise Error, 'source.remote_url must use https' unless source['remote_url'].start_with?('https://')
|
|
77
|
+
raise Error, 'source.license_evidence_url must use https' unless source['license_evidence_url'].start_with?('https://')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_backlog!
|
|
81
|
+
backlog = manifest['admission_backlog']
|
|
82
|
+
raise Error, 'admission_backlog must be an array' unless backlog.is_a?(Array)
|
|
83
|
+
|
|
84
|
+
backlog.each do |item|
|
|
85
|
+
require_keys(item, %w[candidate_id status reason score_eligible], 'admission_backlog item')
|
|
86
|
+
unless BACKLOG_STATUSES.include?(item['status'])
|
|
87
|
+
raise Error, "#{item['candidate_id']}: unsupported backlog status"
|
|
88
|
+
end
|
|
89
|
+
if item['status'] == 'blocked' && item['score_eligible']
|
|
90
|
+
raise Error, "#{item['candidate_id']}: blocked candidate cannot be score eligible"
|
|
91
|
+
end
|
|
92
|
+
next unless item['status'] == 'admitted'
|
|
93
|
+
|
|
94
|
+
require_keys(item, %w[case_id], 'admitted backlog item')
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_admitted_backlog!(case_ids)
|
|
99
|
+
manifest['admission_backlog'].select { |item| item['status'] == 'admitted' }.each do |item|
|
|
100
|
+
raise Error, "#{item['candidate_id']}: admitted case is missing" unless case_ids.include?(item['case_id'])
|
|
101
|
+
|
|
102
|
+
admitted = manifest['cases'].find { |candidate| candidate['case_id'] == item['case_id'] }
|
|
103
|
+
next if item['score_eligible'] == admitted.dig('oracle', 'score_eligible')
|
|
104
|
+
|
|
105
|
+
raise Error, "#{item['candidate_id']}: backlog and case score eligibility differ"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_case!(item)
|
|
110
|
+
require_keys(item, REQUIRED_CASE_KEYS, 'case')
|
|
111
|
+
raise Error, 'case_id must be lowercase kebab-case' unless CASE_ID_PATTERN.match?(item['case_id'].to_s)
|
|
112
|
+
|
|
113
|
+
validate_sha!(item['merge_commit'], "#{item['case_id']}.merge_commit")
|
|
114
|
+
validate_sha!(item['base_commit'], "#{item['case_id']}.base_commit")
|
|
115
|
+
parents = item['parent_commits']
|
|
116
|
+
unless parents.is_a?(Array) && parents.length == 2
|
|
117
|
+
raise Error,
|
|
118
|
+
"#{item['case_id']}: exactly two parents required"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
parents.each { |sha| validate_sha!(sha, "#{item['case_id']}.parent_commits") }
|
|
122
|
+
validate_blobs!(item)
|
|
123
|
+
validate_selector!(item)
|
|
124
|
+
validate_oracle!(item)
|
|
125
|
+
item['case_id']
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_blobs!(item)
|
|
129
|
+
require_keys(item['blob_oids'], %w[base ours theirs human], "#{item['case_id']}.blob_oids")
|
|
130
|
+
item['blob_oids'].each_value { |oid| validate_sha!(oid, "#{item['case_id']}.blob_oids") }
|
|
131
|
+
path = item['path']
|
|
132
|
+
return unless path.empty? || Pathname(path).absolute? || path.split('/').include?('..')
|
|
133
|
+
|
|
134
|
+
raise Error,
|
|
135
|
+
"#{item['case_id']}: path must be relative"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_selector!(item)
|
|
139
|
+
selector = item['selector']
|
|
140
|
+
require_keys(selector, %w[provider_id family dialect backend profile require], "#{item['case_id']}.selector")
|
|
141
|
+
require_keys(item['stratum'], %w[provider dialect conflict_type], "#{item['case_id']}.stratum")
|
|
142
|
+
return if item['capability_tags'].is_a?(Array) && item['capability_tags'].any?
|
|
143
|
+
|
|
144
|
+
raise Error,
|
|
145
|
+
"#{item['case_id']}: capability_tags must not be empty"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_oracle!(item)
|
|
149
|
+
oracle = item['oracle']
|
|
150
|
+
require_keys(
|
|
151
|
+
oracle,
|
|
152
|
+
%w[classification human_resolution_rationale ambiguity_status reclassification_status
|
|
153
|
+
false_auto_merge_review score_eligible],
|
|
154
|
+
"#{item['case_id']}.oracle"
|
|
155
|
+
)
|
|
156
|
+
unless CLASSIFICATIONS.include?(oracle['classification'])
|
|
157
|
+
raise Error, "#{item['case_id']}: unsupported oracle classification"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
eligible = oracle['score_eligible']
|
|
161
|
+
reviewed = oracle['false_auto_merge_review'] == 'complete'
|
|
162
|
+
unscorable = %w[ambiguous_manual_review excluded].include?(oracle['classification'])
|
|
163
|
+
raise Error, "#{item['case_id']}: case cannot be score eligible" if eligible && (!reviewed || unscorable)
|
|
164
|
+
return unless oracle['classification'] == 'structurally_equivalent_resolution'
|
|
165
|
+
|
|
166
|
+
require_keys(item, %w[conflict_evidence review], item['case_id'])
|
|
167
|
+
require_keys(
|
|
168
|
+
item['conflict_evidence'],
|
|
169
|
+
%w[method result review_status],
|
|
170
|
+
"#{item['case_id']}.conflict_evidence"
|
|
171
|
+
)
|
|
172
|
+
require_keys(item['review'], %w[provenance status], "#{item['case_id']}.review")
|
|
173
|
+
require_keys(oracle, %w[provider_coverage], "#{item['case_id']}.oracle")
|
|
174
|
+
coverage = oracle['provider_coverage']
|
|
175
|
+
require_keys(coverage, %w[status reason], "#{item['case_id']}.oracle.provider_coverage")
|
|
176
|
+
complete = item.dig('conflict_evidence', 'result') == 'content_conflict' &&
|
|
177
|
+
item.dig('conflict_evidence', 'review_status') == 'complete' &&
|
|
178
|
+
item.dig('review', 'status') == 'complete'
|
|
179
|
+
raise Error, "#{item['case_id']}: reviewed conflict evidence is incomplete" unless complete
|
|
180
|
+
return unless eligible && coverage['status'] != 'supported'
|
|
181
|
+
|
|
182
|
+
raise Error, "#{item['case_id']}: unsupported provider coverage cannot be score eligible"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def require_keys(hash, keys, context)
|
|
186
|
+
raise Error, "#{context} must be an object" unless hash.is_a?(Hash)
|
|
187
|
+
|
|
188
|
+
missing = keys.reject { |key| hash.key?(key) }
|
|
189
|
+
raise Error, "#{context} missing: #{missing.join(', ')}" if missing.any?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def validate_sha!(sha, context)
|
|
193
|
+
raise Error, "#{context} must be a full lowercase SHA" unless SHA_PATTERN.match?(sha.to_s)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Executes validated corpus cases without changing the source checkout.
|
|
198
|
+
class CorpusRunner
|
|
199
|
+
DEFAULT_TIMEOUT = 30
|
|
200
|
+
OUTCOMES = %w[
|
|
201
|
+
correct_clean false_conflict true_conflict false_auto_merge error unsupported excluded_ambiguous
|
|
202
|
+
].freeze
|
|
203
|
+
|
|
204
|
+
def initialize(corpus:, repository:, driver_path:, tmp_root:, timeout: DEFAULT_TIMEOUT)
|
|
205
|
+
@corpus = corpus
|
|
206
|
+
@repository = Pathname(repository).expand_path
|
|
207
|
+
@driver_path = Pathname(driver_path).expand_path
|
|
208
|
+
@tmp_root = Pathname(tmp_root).expand_path
|
|
209
|
+
@timeout = Integer(timeout)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def run(case_id: nil)
|
|
213
|
+
verify_environment!
|
|
214
|
+
@corpus.cases(case_id).map { |item| run_case(item) }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
def verify_environment!
|
|
220
|
+
raise Corpus::Error, "missing repository: #{@repository}" unless @repository.join('.git').exist?
|
|
221
|
+
unless @driver_path.file? && @driver_path.executable?
|
|
222
|
+
raise Corpus::Error,
|
|
223
|
+
"missing installed driver: #{@driver_path}"
|
|
224
|
+
end
|
|
225
|
+
raise Corpus::Error, 'tmp_root must be inside the ast-merge-git repository' unless inside_gem_root?(@tmp_root)
|
|
226
|
+
|
|
227
|
+
status = git_source('status', '--porcelain')
|
|
228
|
+
raise Corpus::Error, 'source repository is dirty; corpus reads require a clean checkout' unless status.empty?
|
|
229
|
+
|
|
230
|
+
git_source('cat-file', '-e', "#{@corpus.manifest.dig('source', 'revision')}^{commit}")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def inside_gem_root?(path)
|
|
234
|
+
root = Pathname(__dir__).join('..', '..', '..', '..').realpath
|
|
235
|
+
resolved = path.exist? ? path.realpath : path.dirname.realpath.join(path.basename)
|
|
236
|
+
resolved.to_s.start_with?("#{root}/")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def run_case(item)
|
|
240
|
+
roles = prove_and_read_blobs(item)
|
|
241
|
+
workspace = @tmp_root.join("#{item['case_id']}-#{Process.pid}")
|
|
242
|
+
FileUtils.rm_rf(workspace)
|
|
243
|
+
FileUtils.mkdir_p(workspace)
|
|
244
|
+
git_workspace(workspace, 'init', '--quiet')
|
|
245
|
+
configure_driver(workspace, item)
|
|
246
|
+
|
|
247
|
+
baseline = execute_baseline(workspace, roles)
|
|
248
|
+
candidate = execute_candidate(workspace, roles, item)
|
|
249
|
+
rerun = execute_candidate(workspace, roles, item)
|
|
250
|
+
build_result(item, roles, baseline, candidate, rerun)
|
|
251
|
+
ensure
|
|
252
|
+
FileUtils.rm_rf(workspace) if workspace
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def prove_and_read_blobs(item)
|
|
256
|
+
git_source(
|
|
257
|
+
'merge-base',
|
|
258
|
+
'--is-ancestor',
|
|
259
|
+
item['merge_commit'],
|
|
260
|
+
@corpus.manifest.dig('source', 'revision')
|
|
261
|
+
)
|
|
262
|
+
parents = git_source('rev-list', '--parents', '-n', '1', item['merge_commit']).split
|
|
263
|
+
raise Corpus::Error, "#{item['case_id']}: merge must have exactly two parents" unless parents.length == 3
|
|
264
|
+
raise Corpus::Error, "#{item['case_id']}: parent SHAs differ" unless parents.drop(1) == item['parent_commits']
|
|
265
|
+
|
|
266
|
+
base = git_source('merge-base', *item['parent_commits']).strip
|
|
267
|
+
raise Corpus::Error, "#{item['case_id']}: merge-base differs" unless base == item['base_commit']
|
|
268
|
+
|
|
269
|
+
revisions = {
|
|
270
|
+
'base' => item['base_commit'],
|
|
271
|
+
'ours' => item['parent_commits'][0],
|
|
272
|
+
'theirs' => item['parent_commits'][1],
|
|
273
|
+
'human' => item['merge_commit']
|
|
274
|
+
}
|
|
275
|
+
revisions.to_h do |role, revision|
|
|
276
|
+
spec = "#{revision}:#{item['path']}"
|
|
277
|
+
oid = git_source('rev-parse', spec).strip
|
|
278
|
+
raise Corpus::Error, "#{item['case_id']}: #{role} blob differs" unless oid == item.dig('blob_oids', role)
|
|
279
|
+
|
|
280
|
+
[role, git_source_binary('cat-file', 'blob', spec)]
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def configure_driver(workspace, item)
|
|
285
|
+
selector = item['selector']
|
|
286
|
+
env = selector_env(selector).map { |key, value| "#{key}=#{Shellwords.escape(value)}" }.join(' ')
|
|
287
|
+
command = "#{env} #{Shellwords.escape(@driver_path.to_s)} %O %A %B %P %L"
|
|
288
|
+
git_workspace(workspace, 'config', 'merge.structuredmerge-corpus.name', 'StructuredMerge corpus driver')
|
|
289
|
+
git_workspace(workspace, 'config', 'merge.structuredmerge-corpus.driver', command)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def execute_baseline(workspace, roles)
|
|
293
|
+
write_roles(workspace, roles)
|
|
294
|
+
timed_capture({}, 'git', 'merge-file', '-p', 'ours', 'base', 'theirs', chdir: workspace).then do |capture|
|
|
295
|
+
capture.merge(output: capture[:stdout])
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def execute_candidate(workspace, roles, item)
|
|
300
|
+
write_roles(workspace, roles)
|
|
301
|
+
capture = timed_capture(
|
|
302
|
+
candidate_env(item['selector']),
|
|
303
|
+
@driver_path.to_s,
|
|
304
|
+
'base',
|
|
305
|
+
'ours',
|
|
306
|
+
'theirs',
|
|
307
|
+
item['path'],
|
|
308
|
+
'7',
|
|
309
|
+
chdir: workspace
|
|
310
|
+
)
|
|
311
|
+
capture.merge(output: workspace.join('ours').binread)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def write_roles(workspace, roles)
|
|
315
|
+
%w[base ours theirs].each { |role| workspace.join(role).binwrite(roles.fetch(role)) }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def selector_env(selector)
|
|
319
|
+
{
|
|
320
|
+
'AST_MERGE_PROVIDER' => selector['provider_id'],
|
|
321
|
+
'AST_MERGE_FAMILY' => selector['family'],
|
|
322
|
+
'AST_MERGE_DIALECT' => selector['dialect'],
|
|
323
|
+
'AST_MERGE_BACKEND' => selector['backend'],
|
|
324
|
+
'AST_MERGE_PROFILE' => selector['profile'],
|
|
325
|
+
'AST_MERGE_REQUIRE' => selector['require']
|
|
326
|
+
}
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def candidate_env(selector)
|
|
330
|
+
forbidden = ENV.keys.grep(/ORACLE|HUMAN|EXPECTED/i).to_h { |key| [key, nil] }
|
|
331
|
+
forbidden.merge(selector_env(selector))
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def build_result(item, roles, baseline, candidate, rerun)
|
|
335
|
+
{
|
|
336
|
+
schema_version: 1,
|
|
337
|
+
case_id: item['case_id'],
|
|
338
|
+
source: item.slice('merge_commit', 'base_commit', 'parent_commits', 'path', 'blob_oids'),
|
|
339
|
+
oracle: item['oracle'],
|
|
340
|
+
baseline: outcome(baseline, roles['human'], item, adapter: :git_merge_file),
|
|
341
|
+
candidate: outcome(candidate, roles['human'], item, adapter: :structured_merge),
|
|
342
|
+
human_result: { sha256: digest(roles['human']), bytes: roles['human'].bytesize },
|
|
343
|
+
deterministic_rerun: deterministic?(candidate, rerun),
|
|
344
|
+
claim_eligibility: claim_eligibility(item),
|
|
345
|
+
runtime_policy: { comparable: false, deterministic: false }
|
|
346
|
+
}
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def outcome(capture, human, item, adapter:)
|
|
350
|
+
output = capture.fetch(:output)
|
|
351
|
+
markers = conflict_markers(output)
|
|
352
|
+
exact = output == human
|
|
353
|
+
provider = provider_equivalence(output, human, item, exact)
|
|
354
|
+
exit_class = exit_classification(capture[:status], adapter)
|
|
355
|
+
classified = classify(item, exit_class, exact || provider[:equivalent], adapter: adapter)
|
|
356
|
+
{
|
|
357
|
+
exit_status: capture[:status],
|
|
358
|
+
exit_classification: exit_class,
|
|
359
|
+
stdout: capture[:stdout],
|
|
360
|
+
stderr: capture[:stderr],
|
|
361
|
+
output: output,
|
|
362
|
+
output_sha256: digest(output),
|
|
363
|
+
exact_human_result: exact,
|
|
364
|
+
structurally_equivalent_human_result: provider[:equivalent],
|
|
365
|
+
provider_check: provider,
|
|
366
|
+
parse_valid: provider[:available] ? provider[:valid] : nil,
|
|
367
|
+
outcome: classified,
|
|
368
|
+
conflict_markers: markers,
|
|
369
|
+
duration_ns: capture[:duration_ns],
|
|
370
|
+
runtime_comparable: false
|
|
371
|
+
}
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def timed_capture(env, *command, chdir:)
|
|
375
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
376
|
+
stdin, stdout_io, stderr_io, process = Open3.popen3(env, *command, chdir: chdir.to_s)
|
|
377
|
+
[stdin, stdout_io, stderr_io].each(&:binmode)
|
|
378
|
+
stdin.close
|
|
379
|
+
stdout_reader = Thread.new { stdout_io.read }
|
|
380
|
+
stderr_reader = Thread.new { stderr_io.read }
|
|
381
|
+
timed_out = process.join(@timeout).nil?
|
|
382
|
+
terminate_process(process) if timed_out
|
|
383
|
+
stdout = stdout_reader.value
|
|
384
|
+
stderr = stderr_reader.value
|
|
385
|
+
stderr = [stderr, "timeout after #{@timeout}s"].reject(&:empty?).join("\n") if timed_out
|
|
386
|
+
status = timed_out ? 2 : process.value.exitstatus
|
|
387
|
+
{ stdout: stdout, stderr: stderr, status: status,
|
|
388
|
+
duration_ns: Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - started }
|
|
389
|
+
rescue Errno::ENOENT => e
|
|
390
|
+
{ stdout: '', stderr: e.message, status: 2, output: '', duration_ns: 0 }
|
|
391
|
+
ensure
|
|
392
|
+
[stdin, stdout_io, stderr_io].compact.each { |io| io.close unless io.closed? }
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def terminate_process(process)
|
|
396
|
+
Process.kill('TERM', process.pid)
|
|
397
|
+
return if process.join(1)
|
|
398
|
+
|
|
399
|
+
Process.kill('KILL', process.pid)
|
|
400
|
+
process.join
|
|
401
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
402
|
+
process.join
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def git_source(*args)
|
|
406
|
+
run_git(@repository, *args)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def git_source_binary(*args)
|
|
410
|
+
run_git(@repository, *args, binary: true)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def git_workspace(workspace, *args)
|
|
414
|
+
run_git(workspace, *args)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def run_git(directory, *args, binary: false)
|
|
418
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
|
419
|
+
stdout, stderr, status = Open3.capture3('git', '-C', directory.to_s, *args, binmode: true)
|
|
420
|
+
raise Corpus::Error, "git #{args.first} failed: #{stderr.strip}" unless status.success?
|
|
421
|
+
|
|
422
|
+
binary || options[:binary] ? stdout : stdout.force_encoding(Encoding::UTF_8)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def digest(content)
|
|
426
|
+
Digest::SHA256.hexdigest(content)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def exit_classification(status, adapter)
|
|
430
|
+
return 'error' unless status
|
|
431
|
+
|
|
432
|
+
if adapter == :git_merge_file
|
|
433
|
+
return 'clean' if status.zero?
|
|
434
|
+
|
|
435
|
+
return status == 255 ? 'error' : 'conflict'
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
{ 0 => 'clean', 1 => 'conflict', 2 => 'error' }.fetch(status, 'error')
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def classify(item, exit_class, equivalent, adapter:)
|
|
442
|
+
classification = item.dig('oracle', 'classification')
|
|
443
|
+
return 'excluded_ambiguous' if %w[ambiguous_manual_review excluded].include?(classification)
|
|
444
|
+
if adapter == :structured_merge && item.dig('oracle', 'provider_coverage', 'status') == 'unsupported'
|
|
445
|
+
return 'unsupported'
|
|
446
|
+
end
|
|
447
|
+
return 'error' if exit_class == 'error'
|
|
448
|
+
if classification == 'conflict_expected'
|
|
449
|
+
return exit_class == 'conflict' ? 'true_conflict' : 'false_auto_merge'
|
|
450
|
+
end
|
|
451
|
+
return 'false_conflict' if exit_class == 'conflict'
|
|
452
|
+
return 'correct_clean' if equivalent
|
|
453
|
+
|
|
454
|
+
'false_auto_merge'
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def deterministic?(first, second)
|
|
458
|
+
%i[status stdout stderr].all? { |key| first[key] == second[key] } &&
|
|
459
|
+
first[:output] == second[:output]
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def claim_eligibility(item)
|
|
463
|
+
oracle = item['oracle']
|
|
464
|
+
eligible = oracle['score_eligible'] &&
|
|
465
|
+
oracle['false_auto_merge_review'] == 'complete' &&
|
|
466
|
+
!%w[ambiguous_manual_review excluded].include?(oracle['classification'])
|
|
467
|
+
{ score_eligible: eligible,
|
|
468
|
+
quality_claim_allowed: eligible && @corpus.manifest.dig('claim_policy', 'quality_claims_allowed') }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def provider_equivalence(output, human, item, exact)
|
|
472
|
+
selector = item.fetch('selector')
|
|
473
|
+
method = 'selected_provider.diff2(expected_human, candidate_output).changes.empty?'
|
|
474
|
+
if exact
|
|
475
|
+
return { equivalent: true, available: true, valid: true,
|
|
476
|
+
provider_id: selector['provider_id'], method: 'exact_bytes' }
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
require selector.fetch('require')
|
|
480
|
+
result = Ast::Merge.dispatch_provider(
|
|
481
|
+
:diff2,
|
|
482
|
+
{
|
|
483
|
+
provider_id: selector.fetch('provider_id'),
|
|
484
|
+
family: selector.fetch('family'),
|
|
485
|
+
dialect: selector.fetch('dialect'),
|
|
486
|
+
backend: selector.fetch('backend'),
|
|
487
|
+
profile_id: selector.fetch('profile'),
|
|
488
|
+
before_source: human,
|
|
489
|
+
after_source: output,
|
|
490
|
+
path_name: item.fetch('path')
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
valid = result[:ok] == true
|
|
494
|
+
{ equivalent: valid && result.fetch(:changes).empty?, available: true, valid: valid,
|
|
495
|
+
provider_id: selector['provider_id'], method: method,
|
|
496
|
+
diagnostic_codes: Array(result[:diagnostics]).filter_map { |entry| entry[:code] || entry['code'] } }
|
|
497
|
+
rescue Ast::Merge::Error, KeyError, LoadError => e
|
|
498
|
+
{ equivalent: false, available: false, valid: false,
|
|
499
|
+
provider_id: selector['provider_id'], method: method,
|
|
500
|
+
error: "#{e.class}: #{e.message}" }
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Conflict markers are Git's line protocol, not syntax nodes; byte scans
|
|
504
|
+
# intentionally avoid parser-specific ownership claims.
|
|
505
|
+
def conflict_markers(output)
|
|
506
|
+
ranges = []
|
|
507
|
+
start_byte = nil
|
|
508
|
+
offset = 0
|
|
509
|
+
output.each_line do |line|
|
|
510
|
+
start_byte = offset if line.start_with?('<<<<<<<')
|
|
511
|
+
if start_byte && line.start_with?('>>>>>>>')
|
|
512
|
+
ranges << { start_byte: start_byte,
|
|
513
|
+
end_byte: offset + line.bytesize }
|
|
514
|
+
end
|
|
515
|
+
start_byte = nil if start_byte && line.start_with?('>>>>>>>')
|
|
516
|
+
offset += line.bytesize
|
|
517
|
+
end
|
|
518
|
+
{ present: ranges.any?, count: ranges.length, byte_ranges: ranges }
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Explicitly acquires a pinned remote without changing an existing tree.
|
|
523
|
+
class CorpusAcquirer
|
|
524
|
+
class << self
|
|
525
|
+
def acquire(corpus:, destination:, tmp_root:)
|
|
526
|
+
corpus.validate!
|
|
527
|
+
destination = Pathname(destination).expand_path
|
|
528
|
+
tmp_root = Pathname(tmp_root).expand_path
|
|
529
|
+
validate_destination!(destination, tmp_root)
|
|
530
|
+
|
|
531
|
+
FileUtils.mkdir_p(destination.dirname)
|
|
532
|
+
clone!(corpus.manifest.dig('source', 'remote_url'), destination)
|
|
533
|
+
revision = corpus.manifest.dig('source', 'revision')
|
|
534
|
+
git!('pinned revision missing after clone', '-C', destination.to_s, 'cat-file', '-e',
|
|
535
|
+
"#{revision}^{commit}")
|
|
536
|
+
git!('pinned revision checkout failed', '-C', destination.to_s, 'checkout', '--detach', '--quiet',
|
|
537
|
+
revision)
|
|
538
|
+
destination
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
private
|
|
542
|
+
|
|
543
|
+
def validate_destination!(destination, tmp_root)
|
|
544
|
+
gem_root = Pathname(__dir__).join('..', '..', '..', '..').realpath
|
|
545
|
+
resolved_tmp = tmp_root.exist? ? tmp_root.realpath : tmp_root.dirname.realpath.join(tmp_root.basename)
|
|
546
|
+
unless resolved_tmp.to_s.start_with?("#{gem_root}/")
|
|
547
|
+
raise Corpus::Error, 'clone tmp root must be inside the ast-merge-git repository'
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
FileUtils.mkdir_p(tmp_root)
|
|
551
|
+
resolved_tmp = tmp_root.realpath
|
|
552
|
+
resolved_destination = resolve_destination(destination)
|
|
553
|
+
unless resolved_destination.to_s.start_with?("#{resolved_tmp}/")
|
|
554
|
+
raise Corpus::Error, 'clone destination must be inside the configured repo-local tmp root'
|
|
555
|
+
end
|
|
556
|
+
raise Corpus::Error, "clone destination already exists: #{destination}" if destination.exist?
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def resolve_destination(destination)
|
|
560
|
+
return destination.realpath if destination.exist?
|
|
561
|
+
|
|
562
|
+
ancestor = destination.cleanpath.dirname
|
|
563
|
+
ancestor = ancestor.dirname until ancestor.exist?
|
|
564
|
+
ancestor.realpath.join(destination.cleanpath.relative_path_from(ancestor)).cleanpath
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def clone!(remote, destination)
|
|
568
|
+
git!('git clone failed', 'clone', '--no-checkout', '--filter=blob:none', remote, destination.to_s)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def git!(message, *arguments)
|
|
572
|
+
_stdout, stderr, status = Open3.capture3('git', *arguments, binmode: true)
|
|
573
|
+
raise Corpus::Error, "#{message}: #{stderr.strip}" unless status.success?
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
# rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|