shellfie 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -2
  3. data/README.md +258 -97
  4. data/lib/shellfie/animation_frame_builder.rb +31 -7
  5. data/lib/shellfie/animation_timeline.rb +2 -1
  6. data/lib/shellfie/ansi_line_buffer.rb +20 -4
  7. data/lib/shellfie/ansi_normalizer.rb +18 -8
  8. data/lib/shellfie/ansi_parser.rb +120 -7
  9. data/lib/shellfie/cassette.rb +76 -0
  10. data/lib/shellfie/cli.rb +65 -2
  11. data/lib/shellfie/cli_authoring.rb +233 -0
  12. data/lib/shellfie/cli_generate.rb +244 -40
  13. data/lib/shellfie/cli_info.rb +106 -6
  14. data/lib/shellfie/cli_run.rb +167 -0
  15. data/lib/shellfie/config.rb +5 -1
  16. data/lib/shellfie/config_defaults.rb +21 -2
  17. data/lib/shellfie/config_validation.rb +93 -4
  18. data/lib/shellfie/dependency_checker.rb +74 -3
  19. data/lib/shellfie/errors.rb +1 -0
  20. data/lib/shellfie/ffmpeg_encoder.rb +46 -0
  21. data/lib/shellfie/font_resolver.rb +11 -1
  22. data/lib/shellfie/gif_generator.rb +157 -11
  23. data/lib/shellfie/gif_palette.rb +8 -4
  24. data/lib/shellfie/html_renderer.rb +54 -0
  25. data/lib/shellfie/line_layout.rb +19 -10
  26. data/lib/shellfie/output_writer.rb +7 -2
  27. data/lib/shellfie/parser.rb +82 -19
  28. data/lib/shellfie/parser_validation.rb +48 -15
  29. data/lib/shellfie/render_geometry.rb +2 -1
  30. data/lib/shellfie/render_segment.rb +17 -5
  31. data/lib/shellfie/renderer.rb +28 -11
  32. data/lib/shellfie/rendering/text_painter.rb +23 -15
  33. data/lib/shellfie/rendering/window_chrome.rb +2 -2
  34. data/lib/shellfie/reproducibility_manifest.rb +41 -0
  35. data/lib/shellfie/session.rb +111 -0
  36. data/lib/shellfie/session_config.rb +562 -0
  37. data/lib/shellfie/session_runner.rb +689 -0
  38. data/lib/shellfie/svg_renderer.rb +222 -0
  39. data/lib/shellfie/terminal_screen.rb +389 -0
  40. data/lib/shellfie/text_metrics.rb +74 -15
  41. data/lib/shellfie/transcript_renderer.rb +92 -0
  42. data/lib/shellfie/version.rb +1 -1
  43. data/lib/shellfie/yaml_safety.rb +147 -0
  44. data/lib/shellfie.rb +8 -1
  45. data/schema/shellfie-v1.schema.json +153 -0
  46. data/schema/shellfie-v2.schema.json +262 -0
  47. metadata +27 -24
  48. data/.rspec +0 -3
  49. data/Rakefile +0 -8
  50. data/docs/.nojekyll +0 -0
  51. data/docs/index.html +0 -205
  52. data/docs/scripts.js +0 -85
  53. data/docs/styles.css +0 -507
  54. data/examples/animation.yml +0 -33
  55. data/examples/colored.yml +0 -20
  56. data/examples/demo.gif +0 -0
  57. data/examples/demo.png +0 -0
  58. data/examples/demo_animation.yml +0 -31
  59. data/examples/headless.png +0 -0
  60. data/examples/headless.yml +0 -16
  61. data/examples/scrolling.yml +0 -48
  62. data/examples/simple.yml +0 -21
  63. data/examples/theme_macos.png +0 -0
  64. data/examples/theme_ubuntu.png +0 -0
  65. data/examples/theme_windows.png +0 -0
  66. data/shellfie.gemspec +0 -32
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "parser"
5
+ require_relative "terminal_screen"
6
+
7
+ module Shellfie
8
+ class Session
9
+ attr_reader :events, :captures, :exit_status, :screen
10
+
11
+ def initialize(columns:, rows:, title: "Terminal Session", events: [], captures: {}, exit_status: nil)
12
+ unless columns.is_a?(Integer) && columns.between?(1, 500) && rows.is_a?(Integer) && rows.between?(1, 200)
13
+ raise ParseError, "Invalid session dimensions"
14
+ end
15
+
16
+ @title = title
17
+ @events = events
18
+ @captures = captures
19
+ @exit_status = exit_status
20
+ @screen = TerminalScreen.new(columns: columns, rows: rows)
21
+ events.each { |event| @screen.feed(event[:text].to_s) if event.fetch(:visible, true) }
22
+ end
23
+
24
+ def record(text, delay: 0, visible: true, status: nil)
25
+ screen.feed(text) if visible
26
+ event = { text: visible ? text : "", delay: delay, visible: visible, status: status }
27
+ events << event
28
+ @exit_status = status unless status.nil?
29
+ event
30
+ end
31
+
32
+ def capture(name)
33
+ captures[name] = screen.render_lines
34
+ end
35
+
36
+ def render_config(theme:, options: {}, animated: false, lines: nil)
37
+ base = {
38
+ theme: theme,
39
+ title: @title,
40
+ window: options.fetch(:window, {}),
41
+ font: options.fetch(:font, {}),
42
+ animation: options.fetch(:animation, {}),
43
+ headless: options.fetch(:headless, false)
44
+ }
45
+ if animated
46
+ raise ValidationError, "Captured screens cannot be rendered as animations" if lines
47
+
48
+ frames = []
49
+ each_snapshot do |event, snapshot|
50
+ frame = Frame.new(screen: snapshot, delay: [(event[:delay].to_f * 1_000).round, 1].max)
51
+ frames << frame
52
+ end
53
+ Config.new(**base, frames: frames)
54
+ else
55
+ Config.new(**base, lines: (lines || screen.render_lines).map { |line| Line.new(output: line) })
56
+ end
57
+ end
58
+
59
+ def compose_hash
60
+ frames = []
61
+ each_snapshot do |event, snapshot|
62
+ frames << {
63
+ "screen" => snapshot,
64
+ "delay" => [(event[:delay].to_f * 1_000).round, 1].max
65
+ }
66
+ end
67
+ {
68
+ "version" => 1,
69
+ "title" => @title,
70
+ "window" => { "width" => screen.columns * 8, "visible_lines" => screen.rows },
71
+ "frames" => frames
72
+ }
73
+ end
74
+
75
+ def to_h
76
+ {
77
+ version: 1,
78
+ title: @title,
79
+ columns: screen.columns,
80
+ rows: screen.rows,
81
+ events: events,
82
+ captures: captures,
83
+ exit_status: exit_status
84
+ }
85
+ end
86
+
87
+ private
88
+
89
+ def each_snapshot
90
+ replay = TerminalScreen.new(columns: screen.columns, rows: screen.rows)
91
+ count = characters = 0
92
+ events.each do |event|
93
+ next unless event[:visible]
94
+
95
+ text = event[:text].to_s
96
+ next if text.empty? && !event[:delay].to_f.positive?
97
+
98
+ count += 1
99
+ raise ResourceLimitError, "Too many session frames (max #{Config::DEFAULTS[:limits][:max_frames]})" if count > Config::DEFAULTS[:limits][:max_frames]
100
+
101
+ replay.feed(text) unless text.empty?
102
+ snapshot = event[:screen] || replay.render_lines
103
+ characters += snapshot.sum(&:length)
104
+ if characters > Config::DEFAULTS[:limits][:max_characters]
105
+ raise ResourceLimitError, "Session snapshots are too large (max #{Config::DEFAULTS[:limits][:max_characters]} characters)"
106
+ end
107
+ yield event, snapshot
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,562 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "did_you_mean"
5
+ require "rbconfig"
6
+ require "rubygems/requirement"
7
+ require_relative "config"
8
+ require_relative "errors"
9
+ require_relative "parser_validation"
10
+ require_relative "yaml_safety"
11
+
12
+ module Shellfie
13
+ class SessionConfig
14
+ MAX_BYTES = 1_048_576
15
+ MAX_DURATION = 86_400
16
+ MAX_COUNT = 10_000
17
+ MAX_CAPTURES = 100
18
+ MAX_PATTERN_LENGTH = 512
19
+ MAX_INCLUDE_FILES = 100
20
+ MAX_TOTAL_BYTES = 10 * MAX_BYTES
21
+ ACTIONS = %i[run type key sleep wait expect capture hide show].freeze
22
+ STEP_OPTION_KEYS = {
23
+ run: %i[visibility timeout async cwd], type: %i[speed], key: %i[count async timeout delay],
24
+ sleep: [], wait: %i[timeout], expect: [], capture: [], hide: [], show: []
25
+ }.freeze
26
+ TOP_LEVEL_KEYS = %i[version mode title theme terminal requires steps outputs render redact vars step_sets].freeze
27
+ TERMINAL_KEYS = %i[shell columns rows cwd cwd_policy env env_allowlist timeout total_timeout prompt].freeze
28
+ OUTPUT_KEYS = %i[path format animate scale shadow transparent capture].freeze
29
+ OUTPUT_FORMATS = %w[png gif svg svg-raster webp apng mp4 webm png-sequence html txt ansi json asciicast cast].freeze
30
+ RENDER_KEYS = %i[window font animation headless].freeze
31
+
32
+ attr_reader :path, :source_paths, :mode, :title, :theme, :terminal, :requires, :steps, :outputs, :render, :redactions
33
+
34
+ def self.parse(path)
35
+ source_path = File.realpath(path)
36
+ state = { files: 0, bytes: 0 }
37
+ raw, documents, provenance = load_included(source_path, root: File.dirname(source_path), stack: [], state: state)
38
+ new(raw, path: source_path, source_paths: documents.map(&:first).uniq)
39
+ rescue ValidationError => e
40
+ raise YamlSafety.annotate_validation_error(e, documents || [], provenance: provenance || {})
41
+ rescue Psych::Exception => e
42
+ raise ParseError, "Invalid session YAML syntax: #{e.message}"
43
+ rescue Errno::ENOENT
44
+ raise ParseError, "Session file not found: #{path}"
45
+ end
46
+
47
+ def self.load_included(path, root:, stack:, state:, policy: nil)
48
+ raise ParseError, "Circular session include: #{(stack + [path]).map { |item| File.basename(item) }.join(' -> ')}" if stack.include?(path)
49
+
50
+ content = YamlSafety.read_file(path, max_bytes: MAX_BYTES, label: "Session")
51
+ state[:files] += 1
52
+ state[:bytes] += content.bytesize
53
+ raise ParseError, "Too many session includes (max #{MAX_INCLUDE_FILES})" if state[:files] > MAX_INCLUDE_FILES
54
+ raise ParseError, "Included sessions are too large in total (max #{MAX_TOTAL_BYTES} bytes)" if state[:bytes] > MAX_TOTAL_BYTES
55
+
56
+ raw = YAML.safe_load(content, symbolize_names: true, aliases: true)
57
+ YamlSafety.validate_tree!(raw)
58
+ raise ParseError, "Included session must be a YAML mapping: #{path}" unless raw.is_a?(Hash)
59
+ own_provenance = provenance_for(raw, path)
60
+ return [raw, [[path, content]], own_provenance] unless raw[:include]
61
+
62
+ declared_policy = raw[:include_policy]
63
+ if raw.key?(:include_policy) && !%w[allow root].include?(declared_policy)
64
+ raise ParseError, "include_policy must be allow or root"
65
+ end
66
+ policy = "root" if policy == "root" || declared_policy == "root"
67
+ policy ||= declared_policy || "allow"
68
+
69
+ merged = {}
70
+ provenance = {}
71
+ documents = [[path, content]]
72
+ Array(raw[:include]).each do |included|
73
+ raise ParseError, "Included session path must be a string" unless included.is_a?(String)
74
+
75
+ included_path = File.realpath(File.expand_path(included, File.dirname(path)))
76
+ if policy == "root" && included_path != root && !included_path.start_with?("#{root}#{File::SEPARATOR}")
77
+ raise ParseError, "Included session escapes the session root: #{included}"
78
+ end
79
+ value, nested_documents, nested_provenance = load_included(
80
+ included_path, root: root, stack: stack + [path], state: state, policy: policy
81
+ )
82
+ merged, provenance = merge_included(merged, value, provenance, nested_provenance)
83
+ documents.concat(nested_documents)
84
+ end
85
+ own = raw.reject { |key, _value| %i[include include_policy].include?(key) }
86
+ own_provenance.reject! { |key, _value| %i[include include_policy].include?(key.first) }
87
+ merged, provenance = merge_included(merged, own, provenance, own_provenance)
88
+ [merged, documents, provenance]
89
+ rescue Errno::ENOENT
90
+ raise ParseError, "Included session file not found from #{path}"
91
+ end
92
+
93
+ def self.merge_included(base, overrides, base_provenance = {}, override_provenance = {}, prefix = [])
94
+ merged = base.dup
95
+ provenance = base_provenance.dup
96
+ overrides.each do |key, right|
97
+ left = base[key]
98
+ target = prefix + [key]
99
+ if %i[steps requires outputs redact].include?(key)
100
+ offset = Array(left).size
101
+ merged[key] = Array(left) + Array(right)
102
+ copy_provenance!(provenance, override_provenance, target) do |path|
103
+ path.size > target.size && path[target.size].is_a?(Integer) ? target + [path[target.size] + offset] + path[(target.size + 1)..] : path
104
+ end
105
+ elsif left.is_a?(Hash) && right.is_a?(Hash)
106
+ merged[key], provenance = merge_included(left, right, provenance, override_provenance, target)
107
+ else
108
+ merged[key] = right
109
+ provenance.delete_if { |path, _value| path[0, target.size] == target }
110
+ copy_provenance!(provenance, override_provenance, target)
111
+ end
112
+ end
113
+ [merged, provenance]
114
+ end
115
+
116
+ def self.provenance_for(value, source, path = [], result = {})
117
+ result[path] = [source, path]
118
+ case value
119
+ when Hash
120
+ value.each { |key, nested| provenance_for(nested, source, path + [key], result) }
121
+ when Array
122
+ value.each_with_index { |nested, index| provenance_for(nested, source, path + [index], result) }
123
+ end
124
+ result
125
+ end
126
+
127
+ def self.copy_provenance!(target_map, source_map, prefix)
128
+ source_map.each do |path, source|
129
+ next unless path[0, prefix.size] == prefix
130
+
131
+ target_map[block_given? ? yield(path) : path] = source
132
+ end
133
+ end
134
+
135
+ private_class_method :load_included, :merge_included, :provenance_for, :copy_provenance!
136
+
137
+ def initialize(raw, path: nil, source_paths: nil)
138
+ raise ValidationError, "Session configuration must be a YAML mapping" unless raw.is_a?(Hash)
139
+
140
+ unknown = raw.keys - TOP_LEVEL_KEYS
141
+ raise_unknown_keys!(unknown, TOP_LEVEL_KEYS, "session")
142
+ variables = validate_variables(raw[:vars] || {})
143
+ raw = interpolate_variables(raw.reject { |key, _value| key == :vars }, variables)
144
+ raise ValidationError, "Session config version must be 2" unless raw[:version] == 2
145
+ raise ValidationError, "Session config must contain steps" unless raw.key?(:steps)
146
+ raise ValidationError, "Session title must be a string" if raw.key?(:title) && !raw[:title].is_a?(String)
147
+
148
+ @path = path
149
+ @source_paths = Array(source_paths || path).compact.freeze
150
+ @mode = (raw[:mode] || "run").to_s
151
+ @title = (raw[:title] || "Terminal Session").to_s
152
+ @theme = (raw[:theme] || "macos").to_s
153
+ @terminal = defaults.merge(symbolize_hash(raw[:terminal] || {}))
154
+ @requires = Array(raw[:requires])
155
+ raise ValidationError, "steps must be an array" unless raw[:steps].is_a?(Array)
156
+ @steps = expand_steps(raw[:steps], validate_step_sets(raw[:step_sets] || {}))
157
+ @outputs = Array(raw[:outputs]).map { |output| symbolize_hash(output) }
158
+ @render = symbolize_hash(raw[:render] || {})
159
+ %i[window font animation].each do |key|
160
+ @render[key] = symbolize_hash(@render[key]) if @render[key].is_a?(Hash)
161
+ end
162
+ @redactions = Array(raw[:redact])
163
+ validate!
164
+ end
165
+
166
+ def base_dir
167
+ path ? File.dirname(path) : Dir.pwd
168
+ end
169
+
170
+ def to_h
171
+ {
172
+ version: 2,
173
+ mode: mode,
174
+ title: title,
175
+ theme: theme,
176
+ terminal: terminal,
177
+ requires: requires,
178
+ steps: steps,
179
+ outputs: outputs,
180
+ render: render,
181
+ redact: redactions
182
+ }
183
+ end
184
+
185
+ private
186
+
187
+ def defaults
188
+ {
189
+ shell: ENV.fetch("SHELL", "/bin/sh"),
190
+ columns: 100,
191
+ rows: 28,
192
+ cwd: ".",
193
+ cwd_policy: "allow",
194
+ env: {},
195
+ env_allowlist: nil,
196
+ timeout: 30,
197
+ total_timeout: nil,
198
+ prompt: "$ "
199
+ }
200
+ end
201
+
202
+ def symbolize_hash(value)
203
+ raise ValidationError, "Expected a mapping, got #{value.class}" unless value.is_a?(Hash)
204
+
205
+ value.each_with_object({}) do |(key, nested), result|
206
+ raise ValidationError, "Mapping keys must be strings or symbols" unless key.respond_to?(:to_sym)
207
+
208
+ result[key.to_sym] = nested
209
+ end
210
+ end
211
+
212
+ def validate_variables(value)
213
+ raise ValidationError, "vars must be a mapping" unless value.is_a?(Hash)
214
+ raise ValidationError, "vars may contain at most 100 entries" if value.size > 100
215
+
216
+ value.to_h do |name, nested|
217
+ key = name.to_s
218
+ raise ValidationError, "Variable names must use letters, numbers, and underscores" unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
219
+ unless nested.is_a?(String) || nested.is_a?(Numeric) || [true, false, nil].include?(nested)
220
+ raise ValidationError, "vars.#{key} must be a scalar"
221
+ end
222
+ raise ValidationError, "vars.#{key} is too large" if nested.to_s.bytesize > 4_096
223
+
224
+ [key, nested]
225
+ end
226
+ end
227
+
228
+ def interpolate_variables(value, variables)
229
+ case value
230
+ when Hash
231
+ value.to_h { |key, nested| [key, interpolate_variables(nested, variables)] }
232
+ when Array
233
+ value.map { |nested| interpolate_variables(nested, variables) }
234
+ when String
235
+ if (match = /\A\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}\z/.match(value))
236
+ return variable_value(match[1], variables)
237
+ end
238
+ value.gsub(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/) { variable_value(Regexp.last_match(1), variables).to_s }
239
+ else
240
+ value
241
+ end
242
+ end
243
+
244
+ def variable_value(name, variables)
245
+ raise ValidationError, "Undefined variable: #{name}" unless variables.key?(name)
246
+
247
+ variables[name]
248
+ end
249
+
250
+ def validate_step_sets(value)
251
+ raise ValidationError, "step_sets must be a mapping" unless value.is_a?(Hash)
252
+ raise ValidationError, "step_sets may contain at most 100 entries" if value.size > 100
253
+
254
+ value.to_h do |name, entries|
255
+ key = name.to_s
256
+ raise ValidationError, "Step set names must use letters, numbers, and underscores" unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
257
+ raise ValidationError, "step_sets.#{key} must be an array" unless entries.is_a?(Array)
258
+
259
+ [key, entries]
260
+ end
261
+ end
262
+
263
+ def expand_steps(entries, sets, stack = [])
264
+ entries.each_with_object([]) do |entry, result|
265
+ step = normalize_step(entry)
266
+ condition = step.is_a?(Hash) && step.delete(:if)
267
+ next unless condition.nil? || condition_matches?(condition)
268
+
269
+ repeat = step.is_a?(Hash) ? step.delete(:repeat) || 1 : 1
270
+ unless repeat.is_a?(Integer) && repeat.between?(1, MAX_COUNT)
271
+ raise ValidationError, "step repeat must be between 1 and #{MAX_COUNT}"
272
+ end
273
+
274
+ expanded = if step.is_a?(Hash) && step.key?(:use)
275
+ raise ValidationError, "A reusable step may contain only use and repeat" unless step.keys == [:use]
276
+ name = step[:use].to_s
277
+ raise ValidationError, "Unknown step set: #{name}" unless sets.key?(name)
278
+ raise ValidationError, "Circular step set: #{(stack + [name]).join(' -> ')}" if stack.include?(name)
279
+
280
+ expand_steps(sets[name], sets, stack + [name])
281
+ else
282
+ [step]
283
+ end
284
+ repeat.times { result.concat(Config.deep_dup(expanded)) }
285
+ raise ValidationError, "Session has too many expanded steps (max 10,000)" if result.size > 10_000
286
+ end
287
+ end
288
+
289
+ def condition_matches?(value)
290
+ condition = symbolize_hash(value)
291
+ validate_mapping_keys!(condition, %i[os shell ruby env], "step.if")
292
+ raise ValidationError, "step.if must contain a condition" if condition.empty?
293
+
294
+ matches = []
295
+ if condition.key?(:os)
296
+ systems = Array(condition[:os]).map(&:to_s)
297
+ raise ValidationError, "step.if.os must be macos, linux, or windows" unless (systems - %w[macos linux windows]).empty?
298
+ matches << systems.include?(host_os)
299
+ end
300
+ if condition.key?(:shell)
301
+ raise ValidationError, "step.if.shell must be a string" unless condition[:shell].is_a?(String)
302
+ matches << File.basename(terminal[:shell]) == condition[:shell]
303
+ end
304
+ if condition.key?(:ruby)
305
+ raise ValidationError, "step.if.ruby must be a requirement string" unless condition[:ruby].is_a?(String)
306
+ matches << Gem::Requirement.new(condition[:ruby]).satisfied_by?(Gem::Version.new(RUBY_VERSION))
307
+ end
308
+ if condition.key?(:env)
309
+ raise ValidationError, "step.if.env must be a mapping" unless condition[:env].is_a?(Hash)
310
+ configured = terminal[:env].transform_keys(&:to_s)
311
+ matches << condition[:env].all? { |name, expected| configured[name.to_s] == expected }
312
+ end
313
+ matches.all?
314
+ rescue Gem::Requirement::BadRequirementError => e
315
+ raise ValidationError, "Invalid step.if.ruby requirement: #{e.message}"
316
+ end
317
+
318
+ def host_os
319
+ value = RbConfig::CONFIG["host_os"]
320
+ return "windows" if value.match?(/mswin|mingw|cygwin/)
321
+ return "macos" if value.include?("darwin")
322
+
323
+ "linux"
324
+ end
325
+
326
+ def normalize_step(step)
327
+ return { step.to_sym => true } if step.is_a?(String) && %w[hide show].include?(step)
328
+
329
+ symbolize_hash(step)
330
+ end
331
+
332
+ def validate!
333
+ raise ValidationError, "mode must be run or replay" unless %w[run replay].include?(mode)
334
+ validate_mapping_keys!(terminal, TERMINAL_KEYS, "terminal")
335
+ raise ValidationError, "terminal.shell must be a string" unless terminal[:shell].is_a?(String)
336
+ %i[columns rows].each do |key|
337
+ raise ValidationError, "terminal.#{key} must be a positive integer" unless terminal[key].is_a?(Integer) && terminal[key].positive?
338
+ end
339
+ raise ValidationError, "terminal.columns must be at most 500" if terminal[:columns] > 500
340
+ raise ValidationError, "terminal.rows must be at most 200" if terminal[:rows] > 200
341
+ raise ValidationError, "terminal.cwd must be a string" unless terminal[:cwd].is_a?(String)
342
+ raise ValidationError, "terminal.cwd_policy must be allow or root" unless %w[allow root].include?(terminal[:cwd_policy])
343
+ raise ValidationError, "terminal.prompt must be a string" unless terminal[:prompt].is_a?(String)
344
+ raise ValidationError, "terminal.prompt must not be blank" if terminal[:prompt].strip.empty?
345
+ raise ValidationError, "terminal.env must be a mapping" unless terminal[:env].is_a?(Hash)
346
+ unless terminal[:env].all? { |key, value| key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) && (value.nil? || value.is_a?(String)) }
347
+ raise ValidationError, "terminal.env keys must be names and values must be strings or null"
348
+ end
349
+ raise ValidationError, "terminal.env.PS1 is managed by terminal.prompt" if terminal[:env].keys.any? { |key| key.to_s == "PS1" }
350
+ unless terminal[:env_allowlist].nil?
351
+ unless terminal[:env_allowlist].is_a?(Array) && terminal[:env_allowlist].all? { |name| name.is_a?(String) && name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) }
352
+ raise ValidationError, "terminal.env_allowlist must contain environment variable names"
353
+ end
354
+ raise ValidationError, "terminal.env_allowlist must not contain duplicates" unless terminal[:env_allowlist].uniq.size == terminal[:env_allowlist].size
355
+ disallowed = terminal[:env].keys.map(&:to_s) - terminal[:env_allowlist]
356
+ raise ValidationError, "terminal.env contains variables outside env_allowlist: #{disallowed.join(', ')}" unless disallowed.empty?
357
+ end
358
+ raise ValidationError, "terminal.timeout must be positive" unless duration(terminal[:timeout]).positive?
359
+ duration(terminal[:total_timeout]) unless terminal[:total_timeout].nil?
360
+ raise ValidationError, "requires must contain command names" unless requires.all? { |item| item.is_a?(String) && item.match?(/\A[\w.+-]+\z/) }
361
+ raise ValidationError, "Session has too many steps (max 10,000)" if steps.size > 10_000
362
+
363
+ steps.each_with_index { |step, index| validate_step!(step, index) }
364
+ expanded_events = steps.sum do |step|
365
+ action = (step.keys & ACTIONS).first
366
+ case action
367
+ when :run
368
+ step.fetch(:visibility, step[:async] ? "visible" : "hidden") == "visible" ? 1 : 0
369
+ when :type then 1
370
+ when :key then step[:delay] ? Integer(step[:count] || 1) : 1
371
+ when :wait then step[:wait].is_a?(Hash) && (step[:wait][:exit] || step[:wait]["exit"]) ? 1 : 0
372
+ else 0
373
+ end
374
+ end
375
+ max_events = Config::DEFAULTS[:limits][:max_frames]
376
+ raise ValidationError, "Session expands to too many events (max #{max_events})" if expanded_events > max_events
377
+ capture_names = steps.filter_map { |step| step[:capture] }
378
+ raise ValidationError, "Too many captures (max #{MAX_CAPTURES})" if capture_names.size > MAX_CAPTURES
379
+ raise ValidationError, "Capture names must be unique" unless capture_names.uniq.size == capture_names.size
380
+ outputs.each_with_index do |output, index|
381
+ validate_mapping_keys!(output, OUTPUT_KEYS, "outputs[#{index}]")
382
+ raise ValidationError, "outputs[#{index}].path must be a string" unless output[:path].is_a?(String)
383
+ if output.key?(:capture) && !output[:capture].is_a?(String)
384
+ raise ValidationError, "outputs[#{index}].capture must be a string"
385
+ end
386
+ if output[:capture] && !capture_names.include?(output[:capture])
387
+ raise ValidationError, "outputs[#{index}] references unknown capture: #{output[:capture]}"
388
+ end
389
+ if output[:format] && !OUTPUT_FORMATS.include?(output[:format].to_s)
390
+ raise ValidationError, "outputs[#{index}].format is unsupported"
391
+ end
392
+ if output[:scale] && (!output[:scale].is_a?(Integer) || !output[:scale].between?(1, 3))
393
+ raise ValidationError, "outputs[#{index}].scale must be between 1 and 3"
394
+ end
395
+ %i[animate shadow transparent].each do |key|
396
+ if output.key?(key) && ![true, false].include?(output[key])
397
+ raise ValidationError, "outputs[#{index}].#{key} must be true or false"
398
+ end
399
+ end
400
+ end
401
+ raise ValidationError, "Too many redaction patterns (max 100)" if redactions.size > 100
402
+ if redactions.any? { |pattern| pattern.to_s.length > MAX_PATTERN_LENGTH }
403
+ raise ValidationError, "Redaction patterns must be at most #{MAX_PATTERN_LENGTH} characters"
404
+ end
405
+ raise ValidationError, "Redaction patterns must be strings" unless redactions.all?(String)
406
+ redactions.each { |pattern| Regexp.new(pattern) }
407
+ validate_mapping_keys!(render, RENDER_KEYS, "render")
408
+ %i[window font animation].each do |key|
409
+ raise ValidationError, "render.#{key} must be a mapping" if render.key?(key) && !render[key].is_a?(Hash)
410
+ end
411
+ {
412
+ window: ParserValidation::WINDOW_KEYS,
413
+ font: ParserValidation::FONT_KEYS,
414
+ animation: ParserValidation::ANIMATION_KEYS
415
+ }.each do |key, allowed|
416
+ validate_mapping_keys!(render[key], allowed, "render.#{key}") if render[key]
417
+ end
418
+ if render.key?(:headless) && ![true, false].include?(render[:headless])
419
+ raise ValidationError, "render.headless must be true or false"
420
+ end
421
+ Config.new(
422
+ theme: theme,
423
+ window: render[:window] || {},
424
+ font: render[:font] || {},
425
+ animation: render[:animation] || {},
426
+ headless: render.fetch(:headless, false)
427
+ )
428
+ rescue RegexpError => e
429
+ raise ValidationError, "Invalid redaction pattern: #{e.message}"
430
+ end
431
+
432
+ def validate_step!(step, index)
433
+ actions = step.keys & ACTIONS
434
+ raise ValidationError, "steps[#{index}] must contain exactly one action" unless actions.size == 1
435
+
436
+ allowed = actions + STEP_OPTION_KEYS.fetch(actions.first)
437
+ validate_mapping_keys!(step, allowed, "steps[#{index}]")
438
+ action = actions.first
439
+ value = step[action]
440
+ if %i[run type key capture].include?(action) && !value.is_a?(String)
441
+ raise ValidationError, "steps[#{index}].#{action} must be a string"
442
+ end
443
+ if %i[wait expect].include?(action) && !value.is_a?(Hash) && !value.is_a?(String)
444
+ raise ValidationError, "steps[#{index}].#{action} must be a string or mapping"
445
+ end
446
+ duration(step[:timeout]) if step.key?(:timeout)
447
+ duration(step[:delay]) if step.key?(:delay)
448
+ duration(value) if action == :sleep
449
+ if step.key?(:visibility) && !%w[visible hidden].include?(step[:visibility])
450
+ raise ValidationError, "steps[#{index}].visibility must be visible or hidden"
451
+ end
452
+ if step.key?(:count) && (!step[:count].is_a?(Integer) || !step[:count].between?(1, MAX_COUNT))
453
+ raise ValidationError, "steps[#{index}].count must be between 1 and #{MAX_COUNT}"
454
+ end
455
+ if step.key?(:async) && ![true, false].include?(step[:async])
456
+ raise ValidationError, "steps[#{index}].async must be true or false"
457
+ end
458
+ if step.key?(:cwd) && !step[:cwd].is_a?(String)
459
+ raise ValidationError, "steps[#{index}].cwd must be a string"
460
+ end
461
+ if action == :run && step[:async] && step.fetch(:visibility, "visible") == "hidden"
462
+ raise ValidationError, "steps[#{index}] cannot hide an asynchronous run"
463
+ end
464
+ if %i[hide show].include?(action) && value != true
465
+ raise ValidationError, "steps[#{index}].#{action} must be true"
466
+ end
467
+ if step.key?(:speed)
468
+ speed_text = step[:speed].to_s
469
+ speed = /\A(\d+(?:\.\d+)?)cps\z/.match(speed_text)&.[](1)&.to_f if speed_text.bytesize <= 32
470
+ raise ValidationError, "steps[#{index}].speed must be between 1cps and 1000cps" unless speed&.between?(1, 1_000)
471
+ end
472
+ validate_wait!(value, index) if action == :wait && value.is_a?(Hash)
473
+ validate_pattern_size!(value, "steps[#{index}].wait") if action == :wait && value.is_a?(String)
474
+ validate_expect!(value, index) if action == :expect && value.is_a?(Hash)
475
+ end
476
+
477
+ def validate_wait!(value, index)
478
+ condition = symbolize_hash(value)
479
+ validate_mapping_keys!(condition, %i[screen line prompt stable exit timeout], "steps[#{index}].wait")
480
+ predicates = condition.keys & %i[screen line prompt stable exit]
481
+ raise ValidationError, "steps[#{index}].wait must contain one condition" unless predicates.size == 1
482
+
483
+ duration(condition[:stable]) if condition.key?(:stable)
484
+ duration(condition[:timeout]) if condition.key?(:timeout)
485
+ if condition.key?(:exit) && condition[:exit] != true
486
+ raise ValidationError, "steps[#{index}].wait.exit must be true"
487
+ end
488
+ if condition.key?(:prompt) && condition[:prompt] != true
489
+ raise ValidationError, "steps[#{index}].wait.prompt must be true"
490
+ end
491
+ validate_pattern_size!(condition[:screen] || condition[:line], "steps[#{index}].wait")
492
+ %i[screen line].each do |key|
493
+ if condition.key?(key) && !condition[key].is_a?(String)
494
+ raise ValidationError, "steps[#{index}].wait.#{key} must be a string"
495
+ end
496
+ end
497
+ end
498
+
499
+ def validate_expect!(value, index)
500
+ condition = symbolize_hash(value)
501
+ validate_mapping_keys!(condition, %i[screen_contains screen line exit_status cursor_row cursor_column golden elapsed_under elapsed_over], "steps[#{index}].expect")
502
+ raise ValidationError, "steps[#{index}].expect must contain a condition" if condition.empty?
503
+ if condition.key?(:exit_status) && (!condition[:exit_status].is_a?(Integer) || !condition[:exit_status].between?(0, 255))
504
+ raise ValidationError, "steps[#{index}].expect.exit_status must be between 0 and 255"
505
+ end
506
+ %i[cursor_row cursor_column].each do |key|
507
+ next unless condition.key?(key)
508
+ unless condition[key].is_a?(Integer) && condition[key] >= 0
509
+ raise ValidationError, "steps[#{index}].expect.#{key} must be a non-negative integer"
510
+ end
511
+ end
512
+ validate_pattern_size!(condition[:screen], "steps[#{index}].expect")
513
+ validate_pattern_size!(condition[:line], "steps[#{index}].expect")
514
+ %i[screen line screen_contains].each do |key|
515
+ if condition.key?(key) && !condition[key].is_a?(String)
516
+ raise ValidationError, "steps[#{index}].expect.#{key} must be a string"
517
+ end
518
+ end
519
+ if condition.key?(:golden) && !condition[:golden].is_a?(String)
520
+ raise ValidationError, "steps[#{index}].expect.golden must be a string"
521
+ end
522
+ %i[elapsed_under elapsed_over].each { |key| duration(condition[key]) if condition.key?(key) }
523
+ end
524
+
525
+ def validate_mapping_keys!(hash, allowed, context)
526
+ unknown = hash.keys - allowed
527
+ raise_unknown_keys!(unknown, allowed, context)
528
+ end
529
+
530
+ def raise_unknown_keys!(unknown, allowed, context)
531
+ return if unknown.empty?
532
+
533
+ suggestions = unknown.filter_map do |key|
534
+ match = DidYouMean::SpellChecker.new(dictionary: allowed.map(&:to_s)).correct(key.to_s).first
535
+ "#{key} -> #{match}" if match
536
+ end
537
+ hint = suggestions.empty? ? "" : " (did you mean #{suggestions.join(", ")}?)"
538
+ raise ValidationError, "Unknown #{context} key(s): #{unknown.join(", ")}#{hint}"
539
+ end
540
+
541
+ def duration(value)
542
+ match = /\A(\d+(?:\.\d+)?)(ms|s)?\z/.match(value.to_s)
543
+ raise ValidationError, "Invalid duration: #{value}" unless match
544
+ if match[1].bytesize > 32
545
+ raise ValidationError, "Duration must be finite and at most #{MAX_DURATION}s"
546
+ end
547
+
548
+ seconds = match[1].to_f
549
+ seconds /= 1_000 if match[2] == "ms"
550
+ unless seconds.finite? && seconds.positive? && seconds <= MAX_DURATION
551
+ raise ValidationError, "Duration must be greater than 0 and at most #{MAX_DURATION}s"
552
+ end
553
+ seconds
554
+ end
555
+
556
+ def validate_pattern_size!(pattern, context)
557
+ return unless pattern && pattern.to_s.length > MAX_PATTERN_LENGTH
558
+
559
+ raise ValidationError, "#{context} pattern must be at most #{MAX_PATTERN_LENGTH} characters"
560
+ end
561
+ end
562
+ end