lemans 0.0.0.pre → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +228 -0
  5. data/exe/lemans +17 -0
  6. data/exe/lemans-remote +984 -0
  7. data/lib/lemans/agents/base.rb +30 -0
  8. data/lib/lemans/agents/miniswen.rb +119 -0
  9. data/lib/lemans/agents/miniswen_installed.rb +67 -0
  10. data/lib/lemans/agents/nop.rb +15 -0
  11. data/lib/lemans/agents/oracle.rb +53 -0
  12. data/lib/lemans/agents.rb +21 -0
  13. data/lib/lemans/bench.rb +280 -0
  14. data/lib/lemans/cli/board_reporter.rb +135 -0
  15. data/lib/lemans/cli/progress_reporter.rb +67 -0
  16. data/lib/lemans/cli.rb +181 -0
  17. data/lib/lemans/clobber.rb +79 -0
  18. data/lib/lemans/environments/base.rb +55 -0
  19. data/lib/lemans/environments/daytona/retries.rb +49 -0
  20. data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
  21. data/lib/lemans/environments/daytona/shell.rb +142 -0
  22. data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
  23. data/lib/lemans/environments/daytona.rb +175 -0
  24. data/lib/lemans/environments.rb +16 -0
  25. data/lib/lemans/network_policy.rb +66 -0
  26. data/lib/lemans/patch.rb +70 -0
  27. data/lib/lemans/restore_paths.rb +21 -0
  28. data/lib/lemans/results/aggregate.rb +114 -0
  29. data/lib/lemans/results/cost_source.rb +13 -0
  30. data/lib/lemans/results/outcome.rb +36 -0
  31. data/lib/lemans/results/report.rb +149 -0
  32. data/lib/lemans/results/sorting.rb +24 -0
  33. data/lib/lemans/results/tally.rb +19 -0
  34. data/lib/lemans/results/usage.rb +24 -0
  35. data/lib/lemans/run.rb +152 -0
  36. data/lib/lemans/setup.rb +59 -0
  37. data/lib/lemans/setup_files.rb +36 -0
  38. data/lib/lemans/snapshot.rb +55 -0
  39. data/lib/lemans/task.rb +207 -0
  40. data/lib/lemans/tree_digest.rb +24 -0
  41. data/lib/lemans/trial.rb +187 -0
  42. data/lib/lemans/units.rb +44 -0
  43. data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
  44. data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
  45. data/lib/lemans/verifier.rb +199 -0
  46. data/lib/lemans/version.rb +5 -0
  47. data/lib/lemans.rb +29 -0
  48. data/lib/miniswen/agent.rb +678 -0
  49. data/lib/miniswen/cli.rb +224 -0
  50. data/lib/miniswen/environment.rb +14 -0
  51. data/lib/miniswen/local.rb +42 -0
  52. data/lib/miniswen/ruby_llm.rb +42 -0
  53. data/lib/miniswen/testing.rb +134 -0
  54. data/lib/miniswen/trajectory.rb +110 -0
  55. data/lib/miniswen/version.rb +5 -0
  56. data/lib/miniswen.rb +48 -0
  57. metadata +161 -7
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "yaml"
5
+ require "pathname"
6
+
7
+ module Lemans
8
+ # One task: an instruction, an environment, a verifier, a solution.
9
+ # Anything lemans does not understand belongs under `metadata`, copied untouched.
10
+ class Task
11
+ INSTRUCTION = "instruction.md"
12
+ ENVIRONMENT_DIR = "environment"
13
+ TESTS_DIR = "tests"
14
+ SOLUTION_DIR = "solution"
15
+
16
+ FLAT_TEST = "verification_test.rb"
17
+ FLAT_SOLUTION = "solution.patch"
18
+ FLAT_SEED = "environment.patch"
19
+
20
+ FRONTMATTER = /\A---\n(.*?)\n---\n/m
21
+
22
+ # An image, either already published or built from a task's Dockerfile.
23
+ # Built images are named by content digest, so reuse is only ever of the identical thing.
24
+ class ImageSpec
25
+ attr_reader :reference, :dockerfile_path, :slug, :digest
26
+
27
+ def self.registry(reference) = new(reference: reference)
28
+
29
+ def self.dockerfile(path, slug:)
30
+ path = Pathname(path)
31
+ raise ConfigError, "no Dockerfile at #{path}" unless path.file?
32
+
33
+ new(dockerfile_path: path, slug: slug)
34
+ end
35
+
36
+ def initialize(reference: nil, dockerfile_path: nil, slug: nil)
37
+ @reference = reference
38
+ @dockerfile_path = dockerfile_path
39
+ @slug = slug
40
+ # Hashing the reference gives backends one answer to "is this the same image" either way.
41
+ @digest = built? ? TreeDigest.call(context_dir) : Digest::SHA256.hexdigest(reference.to_s)
42
+ freeze
43
+ end
44
+
45
+ def built? = !dockerfile_path.nil?
46
+
47
+ def context_dir = dockerfile_path&.dirname
48
+
49
+ def name
50
+ built? ? "lemans-#{digest[0, 32]}" : reference
51
+ end
52
+
53
+ def to_s = name
54
+ end
55
+
56
+ attr_reader :dir, :name, :description, :difficulty, :tags, :metadata, :bench, :digest
57
+
58
+ def self.load(dir, bench:)
59
+ dir = Pathname(dir)
60
+ new(frontmatter(dir), dir: dir, bench: bench)
61
+ end
62
+
63
+ def self.frontmatter(dir)
64
+ content = dir.join(INSTRUCTION).read
65
+ match = content.match(FRONTMATTER)
66
+ unless match
67
+ # Opens like frontmatter but never matches: silently dropping every
68
+ # declared key (and leaking the raw block to the agent) is worse than
69
+ # refusing. CRLF endings and a missing final newline are the usual causes.
70
+ raise ConfigError, "#{dir.join(INSTRUCTION)}: frontmatter opens with --- but never closes" if
71
+ content.start_with?("---")
72
+
73
+ return {}
74
+ end
75
+ config = YAML.safe_load(match[1], aliases: true) || {}
76
+ raise ConfigError, "#{dir.join(INSTRUCTION)}: frontmatter must be a mapping" unless config.is_a?(Hash)
77
+
78
+ config
79
+ rescue Errno::ENOENT
80
+ {} # fallback to validate!
81
+ rescue Psych::Exception => e
82
+ raise ConfigError, "#{dir.join(INSTRUCTION)}: #{e.message}"
83
+ end
84
+
85
+ def initialize(config, dir:, bench:)
86
+ @dir = Pathname(dir)
87
+ @bench = bench
88
+ @name = config["name"] || @dir.basename.to_s
89
+ @description = config["description"]
90
+ @difficulty = config["difficulty"]
91
+ @tags = Array(config["tags"]).freeze
92
+ @metadata = (config["metadata"] || {}).freeze
93
+ @files = SetupFiles.call(config["files"], root: @dir, label: @dir)
94
+ @restore = config.key?("restore") ? RestorePaths.call(config["restore"], label: "#{@dir}: restore") : nil
95
+
96
+ validate!(config)
97
+ # Recorded on every result: without it a reward cannot say which task version it measured.
98
+ @digest = TreeDigest.call(@dir)[0, 16]
99
+ freeze
100
+ end
101
+
102
+ # The story alone: frontmatter is for the harness, never for the agent.
103
+ def instruction = instruction_path.read.sub(FRONTMATTER, "")
104
+
105
+ def instruction_path = dir.join(INSTRUCTION)
106
+
107
+ def environment_context = dir.join(ENVIRONMENT_DIR)
108
+
109
+ def environment_dockerfile = environment_context.join("Dockerfile")
110
+
111
+ # Stays on the harness side while the agent works; uploaded into the sandbox only at verification.
112
+ def tests_dir = dir.join(TESTS_DIR)
113
+
114
+ # [absolute, remote-relative] pairs.
115
+ def test_files
116
+ if tests_dir.directory?
117
+ expand(tests_dir)
118
+ else
119
+ flat(FLAT_TEST)
120
+ end
121
+ end
122
+
123
+ def solution_files
124
+ if solution_context.directory?
125
+ expand(solution_context)
126
+ else
127
+ flat(FLAT_SOLUTION)
128
+ end
129
+ end
130
+
131
+ def environment_image
132
+ if bench.environment.image
133
+ ImageSpec.registry(bench.environment.image)
134
+ else
135
+ ImageSpec.dockerfile(environment_dockerfile, slug: name)
136
+ end
137
+ end
138
+
139
+ def setup_files(phase)
140
+ declared = @files.fetch(phase.to_sym, [])
141
+ seed = Pathname(FLAT_SEED)
142
+ return declared unless phase.to_sym == :environment && dir.join(seed).file? && !declared.include?(seed)
143
+
144
+ declared + [seed]
145
+ end
146
+
147
+ def solution_context = dir.join(SOLUTION_DIR)
148
+
149
+ def solution? = solution_files.any?
150
+
151
+ def restore_paths = @restore || bench.verifier.restore_paths
152
+
153
+ def to_h
154
+ {
155
+ name: name,
156
+ description: description,
157
+ difficulty: difficulty,
158
+ tags: tags,
159
+ metadata: metadata
160
+ }.compact
161
+ end
162
+
163
+ private
164
+
165
+ # Dotfiles included, matching TreeDigest: the files a digest records are
166
+ # exactly the files that ship.
167
+ def expand(root)
168
+ root.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [_1, _1.relative_path_from(root).to_s] }
169
+ end
170
+
171
+ def flat(filename)
172
+ path = dir.join(filename)
173
+ path.file? ? [[path, filename]] : []
174
+ end
175
+
176
+ def validate!(config)
177
+ raise ConfigError, "#{dir}: #{INSTRUCTION} is required" unless instruction_path.file?
178
+
179
+ refuse_bench_collisions!
180
+
181
+ raise ConfigError, "#{dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when bench.yml names no shared image" unless bench.environment.image || environment_dockerfile.file?
182
+
183
+ if test_files.empty?
184
+ raise ConfigError, "#{dir}: #{TESTS_DIR}/ or a flat #{FLAT_TEST} is required — " \
185
+ "the verifier uploads it at verification time"
186
+ end
187
+
188
+ return unless config.key?("overrides")
189
+
190
+ declared = config["overrides"]
191
+ named = declared.is_a?(Hash) && declared.any? ? " (#{declared.keys.join(", ")})" : ""
192
+ raise ConfigError, "#{dir}: a task cannot override the frozen profile#{named} — " \
193
+ "what has to vary belongs in bench.yml, where it varies for every trial"
194
+ end
195
+
196
+ # Collisions would be resolved by upload order, so a task never gets to shadow the bench-wide copy.
197
+ def refuse_bench_collisions!
198
+ SetupFiles::PHASES.each do |phase|
199
+ shadowed = @files.fetch(phase) & bench.setup_files(phase)
200
+ next if shadowed.empty?
201
+
202
+ raise ConfigError, "#{dir}: files.#{phase} names #{shadowed.first}, which #{bench.path.basename} " \
203
+ "already ships to every task"
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Lemans
6
+ # A directory's contents reduced to one number. Paths hash alongside
7
+ # contents, and dotfiles are included — glob skips them by default.
8
+ module TreeDigest
9
+ def self.call(dir)
10
+ dir = Pathname(dir)
11
+ sha = Digest::SHA256.new
12
+ dir.glob("**/*", File::FNM_DOTMATCH).sort.each do |entry|
13
+ next unless entry.file?
14
+
15
+ path = entry.relative_path_from(dir).to_s
16
+ contents = entry.binread
17
+ sha << [path.bytesize, contents.bytesize].pack("Q>Q>")
18
+ sha << path
19
+ sha << contents
20
+ end
21
+ sha.hexdigest
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module Lemans
8
+ # One task, one agent, one reward. Only what happens inside the agent phase
9
+ # is a statement about the model; everything else is the harness's fault.
10
+ class Trial
11
+ attr_reader :task, :bench, :agent_name, :model, :backend, :dir, :id
12
+
13
+ def initialize(task:, bench:, agent_name:, runs_dir:, model: nil, backend: "daytona")
14
+ @task = task
15
+ @bench = bench
16
+ @agent_name = agent_name
17
+ @model = model
18
+ @backend = backend
19
+ @id = "#{task.name}__#{SecureRandom.alphanumeric(7)}"
20
+ @dir = Pathname(runs_dir).join(model_dir, @id)
21
+ end
22
+
23
+ def run
24
+ logs_dir.mkpath
25
+ started_at = Time.now.utc
26
+ reward = nil
27
+ usage = nil
28
+ outcome = Results::Outcome.new(:completed)
29
+ @phases = {}
30
+ # Declared outside the phase blocks they are assigned in, or `ensure`
31
+ # could not stop a sandbox whose setup raised.
32
+ environment = nil
33
+ snapshot = nil
34
+ patch = nil
35
+
36
+ begin
37
+ agent = Agents.build(agent_name, profile: bench.agent, model: model)
38
+
39
+ phase(:environment_setup) do
40
+ environment = start_environment
41
+ prepare(environment)
42
+
43
+ snapshot = Snapshot.new(environment, bench: bench, task: task,
44
+ timeout: bench.environment.build_timeout_sec)
45
+ snapshot.capture!
46
+
47
+ patch = Patch.new(environment, bench: bench, dir: dir)
48
+ patch.seal!
49
+
50
+ agent.install(environment, task: task)
51
+ environment.network_policy = bench.agent.network
52
+ end
53
+
54
+ agent_result = phase(:agent) do
55
+ in_agent_phase { agent.call(environment, task: task, logs_dir: logs_dir) }
56
+ end
57
+ usage = agent_result.usage
58
+ outcome = over_ceiling(agent_result) || agent_result.outcome
59
+
60
+ patch.collect!
61
+
62
+ if outcome.scored?
63
+ phase(:verifier) do
64
+ # The sandbox is sealed before the tests arrive
65
+ environment.network_policy = NetworkPolicy.none
66
+ reward = Verifier.new(bench: bench, task: task, dir: dir, snapshot: snapshot).call(environment)
67
+ end
68
+ end
69
+ rescue VerifierError => e
70
+ outcome = Results::Outcome.new(:verifier_error, detail: e.message)
71
+ rescue ::Miniswen::AccountingError => e
72
+ outcome = Results::Outcome.new(:accounting_error, detail: e.message)
73
+ rescue InfrastructureError, ::Miniswen::InfrastructureError => e
74
+ outcome = Results::Outcome.new(@agent_phase ? :agent_error : :environment_error, detail: e.message)
75
+ rescue ConfigError
76
+ # A malformed bench is the author's bug to fix - raise!
77
+ raise
78
+ rescue StandardError => e
79
+ # A harness bug must leave evidence.
80
+ outcome = Results::Outcome.new(:harness_crash, detail: crash_detail(e))
81
+ ensure
82
+ environment&.stop
83
+ end
84
+
85
+ write_result(started_at: started_at, reward: reward, outcome: outcome, usage: usage)
86
+ rescue SystemCallError, JSON::GeneratorError => e
87
+ # runs_dir unwritable, disk full
88
+ raise ConfigError, "cannot record trial #{id}: #{e.message}"
89
+ end
90
+
91
+ private
92
+
93
+ def model_dir
94
+ (model || bench.agent.model || agent_name).to_s.split("/").last
95
+ end
96
+
97
+ def logs_dir = dir
98
+
99
+ def over_ceiling(result)
100
+ limit = bench.agent.cost_limit
101
+ cost = result.usage&.cost_usd
102
+ return nil unless limit && cost && result.outcome.scored? && cost > limit
103
+
104
+ Results::Outcome.new(:cost_ceiling_reached,
105
+ detail: format("spent $%<cost>.4f against a $%<limit>.4f limit", cost: cost, limit: limit))
106
+ end
107
+
108
+ def crash_detail(error)
109
+ ["#{error.class}: #{error.message}", *Array(error.backtrace).first(5)].join("\n")
110
+ end
111
+
112
+ def phase(name)
113
+ @phases[name] = { started_at: Time.now.utc.iso8601(6) }
114
+ yield
115
+ ensure
116
+ @phases[name][:finished_at] = Time.now.utc.iso8601(6)
117
+ end
118
+
119
+ def in_agent_phase
120
+ @agent_phase = true
121
+ result = yield
122
+ @agent_phase = false
123
+ result
124
+ end
125
+
126
+ def start_environment
127
+ Environments.build(
128
+ backend,
129
+ image: task.environment_image,
130
+ resources: bench.environment.resources,
131
+ network: bench.environment.network,
132
+ build_timeout_sec: bench.environment.build_timeout_sec,
133
+ labels: { "lemans.task" => task.name, "lemans.trial" => id, "lemans.phase" => "agent" }
134
+ ).start
135
+ end
136
+
137
+ def prepare(environment)
138
+ Setup.new(
139
+ commands: bench.environment.setup,
140
+ task: task,
141
+ phase: :environment,
142
+ timeout_sec: bench.environment.build_timeout_sec
143
+ ).call(environment)
144
+ end
145
+
146
+ def write_result(started_at:, reward:, outcome:, usage:)
147
+ finished_at = Time.now.utc
148
+ result = {
149
+ trial: id,
150
+ task: task.name,
151
+ agent: agent_name,
152
+ model: model || bench.agent.model,
153
+ reward: outcome.scored? ? reward : nil,
154
+ outcome: outcome.to_h,
155
+ usage: usage&.to_h,
156
+ lemans_version: VERSION,
157
+ # The digest already hashes the bench's shipped files along with its
158
+ # config, so the per-file listing added bulk, not pinning.
159
+ profile_digest: bench.digest,
160
+ task_digest: task.digest,
161
+ bench: bench.revision.to_h,
162
+ started_at: started_at.iso8601,
163
+ finished_at: finished_at.iso8601,
164
+ duration_sec: (finished_at - started_at).round(1),
165
+ # Where the wall clock went: without this, a slow sandbox morning
166
+ # reads as a slow model.
167
+ phases: @phases,
168
+ tags: task.tags,
169
+ metadata: task.metadata
170
+ }
171
+ atomic_write(result_path, "#{JSON.pretty_generate(result)}\n")
172
+ result
173
+ end
174
+
175
+ def result_path = dir.join("result.json")
176
+
177
+ # --resume treats any result.json as a finished attempt, so the write must
178
+ # be atomic: a rename is either all there or not there at all.
179
+ def atomic_write(path, content)
180
+ tmp = path.dirname.join(".#{path.basename}.#{Process.pid}.#{SecureRandom.hex(4)}")
181
+ tmp.write(content)
182
+ tmp.rename(path)
183
+ ensure
184
+ tmp&.delete if tmp&.exist?
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ # Durations and sizes as a human writes them ("30m", "2GB"), stored as
5
+ # seconds and megabytes. A bare number gets the obvious reading.
6
+ module Units
7
+ DURATION = /\A(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?\z/
8
+ DURATION_FACTORS = { "ms" => 0.001, "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
9
+
10
+ SIZE = /\A(\d+(?:\.\d+)?)\s*(MB|GB|TB)?\z/i
11
+ SIZE_FACTORS = { "mb" => 1, "gb" => 1024, "tb" => 1024 * 1024 }.freeze
12
+
13
+ class << self
14
+ def seconds(value, field:)
15
+ return nil if value.nil?
16
+ return finite_nonnegative(value, field, "a duration") if value.is_a?(Numeric)
17
+
18
+ match = DURATION.match(value.to_s.strip)
19
+ raise ConfigError, "#{field}: cannot read #{value.inspect} as a duration (try 30m, 300s, 1h)" unless match
20
+
21
+ match[1].to_f * DURATION_FACTORS.fetch(match[2] || "s")
22
+ end
23
+
24
+ def megabytes(value, field:)
25
+ return nil if value.nil?
26
+ return finite_nonnegative(value, field, "a size").round if value.is_a?(Numeric)
27
+
28
+ match = SIZE.match(value.to_s.strip)
29
+ raise ConfigError, "#{field}: cannot read #{value.inspect} as a size (try 2GB, 512MB)" unless match
30
+
31
+ (match[1].to_f * SIZE_FACTORS.fetch((match[2] || "mb").downcase)).round
32
+ end
33
+
34
+ private
35
+
36
+ def finite_nonnegative(value, field, noun)
37
+ number = value.to_f
38
+ raise ConfigError, "#{field}: cannot read #{value.inspect} as #{noun}" if number.negative? || !number.finite?
39
+
40
+ number
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded when a verifier command opts in with `ruby -report-lemans …`
4
+ # (that is `-r eport-lemans`, resolved from /tests on the LOAD_PATH).
5
+ module LemansReport
6
+ def self.registered? = @registered
7
+
8
+ def self.register
9
+ return if @registered
10
+ return unless defined?(::Minitest) && ::Minitest.respond_to?(:extensions)
11
+
12
+ @registered = true
13
+ require_relative "lemans_minitest_reporter"
14
+ ::Minitest.singleton_class.define_method(:plugin_lemans_report_init) do |_options|
15
+ dir = ENV["LOGS"]
16
+ reporter << Reporter.new(dir) if dir && File.directory?(dir)
17
+ end
18
+ (::Minitest.extensions ||= []) << "lemans_report"
19
+ end
20
+ end
21
+
22
+ if defined?(Minitest)
23
+ LemansReport.register
24
+ else
25
+ # `-r` runs before bundler picks the app's minitest, so requiring minitest
26
+ # here would activate the wrong version. Instead watch class definitions and
27
+ # register the moment minitest's own module body closes; the probe disarms
28
+ # itself and nothing foreign is patched.
29
+ trace = TracePoint.new(:end) do |event|
30
+ next unless event.self.is_a?(Module) && event.self.name == "Minitest"
31
+
32
+ LemansReport.register
33
+ trace.disable if LemansReport.registered?
34
+ end
35
+ trace.enable
36
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LemansReport
6
+ # Appends every Minitest result to $LOGS/checks.json. Required by
7
+ # eport-lemans once Minitest is loaded; never load this file directly.
8
+ class Reporter < Minitest::AbstractReporter
9
+ def initialize(dir)
10
+ super()
11
+ @dir = dir
12
+ @results = []
13
+ end
14
+
15
+ def record(result)
16
+ @results << result
17
+ end
18
+
19
+ def report
20
+ graded = @results.select { graded?(_1) }
21
+ prior = existing.fetch("checks", {})
22
+ return if graded.empty? && prior.empty?
23
+
24
+ checks = prior.merge(graded.to_h { [name(_1), status(_1)] }).sort.to_h
25
+ File.write(
26
+ File.join(@dir, "checks.json"),
27
+ JSON.pretty_generate(checks: checks, failures: checks.reject { |_, status| status == "pass" }.keys)
28
+ )
29
+ end
30
+
31
+ # A skip inside the harness-shipped tests is an unverified requirement and
32
+ # fails the run. The app's own suite keeps vanilla skip semantics.
33
+ def passed?
34
+ @results.none? { |result| graded?(result) && result.skipped? }
35
+ end
36
+
37
+ private
38
+
39
+ def graded?(result)
40
+ dir = ENV["TESTS"]
41
+ # The trailing slash matters: /testsuite must not count as /tests.
42
+ dir && result.source_location.first.to_s.start_with?("#{dir.chomp("/")}/")
43
+ end
44
+
45
+ def existing
46
+ JSON.parse(File.read(File.join(@dir, "checks.json")))
47
+ rescue StandardError
48
+ {}
49
+ end
50
+
51
+ def name(result) = "#{result.klass}##{result.name}"
52
+
53
+ def status(result)
54
+ if result.skipped? then "skip"
55
+ elsif result.error? then "error"
56
+ elsif result.passed? then "pass"
57
+ else "fail"
58
+ end
59
+ end
60
+ end
61
+ end