ace-test-runner-e2e 0.40.6 → 0.41.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d69fa4e48a31e4dd0f92cf961dfcc9b1c2fca7b392e61519add56ab806bc492
4
- data.tar.gz: d07a32cde8ab19937798129a563d65ef97884caf0158ce0dd5c6f757fdec352c
3
+ metadata.gz: '023288b5afdd9d9303ed0a4b439503062611cb9cd364e29f68aef91dab4a85c6'
4
+ data.tar.gz: 30026fd012aa74ebe994d0f41b0bcd809d6bf0599f4a842ad087756e88592d8b
5
5
  SHA512:
6
- metadata.gz: d6c7df27616d263629bcda75f2a3ff6eb098170115a8ad3a21cab96ff3e860a380f555c9d06603baef3f6922a7616880392c79cace2888ae89da41d40e55a275
7
- data.tar.gz: 39158b36f9a7b20d5c1e7ee8c5ae27b488482e4724d63758234a59700ca98ec67c8ac03e6783d5b1369b2fafa027db8013f188ad2c70716a330f2ac4bfeeb12b
6
+ metadata.gz: '062688fc71b5c282c9789c0c60cd1242a4b454436f6f98cfb39475fccc33025da06962c5e18c85728766dd59c3fe49aaff35bc987c4585c75b5218d5027c0dd1'
7
+ data.tar.gz: c06d63cd12734b3ac962e0699ecab8934e7f8797ae92b8ee0cf4fef2c3992c980e1e2f664b93e7d844b2bf7564b1003513d8b1c0ec51808e7fee6742d9a49645
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.41.0] - 2026-09-29
11
+
12
+ ### Changed
13
+ - Pipeline verdict integrity: a passing report requires a completed verifier response covering every selected test case; an incomplete verdict set becomes an explicit ERROR (`incomplete_verdict_set`) instead of an inferred PASS.
14
+ - Pipeline failures record `failure_phase`, `failure_category`, and structured execution evidence in the ERROR report; sessions whose provider process began and then ended without a confirmed completion are marked `uncertain_execution: true` in metadata.yml.
15
+ - The full-suite automatic retry (retry-failures-once) excludes uncertain scenarios — their side effects are never replayed. They stay in the final tally as errors and are listed by `FailureFinder#find_uncertain_scenarios` for explicit reconciliation. Explicit single-run `--only-failures` keeps them runnable (deliberate human reconciliation).
16
+ - `read_agent_result` (single and package runs) and the suite metadata reconcile keep a fresh pipeline ERROR authoritative: retained metadata.yml can no longer upgrade an errored execution to PASS.
17
+ - Verifier failures report `infrastructure-error` (runner/repair failures keep `runner-error`).
18
+ - TS-MONO-001-style scenarios can declare a `release-manifest` setup action: the runner
19
+ carries only the explicitly allowed `ACE_RELEASE_MANIFEST` host input (absolute path,
20
+ defaulting to the source root) into deterministic setup, which validates the strict
21
+ schema-version-1 manifest and copies its exact bytes into the sandbox before any
22
+ install goal can run. Invalid or missing input fails setup, and a failed deterministic
23
+ setup now aborts the scenario with an ERROR result instead of degrading to an
24
+ LLM-driven sandbox.
25
+
10
26
  ## [0.40.6] - 2026-09-29
11
27
 
12
28
  ### Fixed
@@ -116,7 +116,11 @@ module Ace
116
116
  timeout: options[:timeout],
117
117
  tags: tags,
118
118
  exclude_tags: exclude_tags,
119
- verify: options[:verify]
119
+ verify: options[:verify],
120
+ # An explicit --only-failures run is deliberate human
121
+ # reconciliation: uncertain scenarios stay selectable there.
122
+ # The automatic retry pass below always excludes them.
123
+ include_uncertain_failures: only_failures
120
124
  }
121
125
 
122
126
  results = run_suite_with_retry(
@@ -190,20 +194,35 @@ module Ace
190
194
  )
191
195
  return annotated unless retry_failures_once && suite_failed?(initial_results)
192
196
 
197
+ # Scenarios whose provider session ended uncertainly are excluded
198
+ # from replay by the failure finder; account for them explicitly
199
+ # so they stay visible failures instead of vanishing.
200
+ uncertain = orchestrator.uncertain_scenarios(packages: run_options[:packages])
201
+ unless uncertain.empty?
202
+ output.puts "#{uncertain.length} scenario(s) ended in uncertain execution and will not be retried (side effects are not replayed): #{uncertain.join(", ")}"
203
+ output.puts "Reconcile those sessions manually before rerunning them."
204
+ end
205
+
193
206
  output.puts "Retrying failed scenarios once..."
194
- retry_results = orchestrator.run(run_options.merge(only_failures: true))
195
- if retry_results[:total].zero?
207
+ retry_results = orchestrator.run(
208
+ run_options.merge(only_failures: true, include_uncertain_failures: false)
209
+ )
210
+ if retry_results[:total].zero? && uncertain.empty?
196
211
  raise Ace::Support::Cli::Error.new(
197
212
  "Retry pass found no failed test scenarios from attempt 1; aborting instead of silently passing"
198
213
  )
199
214
  end
200
215
 
216
+ retry_results = with_uncertain_accounting(retry_results, uncertain)
217
+
201
218
  flaky_scenarios = recovered_flaky_scenarios(initial_results, retry_results)
202
- remaining_failures = failure_scenarios(retry_results)
219
+ remaining_failures = (failure_scenarios(retry_results) + uncertain).uniq.sort
203
220
  final_report_path = write_retry_summary_report(initial_results, retry_results)
204
221
  output.puts "Final Report: #{final_report_path}" if final_report_path
205
222
 
206
- if remaining_failures.empty?
223
+ if (remaining_failures - uncertain).empty? && !uncertain.empty?
224
+ output.puts "All retried scenario(s) recovered; #{uncertain.length} uncertain scenario(s) still require reconciliation"
225
+ elsif remaining_failures.empty?
207
226
  output.puts "#{flaky_scenarios.length} scenario(s) recovered on retry and were marked flaky"
208
227
  else
209
228
  output.puts "#{remaining_failures.length} scenario(s) still failing after retry"
@@ -217,10 +236,23 @@ module Ace
217
236
  remaining_failures: remaining_failures,
218
237
  initial_report_path: initial_results[:report_path],
219
238
  retry_report_path: retry_results[:report_path],
220
- report_path: final_report_path || retry_results[:report_path]
239
+ report_path: final_report_path || retry_results[:report_path],
240
+ uncertain_scenarios: uncertain
221
241
  )
222
242
  end
223
243
 
244
+ # Keep uncertain scenarios in the final tally. The retry pass does
245
+ # not run them (by design), so without this they would disappear
246
+ # from counts and a suite with uncertain sessions could exit 0.
247
+ def with_uncertain_accounting(retry_results, uncertain_ids)
248
+ return retry_results if uncertain_ids.empty?
249
+
250
+ tally = retry_results.dup
251
+ tally[:total] = tally[:total].to_i + uncertain_ids.size
252
+ tally[:errors] = tally[:errors].to_i + uncertain_ids.size
253
+ tally
254
+ end
255
+
224
256
  def write_retry_summary_report(initial_results, retry_results)
225
257
  build_retry_report_writer.write_retry_summary(
226
258
  initial_results: initial_results,
@@ -6,15 +6,23 @@ module Ace
6
6
  module Test
7
7
  module EndToEndRunner
8
8
  module Molecules
9
- # Scans cache for failed test cases from previous E2E test runs
10
- #
11
- # Reads metadata.yml files from .ace-local/test-e2e/*-reports/ directories
12
- # and extracts failed_test_cases arrays. Used by --only-failures CLI flag
13
- # to re-run only tests that failed previously.
14
- #
15
- # Note: This is a Molecule (not an Atom) because it performs filesystem
16
- # I/O via Dir.glob and YAML file reading.
17
- class FailureFinder
9
+ # Scans cache for failed test cases from previous E2E test runs
10
+ #
11
+ # Reads metadata.yml files from .ace-local/test-e2e/*-reports/ directories
12
+ # and extracts failed_test_cases arrays. Used by --only-failures CLI flag
13
+ # to re-run only tests that failed previously.
14
+ #
15
+ # Scenarios whose most recent run ended in uncertain execution (the provider
16
+ # session began and then ended without a confirmed completion) are excluded
17
+ # from find_failures_by_scenario — the automatic retry surface — because
18
+ # re-running them would replay side effects. They are listed by
19
+ # find_uncertain_scenarios instead so callers can surface an actionable
20
+ # diagnostic. Explicit single-run --only-failures paths keep them included:
21
+ # invoking it is a deliberate human reconciliation decision.
22
+ #
23
+ # Note: This is a Molecule (not an Atom) because it performs filesystem
24
+ # I/O via Dir.glob and YAML file reading.
25
+ class FailureFinder
18
26
  CACHE_DIR = ".ace-local/test-e2e"
19
27
  METADATA_FILE = "metadata.yml"
20
28
  REPORTS_SUFFIX = "-reports"
@@ -81,11 +89,17 @@ module Ace
81
89
  # Like find_failures_by_package but preserves per-scenario granularity.
82
90
  # Callers can use this to re-run full failed scenarios.
83
91
  #
92
+ # By default, scenarios whose most recent run is marked
93
+ # uncertain_execution are excluded — replaying them would repeat
94
+ # unverified side effects. The explicit reconciliation path passes
95
+ # include_uncertain: true to select them deliberately.
96
+ #
84
97
  # @param packages [Array<String>] Package names to scan
85
98
  # @param base_dir [String] Base directory to search from (default: current dir)
99
+ # @param include_uncertain [Boolean] keep uncertain scenarios selectable
86
100
  # @return [Hash{String => Hash{String => Array<String>}}]
87
101
  # Package name => { test-id => failed TC IDs }
88
- def find_failures_by_scenario(packages:, base_dir: Dir.pwd)
102
+ def find_failures_by_scenario(packages:, base_dir: Dir.pwd, include_uncertain: false)
89
103
  metadata_files = discover_metadata_files(base_dir)
90
104
  return {} if metadata_files.empty?
91
105
 
@@ -96,6 +110,8 @@ module Ace
96
110
 
97
111
  scenario_failures = {}
98
112
  most_recent.each do |entry|
113
+ next if !include_uncertain && uncertain_execution?(entry[:data])
114
+
99
115
  test_id = entry[:data]["test-id"]
100
116
  failed_ids = extract_failed_test_cases(entry[:data])
101
117
  scenario_failures[test_id] = failed_ids unless failed_ids.empty?
@@ -106,6 +122,32 @@ module Ace
106
122
  result
107
123
  end
108
124
 
125
+ # Find scenarios whose most recent run ended in uncertain execution,
126
+ # grouped by package. These need manual reconciliation before rerun;
127
+ # they are excluded from find_failures_by_scenario.
128
+ #
129
+ # @param packages [Array<String>] Package names to scan
130
+ # @param base_dir [String] Base directory to search from (default: current dir)
131
+ # @return [Hash{String => Array<String>}] Package name => uncertain test-ids
132
+ def find_uncertain_scenarios(packages:, base_dir: Dir.pwd)
133
+ metadata_files = discover_metadata_files(base_dir)
134
+ return {} if metadata_files.empty?
135
+
136
+ result = {}
137
+ packages.each do |package|
138
+ package_metadata = filter_by_package(metadata_files, package)
139
+ most_recent = most_recent_per_test(package_metadata)
140
+
141
+ uncertain = most_recent
142
+ .select { |entry| uncertain_execution?(entry[:data]) }
143
+ .map { |entry| entry[:data]["test-id"] }
144
+ .sort
145
+
146
+ result[package] = uncertain unless uncertain.empty?
147
+ end
148
+ result
149
+ end
150
+
109
151
  private
110
152
 
111
153
  # Discover all metadata.yml files in the cache directory
@@ -169,6 +211,13 @@ module Ace
169
211
  entries.flat_map { |entry| extract_failed_test_cases(entry[:data]) }.uniq
170
212
  end
171
213
 
214
+ # True when the latest run of this scenario ended in uncertain
215
+ # execution (the provider session began and ended without a
216
+ # confirmed completion; side effects may have happened).
217
+ def uncertain_execution?(data)
218
+ data["uncertain_execution"] == true
219
+ end
220
+
172
221
  # Extract failed test case IDs from a single metadata hash
173
222
  #
174
223
  # Returns specific TC IDs when available, or ["*"] as a wildcard
@@ -42,7 +42,12 @@ module Ace
42
42
  # @return [Models::TestResult]
43
43
  def execute(scenario:, cli_args:, sandbox_path:, report_dir:, env_vars: nil, test_cases: nil)
44
44
  started_at = Time.now
45
+ phase = :setup
45
46
  FileUtils.mkdir_p(report_dir)
47
+ # A retry in the same report directory must start with neither
48
+ # terminal marker present: only this attempt's outcome may remain.
49
+ FileUtils.rm_f(File.join(report_dir, ".host-pipeline-complete.json"))
50
+ FileUtils.rm_f(File.join(report_dir, ".host-pipeline-failed.json"))
46
51
  write_command_record(report_dir, "runner", provider: @provider, cli_args: cli_args)
47
52
  write_tc_manifests(report_dir, scenario, test_cases: test_cases)
48
53
 
@@ -72,6 +77,7 @@ module Ace
72
77
  test_cases: test_cases,
73
78
  artifact_contract: declared_artifact_contract(scenario, test_cases: test_cases)
74
79
  )
80
+ phase = :runner
75
81
  runner_response = run_llm(
76
82
  prompt_path: runner[:prompt_path],
77
83
  system_path: runner[:system_path],
@@ -101,6 +107,7 @@ module Ace
101
107
  artifact_contract: artifact_contract,
102
108
  repair_mode: true
103
109
  )
110
+ phase = :repair
104
111
  repair_response = run_llm(
105
112
  prompt_path: repair_runner[:prompt_path],
106
113
  system_path: repair_runner[:system_path],
@@ -126,6 +133,7 @@ module Ace
126
133
  artifact_contract: artifact_contract
127
134
  )
128
135
  write_command_record(report_dir, "verifier", provider: @verifier_provider, cli_args: cli_args)
136
+ phase = :verifier
129
137
  verifier_response = run_llm(
130
138
  prompt_path: verifier[:prompt_path],
131
139
  system_path: verifier[:system_path],
@@ -137,13 +145,14 @@ module Ace
137
145
  fallback: query_fallback_for(@verifier_provider)
138
146
  )
139
147
 
140
- @report_generator.generate(
148
+ report = @report_generator.generate(
141
149
  scenario: scenario,
142
150
  verifier_output: verifier_response[:text],
143
151
  report_dir: report_dir,
144
152
  provider: @verifier_provider,
145
153
  started_at: started_at,
146
154
  completed_at: Time.now,
155
+ expected_test_case_ids: selected_test_case_ids(scenario, test_cases),
147
156
  metadata: base_metadata(
148
157
  report_dir,
149
158
  runner_observations: runner_observations,
@@ -151,8 +160,26 @@ module Ace
151
160
  initial_artifact_contract: initial_artifact_contract
152
161
  )
153
162
  )
163
+ # Host-side completion record, written after the report: the
164
+ # runner-writable metadata.yml alone cannot prove the pipeline
165
+ # finished. A rescue below never writes this file.
166
+ File.write(File.join(report_dir, ".host-pipeline-complete.json"), JSON.generate({
167
+ "scenario" => scenario.test_id,
168
+ "runner_provider" => @provider,
169
+ "verifier_provider" => @verifier_provider,
170
+ "completed_at" => Time.now.utc.iso8601
171
+ }))
172
+ report
154
173
  rescue => e
155
174
  begin
175
+ # A failed pipeline leaves an explicit failure record instead of
176
+ # a completion record; finalization treats absence as failure.
177
+ File.write(File.join(report_dir, ".host-pipeline-failed.json"), JSON.generate({
178
+ "scenario" => scenario.test_id,
179
+ "failure_phase" => phase.to_s,
180
+ "error" => "#{e.class}: #{e.message}",
181
+ "completed_at" => Time.now.utc.iso8601
182
+ }))
156
183
  @report_generator.write_failure_report(
157
184
  scenario: scenario,
158
185
  report_dir: report_dir,
@@ -160,8 +187,8 @@ module Ace
160
187
  started_at: started_at || Time.now,
161
188
  completed_at: Time.now,
162
189
  error_message: "#{e.class}: #{e.message}",
163
- failure_category: "runner-error",
164
- metadata: base_metadata(report_dir)
190
+ failure_category: phase == :verifier ? "infrastructure-error" : "runner-error",
191
+ metadata: base_metadata(report_dir).merge(failure_evidence_metadata(e, phase))
165
192
  )
166
193
  rescue => write_error
167
194
  Models::TestResult.new(
@@ -288,6 +315,25 @@ module Ace
288
315
  Array(scenario.test_cases).select { |tc| wanted.include?(tc.tc_id.to_s.upcase) }
289
316
  end
290
317
 
318
+ # TC ids the verifier must cover for the verdict set to be complete.
319
+ def selected_test_case_ids(scenario, test_cases)
320
+ select_test_cases(scenario, test_cases).map(&:tc_id)
321
+ end
322
+
323
+ # Structured failure metadata for the error report. When the failure
324
+ # carries execution evidence (the provider session began and ended
325
+ # without a confirmed completion), the scenario is marked uncertain:
326
+ # automatic retries must not replay its side effects.
327
+ def failure_evidence_metadata(error, phase)
328
+ metadata = {"failure_phase" => phase.to_s}
329
+ evidence = Ace::LLM::Atoms::ErrorClassifier.execution_evidence_for(error)
330
+ return metadata unless evidence
331
+
332
+ metadata["uncertain_execution"] = true if evidence.uncertain_execution?
333
+ metadata["execution_evidence"] = evidence.to_h
334
+ metadata
335
+ end
336
+
291
337
  def base_metadata(report_dir, runner_observations: nil, artifact_contract: nil, initial_artifact_contract: nil)
292
338
  metadata = {
293
339
  "runner_provider" => @provider,
@@ -31,9 +31,12 @@ module Ace
31
31
  # @param provider [String]
32
32
  # @param started_at [Time]
33
33
  # @param completed_at [Time]
34
+ # @param expected_test_case_ids [Array<String>, nil] TC ids the verifier must cover
34
35
  # @return [Models::TestResult]
35
- def generate(scenario:, verifier_output:, report_dir:, provider:, started_at:, completed_at:, metadata: {})
36
+ def generate(scenario:, verifier_output:, report_dir:, provider:, started_at:, completed_at:,
37
+ expected_test_case_ids: nil, metadata: {})
36
38
  parsed = parse_verifier_output(verifier_output, scenario)
39
+ parsed = enforce_verdict_completeness(parsed, expected_test_case_ids)
37
40
  merged_metadata = metadata.merge(parsed[:metadata] || {})
38
41
 
39
42
  result = Models::TestResult.new(
@@ -94,9 +97,30 @@ module Ace
94
97
  result.with_report_dir(report_dir)
95
98
  end
96
99
 
97
- private
100
+ private
101
+
102
+ # A passing verdict requires one valid verdict per selected test case.
103
+ # A verifier response covering only some of them stays an explicit
104
+ # ERROR — bundle output must never synthesize the missing verdicts.
105
+ def enforce_verdict_completeness(parsed, expected_test_case_ids)
106
+ expected = Array(expected_test_case_ids).map { |id| id.to_s.upcase }
107
+ return parsed if expected.empty? || parsed[:status] == "error"
108
+
109
+ verdict_ids = parsed[:test_cases].map { |tc| tc[:id].to_s.upcase }
110
+ missing = expected - verdict_ids
111
+ return parsed if missing.empty?
112
+
113
+ {
114
+ status: "error",
115
+ test_cases: parsed[:test_cases],
116
+ summary: "Incomplete verifier verdict set (#{verdict_ids.size}/#{expected.size} test cases)",
117
+ error: "Verifier response is missing verdicts for: #{missing.join(", ")}",
118
+ observations: parsed[:observations],
119
+ metadata: (parsed[:metadata] || {}).merge("incomplete_verdict_set" => true)
120
+ }
121
+ end
98
122
 
99
- def parse_verifier_output(text, scenario)
123
+ def parse_verifier_output(text, scenario)
100
124
  goals = parse_goal_sections(text, scenario)
101
125
  return build_result_from_goals(goals, text) unless goals.empty?
102
126
 
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "rubygems/version"
7
+
8
+ module Ace
9
+ module Test
10
+ module EndToEndRunner
11
+ module Molecules
12
+ # Validates the frozen release installation manifest used by release
13
+ # verification scenarios (TS-MONO-001).
14
+ #
15
+ # The manifest is an exact-version contract: schema_version 1, a full
16
+ # source commit, and a nonempty package array. Every package entry
17
+ # carries a unique ACE gem name, one exact artifact version (never a
18
+ # requirement), the full source commit the artifact was built from,
19
+ # and an optional explicit supersession list of earlier versions of
20
+ # the same package.
21
+ #
22
+ # Validation is strict on purpose: unknown fields, missing fields,
23
+ # duplicate names, non-exact versions, malformed SHAs, and
24
+ # contradictory supersession entries all fail before any install
25
+ # attempt is made.
26
+ class ReleaseManifest
27
+ SCHEMA_VERSION = 1
28
+ GEM_NAME_PATTERN = /\Aace-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/
29
+ SOURCE_SHA_PATTERN = /\A[0-9a-f]{40}\z/
30
+
31
+ # Raised for any manifest content or source-path failure. Setup
32
+ # converts this into a setup failure before installation runs.
33
+ class Invalid < StandardError; end
34
+
35
+ # Validate the manifest at +path+ and return its parsed content.
36
+ # @raise [Invalid] when the file is missing, unreadable,
37
+ # malformed, or violates the schema.
38
+ def self.load_validated(path)
39
+ data = parse_json(path)
40
+ validate_schema!(data)
41
+ data
42
+ end
43
+
44
+ # Validate the manifest at +source_path+ and copy the exact bytes
45
+ # to +target_path+, creating the target directory when needed.
46
+ # +required_packages+ names the gems the calling scenario's proof
47
+ # depends on; the manifest must cover every one of them. Entries
48
+ # may be plain names or {name, supersedes} maps requiring an
49
+ # explicit supersession declaration. The buffer is read once so
50
+ # the validated content, the written copy, and the returned digest
51
+ # are the same bytes even if the source file changes mid-flight.
52
+ # @raise [Invalid]
53
+ def self.validate_and_copy(source_path:, target_path:, required_packages: nil)
54
+ raw = read_source(source_path)
55
+ data = parse_buffer(raw, source_path)
56
+ validate_schema!(data)
57
+ entries = data["packages"].to_h { |package| [package["name"], package] }
58
+ Array(required_packages).each do |required|
59
+ name, supersedes = required_packages_entry(required)
60
+ entry = entries[name]
61
+ raise Invalid, "release manifest is missing required package: #{name}" unless entry
62
+
63
+ missing = Array(supersedes) - Array(entry["supersedes"])
64
+ next if missing.empty?
65
+
66
+ raise Invalid,
67
+ "release manifest does not declare that #{name} #{entry["artifact_version"]} supersedes #{missing.join(", ")}"
68
+ end
69
+ FileUtils.mkdir_p(File.dirname(target_path))
70
+ File.binwrite(target_path, raw)
71
+ Digest::SHA256.hexdigest(raw)
72
+ end
73
+
74
+ class << self
75
+ private
76
+
77
+ def read_source(path)
78
+ raise Invalid, "release manifest is missing: #{path}" unless File.file?(path)
79
+ raise Invalid, "release manifest is not readable: #{path}" unless File.readable?(path)
80
+
81
+ raw = File.binread(path)
82
+ raise Invalid, "release manifest is empty: #{path}" if raw.strip.empty?
83
+
84
+ raw
85
+ end
86
+
87
+ def parse_buffer(raw, path)
88
+ data = JSON.parse(raw)
89
+ raise Invalid, "release manifest must be a JSON object: #{path}" unless data.is_a?(Hash)
90
+
91
+ data
92
+ rescue JSON::ParserError => e
93
+ raise Invalid, "release manifest is not valid JSON: #{path} (#{e.message})"
94
+ end
95
+
96
+ def parse_json(path)
97
+ parse_buffer(read_source(path), path)
98
+ end
99
+
100
+ def validate_schema!(data)
101
+ reject_unknown_keys!(data, %w[schema_version source_sha packages], "manifest")
102
+
103
+ schema_version = data["schema_version"]
104
+ unless schema_version.is_a?(Integer) && schema_version == SCHEMA_VERSION
105
+ raise Invalid,
106
+ "unsupported schema_version: #{schema_version.inspect} (expected #{SCHEMA_VERSION})"
107
+ end
108
+
109
+ validate_source_sha!(data["source_sha"], "manifest source_sha")
110
+
111
+ packages = data["packages"]
112
+ raise Invalid, "packages must be a nonempty array" unless packages.is_a?(Array) && !packages.empty?
113
+
114
+ seen = {}
115
+ packages.each_with_index do |entry, index|
116
+ name = validate_package!(entry, index)
117
+ raise Invalid, "duplicate package name: #{name}" if seen.key?(name)
118
+
119
+ seen[name] = true
120
+ end
121
+ end
122
+
123
+ def validate_package!(entry, index)
124
+ label = "packages[#{index}]"
125
+ raise Invalid, "#{label} must be an object" unless entry.is_a?(Hash)
126
+
127
+ reject_unknown_keys!(entry, %w[name artifact_version source_sha supersedes], label)
128
+
129
+ name = entry["name"]
130
+ unless name.is_a?(String) && name.match?(GEM_NAME_PATTERN)
131
+ raise Invalid, "#{label} has invalid ACE gem name: #{name.inspect}"
132
+ end
133
+
134
+ validate_exact_version!(entry["artifact_version"], "#{label}(#{name}) artifact_version")
135
+ validate_source_sha!(entry["source_sha"], "#{label}(#{name}) source_sha")
136
+ validate_supersedes!(entry["supersedes"], entry["artifact_version"], "#{label}(#{name})")
137
+ name
138
+ end
139
+
140
+ def validate_exact_version!(raw, label)
141
+ unless raw.is_a?(String) && !raw.strip.empty?
142
+ raise Invalid, "#{label} must be an exact version string, got #{raw.inspect}"
143
+ end
144
+
145
+ version = begin
146
+ Gem::Version.new(raw)
147
+ rescue ArgumentError
148
+ raise Invalid, "#{label} is not a valid version: #{raw.inspect}"
149
+ end
150
+
151
+ return if version.to_s == raw
152
+
153
+ raise Invalid,
154
+ "#{label} must be an exact version, not a requirement or shorthand: #{raw.inspect}"
155
+ end
156
+
157
+ def validate_source_sha!(raw, label)
158
+ return if raw.is_a?(String) && raw.match?(SOURCE_SHA_PATTERN)
159
+
160
+ raise Invalid, "#{label} must be a full 40-character commit SHA, got #{raw.inspect}"
161
+ end
162
+
163
+ def validate_supersedes!(raw, artifact_version, label)
164
+ return if raw.nil?
165
+
166
+ unless raw.is_a?(Array)
167
+ raise Invalid, "#{label} supersedes must be an array of version strings"
168
+ end
169
+
170
+ artifact = Gem::Version.new(artifact_version)
171
+ seen = {}
172
+ raw.each do |entry|
173
+ validate_exact_version!(entry, "#{label} supersedes entry")
174
+ raise Invalid, "#{label} supersedes duplicates version #{entry}" if seen.key?(entry)
175
+
176
+ seen[entry] = true
177
+ next unless Gem::Version.new(entry) >= artifact
178
+
179
+ raise Invalid,
180
+ "#{label} supersedes must list earlier versions of the same package: #{entry} >= #{artifact_version}"
181
+ end
182
+ end
183
+
184
+ def reject_unknown_keys!(hash, allowed, label)
185
+ unknown = hash.keys - allowed
186
+ return if unknown.empty?
187
+
188
+ raise Invalid, "#{label} has unknown fields: #{unknown.sort.join(", ")}"
189
+ end
190
+
191
+ def required_packages_entry(required)
192
+ case required
193
+ when String
194
+ [required, nil]
195
+ when Hash
196
+ reject_unknown_keys!(required, %w[name supersedes], "required package entry")
197
+ name = required["name"]
198
+ unless name.is_a?(String) && name.match?(GEM_NAME_PATTERN)
199
+ raise Invalid, "required package entry has invalid ACE gem name: #{name.inspect}"
200
+ end
201
+
202
+ [name, required["supersedes"]]
203
+ else
204
+ raise Invalid, "required package entries must be names or {name, supersedes} maps"
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -222,6 +222,11 @@ module Ace
222
222
  metadata["works_for_end_user"] = result.metadata["works_for_end_user"] if result.metadata.key?("works_for_end_user")
223
223
  metadata["user_friction"] = result.metadata["user_friction"] if result.metadata.key?("user_friction")
224
224
  metadata["user_feedback"] = result.metadata["user_feedback"] if result.metadata.key?("user_feedback")
225
+ metadata["failure_category"] = result.metadata["failure_category"] if result.metadata.key?("failure_category")
226
+ metadata["failure_phase"] = result.metadata["failure_phase"] if result.metadata.key?("failure_phase")
227
+ metadata["uncertain_execution"] = result.metadata["uncertain_execution"] if result.metadata.key?("uncertain_execution")
228
+ metadata["execution_evidence"] = result.metadata["execution_evidence"] if result.metadata.key?("execution_evidence")
229
+ metadata["incomplete_verdict_set"] = result.metadata["incomplete_verdict_set"] if result.metadata.key?("incomplete_verdict_set")
225
230
 
226
231
  if test_case
227
232
  metadata["scenario-id"] = scenario.test_id