lemans 1.4.0 → 1.5.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.
@@ -12,6 +12,7 @@ module Lemans
12
12
  HOUSEKEEPING_TIMEOUT = 60
13
13
  MAX_OUTPUT_BYTES = 200_000
14
14
  EXEC_SLACK = 30
15
+ PROXY_PORT = 3128
15
16
 
16
17
  attr_reader :container
17
18
 
@@ -24,7 +25,8 @@ module Lemans
24
25
  end
25
26
 
26
27
  def start
27
- build_image! if image.built?
28
+ build_image!(image) if image.built?
29
+ start_proxy!(network.hosts) if network.allowlist?
28
30
  docker!("run", *run_args, timeout: build_timeout)
29
31
  @container = @name
30
32
  self
@@ -37,9 +39,10 @@ module Lemans
37
39
  timeout ||= DEFAULT_TIMEOUT
38
40
  started = now
39
41
  argv = [ "exec" ]
42
+ env = proxy_env.merge(env) if network.allowlist?
40
43
  env.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
41
44
  # The in-container timeout is what actually kills the process
42
- argv += [ container, "timeout", timeout.to_i.to_s, "sh", "-c", command ]
45
+ argv += [ container, "timeout", timeout.to_i.to_s, "bash", "-c", command ]
43
46
 
44
47
  exit_code, output = capture("docker", *argv, timeout: timeout + EXEC_SLACK)
45
48
  ExecResult.new(command:, exit_code:, output:, duration: (now - started).round(3))
@@ -59,13 +62,13 @@ module Lemans
59
62
  def switch_network_policy!(policy)
60
63
  assert_policy_supported!(policy)
61
64
 
62
- if policy.none?
63
- connected_networks.each { docker!("network", "disconnect", it, container) }
64
- else
65
- networks = connected_networks
66
- docker!("network", "disconnect", "none", container) if networks.include?("none")
67
- docker!("network", "connect", "bridge", container) unless networks.include?("bridge")
68
- end
65
+ wanted = ("bridge" if policy.public?) || (internal_network if policy.allowlist?)
66
+ networks = connected_networks
67
+ (networks - [ wanted ]).each { docker!("network", "disconnect", it, container) }
68
+
69
+ remove_proxy
70
+ start_proxy!(policy.hosts) if policy.allowlist?
71
+ docker!("network", "connect", wanted, container) if wanted && !networks.include?(wanted)
69
72
 
70
73
  @network = policy
71
74
  end
@@ -81,41 +84,101 @@ module Lemans
81
84
  end
82
85
  rescue StandardError => e
83
86
  warn "lemans: container #{container} may still be running — remove failed: #{e.class}: #{e.message}"
87
+ ensure
88
+ remove_proxy
89
+ remove_internal_network
84
90
  end
85
91
 
86
92
  private
87
93
 
88
94
  def assert_policy_supported!(policy)
89
- return if policy.none? || policy.public?
95
+ return unless policy.allowlist? && policy.ip_targets.any?
90
96
 
91
- raise ConfigError, "docker: #{policy.mode} is not supported (public and none only)"
97
+ raise ConfigError, "docker: an allowlist takes host names only (#{policy.ip_targets.join(", ")})"
92
98
  end
93
99
 
94
100
  # The tag is the content digest, so an existing image is the identical thing
95
- def build_image!
96
- exists, = capture("docker", "image", "inspect", image.name, timeout: HOUSEKEEPING_TIMEOUT)
101
+ def build_image!(spec)
102
+ exists, = capture("docker", "image", "inspect", spec.name, timeout: HOUSEKEEPING_TIMEOUT)
97
103
  return if exists.zero?
98
104
 
99
- docker!("build", "--tag", image.name, image.context_dir.to_s, timeout: build_timeout, on_output: @logger)
105
+ docker!("build", "--tag", spec.name, spec.context_dir.to_s, timeout: build_timeout, on_output: @logger)
106
+ end
107
+
108
+ # The task container sits on an internal network with no route out; the
109
+ # proxy is on it and on bridge, so the hosts it allows are the only way out.
110
+ def start_proxy!(hosts)
111
+ image = Config::ImageSpec.dockerfile(Pathname(__dir__).join("docker/proxy/Dockerfile"), slug: "proxy")
112
+ build_image!(image)
113
+ create_internal_network!
114
+ docker!("run", "--detach", "--name", proxy_name, "--network", internal_network,
115
+ *label_args, image.name, *hosts)
116
+ docker!("network", "connect", "bridge", proxy_name)
117
+ wait_for_proxy!
118
+ end
119
+
120
+ def wait_for_proxy!(attempts: 50)
121
+ attempts.times do
122
+ _, output = capture("docker", "logs", proxy_name, timeout: HOUSEKEEPING_TIMEOUT)
123
+ return if output.include?("listening on")
124
+
125
+ sleep 0.2
126
+ end
127
+
128
+ raise InfrastructureError, "docker: the allowlist proxy did not start"
100
129
  end
101
130
 
131
+ def proxy_env
132
+ url = "http://#{proxy_name}:#{PROXY_PORT}"
133
+ no_proxy = "localhost,127.0.0.1"
134
+ { "http_proxy" => url, "https_proxy" => url, "HTTP_PROXY" => url, "HTTPS_PROXY" => url,
135
+ "no_proxy" => no_proxy, "NO_PROXY" => no_proxy }
136
+ end
137
+
138
+ def create_internal_network!
139
+ exists, = capture("docker", "network", "inspect", internal_network, timeout: HOUSEKEEPING_TIMEOUT)
140
+ return if exists.zero?
141
+
142
+ docker!("network", "create", "--internal", *label_args, internal_network)
143
+ end
144
+
145
+ def remove_proxy
146
+ capture("docker", "rm", "--force", proxy_name, timeout: HOUSEKEEPING_TIMEOUT)
147
+ rescue StandardError
148
+ nil
149
+ end
150
+
151
+ def remove_internal_network
152
+ capture("docker", "network", "rm", internal_network, timeout: HOUSEKEEPING_TIMEOUT)
153
+ rescue StandardError
154
+ nil
155
+ end
156
+
157
+ def proxy_name = "#{@name}-proxy"
158
+
159
+ def internal_network = "#{@name}-net"
160
+
102
161
  def run_args
103
162
  args = [ "--detach", "--init", "--name", @name,
104
163
  "--cpus", resources.cpus.to_s, "--memory", "#{resources.memory}m",
105
164
  "--cap-add", "SYS_ADMIN", "--cap-add", "NET_ADMIN", "--security-opt", "apparmor=unconfined",
106
165
  "--entrypoint", "sh" ]
107
166
  args += [ "--network", "none" ] if network.none?
167
+ args += [ "--network", internal_network ] if network.allowlist?
108
168
  env.each { |key, value| args += [ "--env", "#{key}=#{value}" ] }
109
- labels.each { |key, value| args += [ "--label", "#{key}=#{value}" ] }
110
- args + [ image.name, "-c", "tail -f /dev/null" ]
169
+ args + label_args + [ image.name, "-c", "tail -f /dev/null" ]
111
170
  end
112
171
 
172
+ def label_args = labels.flat_map { |key, value| [ "--label", "#{key}=#{value}" ] }
173
+
113
174
  def connected_networks
114
175
  docker!("inspect", "--format", "{{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}", container).split
115
176
  end
116
177
 
117
178
  def remove
118
179
  capture("docker", "rm", "--force", "--volumes", @name, timeout: HOUSEKEEPING_TIMEOUT)
180
+ remove_proxy
181
+ remove_internal_network
119
182
  rescue StandardError
120
183
  nil
121
184
  end
data/lib/lemans/result.rb CHANGED
@@ -136,6 +136,12 @@ module Lemans
136
136
  def as_json(**) = to_h
137
137
  end
138
138
 
139
+ Restart = Data.define(:trial, :step, :mode) do
140
+ def initialize(trial:, step:, mode: nil) = super
141
+
142
+ def as_json(**) = to_h.compact
143
+ end
144
+
139
145
  Step = Data.define(:outcome, :usage, :duration) do
140
146
  def as_json(**) = { outcome: outcome.as_json, usage: usage&.as_json, duration: }.compact
141
147
 
@@ -150,12 +156,12 @@ module Lemans
150
156
  attr_reader :id, :task, :agent, :model, :index,
151
157
  :profile_digest, :task_digest, :revision
152
158
 
153
- attr_accessor :tags, :metadata
159
+ attr_accessor :tags, :metadata, :restarted_from, :total_steps
154
160
 
155
161
  attr_reader :phases, :steps
156
162
 
157
163
  # outcome-related attributes (we use setter-like methods, not accessors)
158
- attr_reader :reward, :credit, :outcome, :usage
164
+ attr_reader :reward, :credit, :features, :outcome, :usage
159
165
 
160
166
  def initialize(task:, agent:, model:, id: nil, index: nil,
161
167
  profile_digest: nil, task_digest: nil, revision: nil)
@@ -171,6 +177,8 @@ module Lemans
171
177
  @metadata = {}
172
178
  @phases = []
173
179
  @steps = nil
180
+ @total_steps = nil
181
+ @restarted_from = nil
174
182
 
175
183
  @id = id || "#{task}__#{SecureRandom.alphanumeric(7)}"
176
184
  @outcome = Outcome.new(:pending)
@@ -222,9 +230,10 @@ module Lemans
222
230
  completed!(outcome, aggregate_usage)
223
231
  end
224
232
 
225
- def graded!(reward, credit: reward)
233
+ def graded!(reward, credit: reward, features: nil)
226
234
  @reward = reward
227
235
  @credit = credit
236
+ @features = features
228
237
  self
229
238
  end
230
239
 
@@ -236,6 +245,49 @@ module Lemans
236
245
  @outcome = Outcome.new(reason, detail)
237
246
  @reward = nil
238
247
  @credit = nil
248
+ @features = nil
249
+ self
250
+ end
251
+
252
+ # A step is settled once the next one started: it passed its gate and left a savepoint.
253
+ def settled_steps
254
+ names = phases.map(&:name)
255
+ (1..steps.to_a.size).count { names.include?(:"agent.#{it + 1}") }
256
+ end
257
+
258
+ # The step whose verification ran last, nil when none did
259
+ def verified_step
260
+ name = phases.reverse.find { it.name.to_s.match?(/\Averifier(\.\d+)?\z/) }&.name or return nil
261
+ name.to_s[/\.(\d+)\z/, 1]&.to_i || steps&.size || 1
262
+ end
263
+
264
+ # Carries over the steps before `step`; a reverification carries `step` itself too.
265
+ def restart!(source, step:, mode: nil)
266
+ @restarted_from = Restart.new(trial: source.id, step:, mode: mode&.to_s)
267
+ carried = mode == :reverify ? step : step - 1
268
+
269
+ if source.steps
270
+ source.steps.first(carried).each { step_completed!(it.outcome, it.usage, duration: it.duration) }
271
+ elsif carried.positive?
272
+ # A single-step run graded before keeps its agent outcome; a failed grading lost it.
273
+ completed!(source.scored? ? source.outcome : Outcome.new(:completed), source.usage)
274
+ end
275
+ self
276
+ end
277
+
278
+ # Splices in the named phases of the source run, shifted so that its
279
+ # `through` phase ends now, and moves this run's environment setup back in
280
+ # front of them: the timeline reads as one run.
281
+ def adopt_phases!(source, names, through: names.last, now: Time.now.utc)
282
+ delta = now - source.phases.find { it.name == through }.finished_at
283
+
284
+ setup = phases.first
285
+ shift = source.phases.find { it.name == setup.name }.finished_at + delta - setup.finished_at
286
+ phases[0] = Phase.new(setup.name, started_at: setup.started_at + shift, finished_at: setup.finished_at + shift)
287
+
288
+ source.phases.select { names.include?(it.name) }.each do |phase|
289
+ phases << Phase.new(phase.name, started_at: phase.started_at + delta, finished_at: phase.finished_at + delta)
290
+ end
239
291
  self
240
292
  end
241
293
 
@@ -245,10 +297,11 @@ module Lemans
245
297
  {
246
298
  trial: id, task:, agent:, model:, index:,
247
299
  profile_digest:, task_digest:, revision: revision&.as_json,
300
+ restarted_from: restarted_from&.as_json,
248
301
  lemans_version: VERSION,
249
302
  tags:, metadata:, phases: phases.map(&:as_json),
250
- steps: steps&.map(&:as_json),
251
- reward:, credit:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
303
+ steps: steps&.map(&:as_json), total_steps:,
304
+ reward:, credit:, features:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
252
305
  started_at: started_at&.iso8601,
253
306
  finished_at: finished_at&.iso8601
254
307
  }.compact
@@ -264,6 +317,8 @@ module Lemans
264
317
  )
265
318
  result.tags = data[:tags] || []
266
319
  result.metadata = data[:metadata] || {}
320
+ result.restarted_from = Restart.new(**data[:restarted_from]) if data[:restarted_from]
321
+ result.total_steps = data[:total_steps]
267
322
  phases_from(data).each { result.phases << it }
268
323
 
269
324
  # Steps first: the stored outcome/usage below override the aggregates.
@@ -281,7 +336,10 @@ module Lemans
281
336
  )
282
337
  end
283
338
 
284
- result.graded!(data[:reward], credit: data[:credit] || data[:reward]) unless data[:reward].nil?
339
+ unless data[:reward].nil?
340
+ result.graded!(data[:reward], credit: data[:credit] || data[:reward],
341
+ features: data[:features]&.transform_keys(&:to_s))
342
+ end
285
343
  result
286
344
  end
287
345
 
@@ -298,6 +356,7 @@ module Lemans
298
356
  )
299
357
  result.tags = definition.tags
300
358
  result.metadata = definition.metadata
359
+ result.total_steps = definition.steps if definition.multistep?
301
360
  result
302
361
  end
303
362
 
@@ -20,18 +20,22 @@ module Lemans
20
20
  def_delegators :definition, :name, :config
21
21
  def_delegators :result, :id
22
22
 
23
- private attr_reader :definition, :store, :reporter
23
+ private attr_reader :definition, :store, :reporter, :restart_from, :restart_mode
24
24
 
25
- def initialize(model, task_definition, index: 0, store: nil, reporter: nil)
25
+ def initialize(model, task_definition, index: 0, store: nil, reporter: nil, restart_from: nil, restart_mode: nil)
26
26
  @model = model
27
27
  @definition = task_definition
28
28
  @index = index
29
29
  @store = store
30
30
  @reporter = reporter
31
+ @restart_from = restart_from
32
+ @restart_mode = restart_mode
31
33
  @status = :pending
32
34
 
33
- # prepare the result object: it's used by the actual execution down the stack
34
- @result = Result.from_task(definition, index:, model: model || config.models.first)
35
+ # prepare the result object: it's used by the actual execution down the stack;
36
+ # a restart keeps the agent of the run it restarts
37
+ @result = Result.from_task(definition, index:, model: model || config.models.first,
38
+ **({ agent: restart_from.agent } if restart_from))
35
39
  end
36
40
 
37
41
  def with_reporter(reporter)
@@ -55,7 +59,7 @@ module Lemans
55
59
  private
56
60
 
57
61
  def execute!
58
- Trial.new(definition, model, store:, result:).run
62
+ Trial.new(definition, model, store:, result:, agent: restart_from&.agent, restart_from:, restart_mode:).run
59
63
  end
60
64
  end
61
65
  end
data/lib/lemans/runner.rb CHANGED
@@ -20,20 +20,26 @@ module Lemans
20
20
 
21
21
  attr_reader :config, :tasks, :store, :reporter
22
22
 
23
- private attr_reader :resuming, :executor
23
+ private attr_reader :resuming, :restarts, :restart_mode, :allow_scored, :executor
24
24
 
25
- def initialize(config, tasks, store: nil, reporter: nil, executor: nil, resume: false)
25
+ def initialize(config, tasks, store: nil, reporter: nil, executor: nil, resume: false,
26
+ restarts: [], restart_mode: nil, allow_scored: false)
26
27
  @config = config
27
28
  @tasks = tasks
28
29
  @store = store
29
30
  @reporter = reporter
30
31
  @executor = executor || Executor.new(config.concurrency)
31
32
  @resuming = resume
33
+ @restarts = restarts
34
+ @restart_mode = restart_mode
35
+ @allow_scored = allow_scored
32
36
  end
33
37
 
34
38
  def resuming? = @resuming
35
39
 
36
40
  def attempts
41
+ return @attempts ||= restart_attempts if restarts.any?
42
+
37
43
  @attempts ||= config.agent.models.flat_map do |model|
38
44
  @tasks.flat_map do |task|
39
45
  completed = resuming? ? completed_attempts(task, model) : 0
@@ -64,6 +70,43 @@ module Lemans
64
70
 
65
71
  private
66
72
 
73
+ # Every run is checked before any starts: one refused run stops the batch.
74
+ def restart_attempts
75
+ refusals = []
76
+ attempts = restarts.filter_map do |restart|
77
+ restart_attempt(restart)
78
+ rescue ConfigError => e
79
+ refusals << e.message
80
+ nil
81
+ end
82
+ raise ConfigError, refusals.join("\n") if refusals.any?
83
+
84
+ attempts
85
+ end
86
+
87
+ # A run named for a restart is restarted on purpose: a changed task or
88
+ # bench is the operator's call, and a reverification may grade a scored run.
89
+ def restart_attempt(restart)
90
+ refuse = ->(reason) { raise ConfigError, "cannot restart #{restart.id}: #{reason}" }
91
+ task = tasks.find { it.name == restart.task }
92
+ refuse.("task #{restart.task} is not in the bench") unless task
93
+ refuse.("it is already scored (--allow-scored to restart it anyway)") if restart.scored? && restart_mode != :reverify && !allow_scored
94
+
95
+ case restart_mode
96
+ when :reverify
97
+ refuse.("it never reached a verification") unless restart.verified_step
98
+ when :recover
99
+ refuse.("#{restart.agent} cannot recover a session") unless Agents.lookup(restart.agent).recoverable?
100
+
101
+ history = task.multistep? ? "agent.result.#{restart.settled_steps + 1}.json" : "agent.result.json"
102
+ refuse.("it has no #{history} to recover from") unless store&.read_artifact(restart, history)
103
+ else
104
+ refuse.("no step was settled before it failed") if restart.settled_steps.zero?
105
+ end
106
+
107
+ Task.new(restart.model, task, store:, index: restart.index || 1, restart_from: restart, restart_mode:)
108
+ end
109
+
67
110
  def completed_attempts(task, model)
68
111
  completed_runs.count do |run|
69
112
  run.task == task.name &&
data/lib/lemans/store.rb CHANGED
@@ -46,5 +46,11 @@ module Lemans
46
46
  def read_artifact(result, path)
47
47
  raise NotImplementedError
48
48
  end
49
+
50
+ # Returns the sorted paths of the artifacts the result stored (the result
51
+ # record is not one of them)
52
+ def artifacts(result)
53
+ raise NotImplementedError
54
+ end
49
55
  end
50
56
  end
@@ -52,8 +52,8 @@ module Lemans
52
52
  end
53
53
 
54
54
  def delete(result)
55
- dir = root.glob("**/#{result.id}").find(&:directory?)
56
- return unless dir
55
+ dir = result_dir(result)
56
+ return unless dir.directory?
57
57
 
58
58
  FileUtils.remove_entry(dir.to_s)
59
59
  prune_empty_parents(dir.parent)
@@ -90,6 +90,13 @@ module Lemans
90
90
  file.read if file.file?
91
91
  end
92
92
 
93
+ def artifacts(result)
94
+ dir = result_dir(result)
95
+ return [] unless dir.directory?
96
+
97
+ (dir.glob("**/*").select(&:file?).map { it.relative_path_from(dir).to_s } - [ FILENAME ]).sort
98
+ end
99
+
93
100
  private
94
101
 
95
102
  def filtered(text) = filterer ? filterer.filter(text) : text
@@ -132,9 +139,14 @@ module Lemans
132
139
  tmp&.delete if tmp&.exist?
133
140
  end
134
141
 
135
- # result.json is stored at <root>/<model-short>/<result-id>
142
+ # result.json is stored at <root>/<model-short>/<result-id>; a tree
143
+ # arranged by hand (runs grouped under batch directories) is searched
144
+ # for the id instead.
136
145
  def result_dir(result)
137
- root.join((result.model || result.agent).to_s.split("/").last.tr("#", "-"), result.id)
146
+ canonical = root.join((result.model || result.agent).to_s.split("/").last.to_s.tr("#", "-"), result.id)
147
+ return canonical if canonical.directory?
148
+
149
+ root.glob("**/#{result.id}").find(&:directory?) || canonical
138
150
  end
139
151
  end
140
152
  end
@@ -61,6 +61,15 @@ module Lemans
61
61
  raise InfrastructureError, "could not savepoint the tree the step left behind" unless savepoint
62
62
  end
63
63
 
64
+ # Brings a fresh tree to where an earlier run left it: the settled steps'
65
+ # patches applied in order and marked as the savepoint, then the work of
66
+ # the step that run stopped in, on top of it, so collect! sees it as this step's.
67
+ def replay!(settled, unsettled = nil)
68
+ settled.each { apply!(it) }
69
+ savepoint!
70
+ apply!(unsettled) if unsettled
71
+ end
72
+
64
73
  # Puts the workdir back to the savepoint exactly: the verifier restored
65
74
  # the graded surfaces from the baseline and may have littered the tree,
66
75
  # and the next step's agent must find neither.
@@ -75,6 +84,18 @@ module Lemans
75
84
 
76
85
  private
77
86
 
87
+ def apply!(contents)
88
+ return if contents.empty?
89
+
90
+ Tempfile.create(%w[agent .patch]) do |file|
91
+ file.write(contents)
92
+ file.flush
93
+ environment.upload(file.path, REMOTE_PATCH)
94
+ end
95
+
96
+ environment.exec!("#{git} apply --binary --whitespace=nowarn #{REMOTE_PATCH} && rm -f #{REMOTE_PATCH}", timeout:)
97
+ end
98
+
78
99
  def save_diff(result, store, from, to, destination)
79
100
  diffed = environment.exec("#{git} diff --binary #{from} #{to} > #{REMOTE_PATCH}", timeout:)
80
101
  return unless diffed.success?
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "prism"
5
+
6
+ module Lemans
7
+ class Trial
8
+ class Verifier
9
+ # Reads the grading schema off the test files without running them: every
10
+ # `def test_*` and ActiveSupport `test "..."` is a check, an
11
+ # `allow_failure` call inside makes it an extra worth its `points:`, and
12
+ # a `# @feature <name>` comment right above a check or inside it tracks
13
+ # the check as that feature. Files required from the test file's
14
+ # directory are read too: suites often live apart.
15
+ class TestScanner < Prism::Visitor
16
+ ENTRY = "verification_test.rb"
17
+ FEATURE = /\A#\s*@feature\s+(\S+)/
18
+
19
+ Span = Data.define(:check, :file, :lines)
20
+
21
+ attr_reader :tests, :points, :features, :base_credit, :stray_features
22
+
23
+ # The task's verification_test.rb scanned, nil when it has none
24
+ def self.for(task)
25
+ local, = task.test_files.find { |_, remote| remote == ENTRY }
26
+ return unless local
27
+
28
+ new(Pathname(local).dirname).tap { it.scan(Pathname(local)) }
29
+ end
30
+
31
+ def initialize(root)
32
+ super()
33
+ @root = root
34
+ @scope = []
35
+ @tests = []
36
+ @points = {}
37
+ @features = {}
38
+ @stray_features = []
39
+ @spans = []
40
+ @current = nil
41
+ @scanned = []
42
+ end
43
+
44
+ def scan(path)
45
+ return if @scanned.include?(path)
46
+
47
+ @scanned << path
48
+ file = @file
49
+ begin
50
+ @file = path
51
+ parsed = Prism.parse_file(path.to_s)
52
+ parsed.value.accept(self)
53
+ attach_features(path, parsed.comments)
54
+ ensure
55
+ @file = file
56
+ end
57
+ end
58
+
59
+ def visit_module_node(node) = scoped(node) { super }
60
+
61
+ def visit_class_node(node) = scoped(node) { super }
62
+
63
+ def visit_def_node(node)
64
+ return super unless node.name.start_with?("test_")
65
+
66
+ within("#{@scope.join("::")}##{node.name}", node) { super }
67
+ end
68
+
69
+ def visit_call_node(node)
70
+ case node.name
71
+ when :test
72
+ title = node.arguments&.arguments&.first
73
+ if node.receiver.nil? && node.block && title.is_a?(Prism::StringNode)
74
+ return within("#{@scope.join("::")}#test_#{title.unescaped.gsub(/\s+/, "_")}", node) { super }
75
+ end
76
+ when :allow_failure
77
+ @points[@current] ||= points_of(node) if @current
78
+ when :require, :require_relative
79
+ required = node.arguments&.arguments&.first
80
+ scan_required(node.name, required.unescaped) if node.receiver.nil? && required.is_a?(Prism::StringNode)
81
+ return
82
+ when :base_credit=
83
+ @base_credit = node.arguments.arguments.first.value if node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :LemansReport
84
+ end
85
+ super
86
+ end
87
+
88
+ private
89
+
90
+ def scan_required(how, name)
91
+ path = (how == :require_relative ? @file.dirname : @root).join("#{name.delete_suffix(".rb")}.rb")
92
+ scan(path) if path.file?
93
+ end
94
+
95
+ def scoped(node)
96
+ @scope.push(node.constant_path.full_name)
97
+ yield
98
+ ensure
99
+ @scope.pop
100
+ end
101
+
102
+ def within(check, node)
103
+ @tests << check
104
+ @spans << Span.new(check:, file: @file, lines: node.location.start_line..node.location.end_line)
105
+ @current = check
106
+ yield
107
+ ensure
108
+ @current = nil
109
+ end
110
+
111
+ def points_of(node)
112
+ keywords = node.arguments&.arguments&.grep(Prism::KeywordHashNode)&.first
113
+ pair = keywords&.elements&.find { it.is_a?(Prism::AssocNode) && it.key.is_a?(Prism::SymbolNode) && it.key.unescaped == "points" }
114
+ pair ? pair.value.value : 1
115
+ end
116
+
117
+ # A comment belongs to the check it sits in, or to the one its
118
+ # comment block opens onto.
119
+ def attach_features(path, comments)
120
+ own_line = path.readlines.each_with_index.filter_map { |text, index| index + 1 if text.lstrip.start_with?("#") }
121
+ spans = @spans.select { it.file == path }
122
+
123
+ comments.each do |comment|
124
+ name = comment.slice[FEATURE, 1] or next
125
+ line = comment.location.start_line
126
+ span = spans.find { it.lines.cover?(line) } ||
127
+ spans.find { |s| s.lines.begin > line && (line...s.lines.begin).all? { own_line.include?(it) } }
128
+ next @stray_features << "#{path}:#{line}" unless span
129
+
130
+ (@features[name] ||= []) << span.check unless @features[name]&.include?(span.check)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end