howzit 2.1.44 → 2.1.46

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: 881188642b99f4be83a7697440251ec8890e6d847468c3984a72183939196857
4
- data.tar.gz: c622b23318a075aa277a0a197e1ec93d8023e52e99018557e7eb6f8309c76927
3
+ metadata.gz: daae85713f05d780640619a8ada2291aaa69c0eb3f2d941f6fb9e2964c9247b4
4
+ data.tar.gz: 5773440b0a9718af5dd0b3e66929bfa74023061b99a85c61d65558b76ac57d4d
5
5
  SHA512:
6
- metadata.gz: 219ae789410d1ec44619f3bfb00b7f8b539a33632e9b9d9e778f384205e03c653efa83a7a090d469dd769a2cdda53ae7026e40b495363879e59f24be38da09ae
7
- data.tar.gz: d0c1dd2d03ab11923e93440fb9f04bfdfb0292d63522fd21483a38a7d2ca31c691dc64e4a894063944b586ef1b2af66fd8a4bd55ea132ce743b6e69e7fd812db
6
+ metadata.gz: 1f688d2e403997a3cf6f3b91c0ebd3746ed51b1d0bc4dfea2be5fd1297a22715d2a7b5672482b2f243dc26b38b56eee2880c9e57df98c55c16178b84eddeb8da
7
+ data.tar.gz: 934e5ef9e7b71bc0323475ae4c54596d7a6813f552d63fd08dd358f51805c6950780d4435b9cf830aed7712392a5a38d52077dfbcceb69293adf02f9b0970794
data/CHANGELOG.md CHANGED
@@ -1,3 +1,51 @@
1
+ ### 2.1.46
2
+
3
+ 2026-09-27 12:52
4
+
5
+ #### CHANGED
6
+
7
+ - **Shell run blocks** (sh, bash, zsh, dash, ksh, or no hashbang) now receive Howzit variables as environment variables instead of having ${VAR} replaced in the script text, so scripts can use their own variables, parameter expansions, and ENV without Howzit clobbering them.
8
+ - **Command-line arguments** after -- are passed to shell run blocks as script arguments, so $1, $#, and "$@" work like any shell script (including inside shell functions).
9
+
10
+ #### NEW
11
+
12
+ - **:shell_variables: config option** (env or substitute) to switch shell run blocks back to text substitution; defaults to env.
13
+ - **$${VAR} escape** passes ${VAR} through to the shell untouched in @run, @copy, and text-substituted blocks.
14
+
15
+ #### IMPROVED
16
+
17
+ - **Howzit-style defaults** (${name:default}) in shell run blocks are converted to the shell's ${name:-default} so they keep working with environment variables.
18
+
19
+ #### FIXED
20
+
21
+ - **Shell parameter expansions** like ${VAR:-default}, ${VAR:=x}, ${VAR:+x}, ${VAR:?x}, and ${VAR:0:3} are no longer mangled into Howzit defaults when VAR isn't a Howzit variable.
22
+ - **Positional placeholders** ($1, ${2}) without a matching argument are left intact instead of being replaced with an empty string, which broke shell functions and awk '{print $1}'.
23
+ - **$@ and $*** are only replaced when arguments were passed after --, instead of always being replaced with an empty string.
24
+ - **Default values containing colons** (e.g. ${url:http://localhost:3000}) are no longer cut off at the second colon.
25
+
26
+ ### 2.1.45
27
+
28
+ 2026-09-27 12:16
29
+
30
+ #### CHANGED
31
+
32
+ - **Multi-topic runs** (comma-separated topics or `default:` metadata) now stop after the first topic with a failed task and mark the remaining topics' tasks as skipped; use --force to run every topic.
33
+
34
+ #### NEW
35
+
36
+ - **Skipped tasks** in the run report: tasks that did not run because of an earlier failure are listed with a skipped marker and "(skipped)" label.
37
+ - **Skipped @include tasks** are expanded to the included topic's individual tasks in the run report.
38
+
39
+ #### IMPROVED
40
+
41
+ - **Conditional topics** list remaining unconditional tasks as skipped after a failure; tasks inside unevaluated conditionals are omitted.
42
+
43
+ #### FIXED
44
+
45
+ - **Run report** now includes the task that failed and stopped a run, instead of omitting it and showing only the earlier successes.
46
+ - **@include** directives now fail when a task in the included topic fails, so the including topic stops instead of continuing with its remaining tasks.
47
+ - **Task totals for @include** reflect the included topic's actual successes and errors instead of counting every included task as a single result.
48
+
1
49
  ### 2.1.44
2
50
 
3
51
  2026-09-27 11:38
@@ -1558,8 +1558,10 @@ module Howzit
1558
1558
  end
1559
1559
  topic_matches.compact! # Remove any nil values from failed matches
1560
1560
  # A matched subtopic is already output/run with its matched parent
1561
- top_level_topics(topic_matches).each do |topic_match|
1561
+ selected = top_level_topics(topic_matches)
1562
+ selected.each_with_index do |topic_match, idx|
1562
1563
  output.push(process_topic(topic_match, Howzit.options[:run], single: true))
1564
+ break if stop_after_failure?(selected[(idx + 1)..])
1563
1565
  end
1564
1566
  else
1565
1567
  top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
@@ -1599,7 +1601,7 @@ module Howzit
1599
1601
  end
1600
1602
 
1601
1603
  # Run each topic with its specific arguments
1602
- topic_specs.each do |topic_match, args|
1604
+ topic_specs.each_with_index do |(topic_match, args), idx|
1603
1605
  # Set arguments if provided, otherwise clear them
1604
1606
  Howzit.arguments = if args && !args.empty?
1605
1607
  args.split(/ *, */).map(&:render_arguments)
@@ -1607,10 +1609,29 @@ module Howzit
1607
1609
  []
1608
1610
  end
1609
1611
  output.push(process_topic(topic_match, Howzit.options[:run], single: true))
1612
+ break if stop_after_failure?(topic_specs[(idx + 1)..].map(&:first))
1610
1613
  end
1611
1614
  finalize_output(output)
1612
1615
  end
1613
1616
 
1617
+ ##
1618
+ ## After running a topic in a multi-topic run, check whether a task
1619
+ ## failed. If so (and --force isn't set), log the remaining topics'
1620
+ ## tasks as skipped.
1621
+ ##
1622
+ ## @param remaining [Array] Topics that haven't run yet
1623
+ ##
1624
+ ## @return [Boolean] true if the run should stop
1625
+ ##
1626
+ def stop_after_failure?(remaining)
1627
+ return false unless Howzit.options[:run]
1628
+ return false if Howzit.options[:force]
1629
+ return false unless Howzit.run_log.any? { |entry| !entry[:success] && !entry[:skipped] }
1630
+
1631
+ remaining.each { |topic| topic.log_skipped_tasks(topic.all_tasks) }
1632
+ true
1633
+ end
1634
+
1614
1635
  ##
1615
1636
  ## Parse default metadata value into individual topic specifications
1616
1637
  ##
data/lib/howzit/config.rb CHANGED
@@ -21,6 +21,7 @@ module Howzit
21
21
  output_title: false,
22
22
  pager: 'auto',
23
23
  paginate: true,
24
+ shell_variables: 'env', # env: pass variables to shell run blocks via ENV, substitute: replace ${VAR} in text
24
25
  show_all_code: false,
25
26
  show_all_on_error: false,
26
27
  wrap: 0
@@ -26,8 +26,20 @@ module Howzit
26
26
  output_lines.join("\n")
27
27
  end
28
28
 
29
+ def status_symbol(entry)
30
+ return '⏭️' if entry[:skipped]
31
+
32
+ entry[:success] ? '✅' : '❌'
33
+ end
34
+
35
+ def status_reason(entry)
36
+ return 'skipped' if entry[:skipped]
37
+
38
+ entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed'
39
+ end
40
+
29
41
  def format_line(entry, prefix_topic)
30
- symbol = entry[:success] ? '✅' : '❌'
42
+ symbol = status_symbol(entry)
31
43
  parts = ["#{symbol} "]
32
44
  if prefix_topic && entry[:topic] && !entry[:topic].empty?
33
45
  # Escape braces and dollar signs in topic name to prevent color code interpretation
@@ -36,10 +48,11 @@ module Howzit
36
48
  end
37
49
  # Escape braces and dollar signs in task name to prevent color code interpretation
38
50
  task_escaped = entry[:task].gsub(/\{/, '\\{').gsub(/\}/, '\\}').gsub(/\$/, '\\$')
39
- parts << "{by}#{task_escaped} {x}"
40
- unless entry[:success]
41
- reason = entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed'
42
- parts << " {br}(#{reason}){x}"
51
+ if entry[:skipped]
52
+ parts << "{d}#{task_escaped} (skipped){x}"
53
+ else
54
+ parts << "{by}#{task_escaped} {x}"
55
+ parts << " {br}(#{status_reason(entry)}){x}" unless entry[:success]
43
56
  end
44
57
  parts.join.c
45
58
  end
@@ -76,7 +89,7 @@ module Howzit
76
89
  def format_row(entry, prefix_topic)
77
90
  # Use plain emoji without color codes - the emoji itself provides visual meaning
78
91
  # and complex ANSI codes interfere with mdless table rendering
79
- symbol = entry[:success] ? '✅' : '❌'
92
+ symbol = status_symbol(entry)
80
93
 
81
94
  task_parts = []
82
95
  task_parts_plain = []
@@ -94,7 +107,7 @@ module Howzit
94
107
  task_parts_plain << entry[:task]
95
108
 
96
109
  unless entry[:success]
97
- reason = entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed'
110
+ reason = status_reason(entry)
98
111
  task_parts << " {br}(#{reason}){x}"
99
112
  task_parts_plain << " (#{reason})"
100
113
  end
@@ -153,6 +153,35 @@ module Howzit
153
153
  end
154
154
  end
155
155
 
156
+ ##
157
+ ## Whether a run block is a POSIX-style shell script (sh, bash,
158
+ ## zsh, dash, ksh). Blocks without a hashbang run with sh.
159
+ ##
160
+ ## @param script_content [String] The script content
161
+ ##
162
+ ## @return [Boolean]
163
+ ##
164
+ def shell_script?(script_content)
165
+ first_line = script_content.to_s.lines.first&.strip
166
+ return true unless first_line&.start_with?('#!')
167
+
168
+ first_line.match?(%r{\A#!\s*(?:/usr/bin/env\s+)?(?:\S*/)?(?:sh|bash|zsh|dash|ksh)(?:\s|\z)})
169
+ end
170
+
171
+ ##
172
+ ## Howzit variables as environment variables for shell run blocks
173
+ ##
174
+ ## @return [Hash] name => string value
175
+ ##
176
+ def variables_env
177
+ (Howzit.named_arguments || {}).each_with_object({}) do |(key, value), env|
178
+ name = key.to_s
179
+ next if value.nil? || !name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
180
+
181
+ env[name] = value.to_s
182
+ end
183
+ end
184
+
156
185
  ##
157
186
  ## Get the injection line for a given interpreter
158
187
  ##
@@ -383,29 +383,64 @@ module Howzit
383
383
  str = dup
384
384
  str.render_named_placeholders
385
385
  str.render_numeric_placeholders
386
- Howzit.arguments.nil? ? str : str.gsub(/\$[@*]/, Shellwords.join(Howzit.arguments))
386
+ args = Howzit.arguments
387
+ return str if args.nil? || args.empty?
388
+
389
+ str.gsub(/(?<!\$)\$[@*]/, Shellwords.join(args))
387
390
  end
388
391
 
392
+ ##
393
+ ## Replace ${NAME} and ${NAME:default} with Howzit variable
394
+ ## values. Names Howzit doesn't know, shell parameter
395
+ ## expansions (${VAR:-x}, ${VAR:=x}, ${VAR:0:3}, etc.), and
396
+ ## escaped $${NAME} placeholders are left for the shell.
397
+ ##
389
398
  def render_named_placeholders
390
- gsub!(/\$\{(?<name>[A-Z0-9_]+(?::.*?)?)\}/i) do
399
+ gsub!(/(?<!\$)\$\{(?<name>[A-Z0-9_]+)(?<rest>:[^}]*)?\}/i) do
391
400
  m = Regexp.last_match
392
- arg, default = m['name'].split(/:/).map(&:strip)
393
- if Howzit.named_arguments&.key?(arg) && !Howzit.named_arguments[arg].nil?
394
- Howzit.named_arguments[arg]
395
- elsif default
396
- default
401
+ original = m[0]
402
+ rest = m['rest'].to_s
403
+ value = placeholder_value(m['name'])
404
+
405
+ if rest.empty?
406
+ value.nil? ? original : value
407
+ elsif rest.start_with?(':-')
408
+ value.nil? || value.empty? || rest.include?('$') ? original : value
409
+ elsif rest.match?(/\A:[=+?\s\d]/)
410
+ original
397
411
  else
398
- # Preserve the original ${VAR} syntax if variable is not defined and no default provided
399
- m[0]
412
+ value.nil? ? rest[1..].strip : value
400
413
  end
401
414
  end
402
415
  end
403
416
 
404
417
  def render_numeric_placeholders
405
- gsub!(/\$\{?(\d+)\}?/) do
406
- arg, default = Regexp.last_match(1).split(/:/)
407
- idx = arg.to_i - 1
408
- Howzit.arguments.length > idx ? Howzit.arguments[idx] : default || Regexp.last_match(0)
418
+ gsub!(/(?<!\$)\$(?:\{(\d+)\}|(\d+))/) do
419
+ m = Regexp.last_match
420
+ placeholder_value(m[1] || m[2]) || m[0]
421
+ end
422
+ end
423
+
424
+ ##
425
+ ## Remove the escape from $${NAME} placeholders, leaving
426
+ ## ${NAME} for the shell. Applied right before execution.
427
+ ##
428
+ ## @return [String] unescaped string
429
+ ##
430
+ def unescape_placeholders
431
+ gsub(/\$\$(?=\{)/, '$')
432
+ end
433
+
434
+ ##
435
+ ## Convert Howzit-style ${NAME:default} to the shell's
436
+ ## ${NAME:-default} so defaults work when variables are
437
+ ## passed to a shell script through the environment
438
+ ##
439
+ ## @return [String] converted string
440
+ ##
441
+ def shellify_defaults
442
+ gsub(/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*|\d+):(?![-=+?\s\d$(])([^}]*)\}/) do
443
+ "${#{Regexp.last_match(1)}:-#{Regexp.last_match(2)}}"
409
444
  end
410
445
  end
411
446
 
@@ -560,6 +595,24 @@ module Howzit
560
595
  Color.template("\n\n#{title}#{tail}{x}\n\n")
561
596
  end
562
597
  end
598
+
599
+ private
600
+
601
+ ##
602
+ ## Look up a placeholder name: digits are positional
603
+ ## arguments, anything else is a named variable
604
+ ##
605
+ ## @return [String, nil] value, or nil if not defined
606
+ ##
607
+ def placeholder_value(name)
608
+ if name.match?(/\A\d+\z/)
609
+ idx = name.to_i - 1
610
+ args = Howzit.arguments || []
611
+ return idx >= 0 && idx < args.length ? args[idx].to_s : nil
612
+ end
613
+
614
+ Howzit.named_arguments&.[](name)&.to_s
615
+ end
563
616
  end
564
617
  end
565
618
 
data/lib/howzit/task.rb CHANGED
@@ -5,7 +5,8 @@ require 'English'
5
5
  module Howzit
6
6
  # Task object
7
7
  class Task
8
- attr_reader :type, :title, :action, :arguments, :parent, :optional, :default, :last_status, :log_level, :source_file
8
+ attr_reader :type, :title, :action, :arguments, :parent, :optional, :default, :last_status, :log_level, :source_file,
9
+ :include_results
9
10
 
10
11
  ##
11
12
  ## Initialize a Task object
@@ -31,7 +32,8 @@ module Howzit
31
32
  @title = attributes[:title]&.to_s
32
33
  @parent = attributes[:parent] || nil
33
34
 
34
- @action = attributes[:action].render_arguments || nil
35
+ action = attributes[:action]
36
+ @action = shell_env_block?(action) ? action : action.render_arguments
35
37
  @log_level = attributes[:log_level]
36
38
  # Get source_file from parent topic if available, or from attributes
37
39
  parent_obj = attributes[:parent]
@@ -60,15 +62,37 @@ module Howzit
60
62
  @title
61
63
  end
62
64
 
65
+ ##
66
+ ## Whether this is a shell run block that receives Howzit
67
+ ## variables through the environment instead of text substitution
68
+ ##
69
+ ## @param content [String] The block content
70
+ ##
71
+ def shell_env_block?(content = @action)
72
+ @type == :block &&
73
+ Howzit.options[:shell_variables].to_s != 'substitute' &&
74
+ ScriptSupport.shell_script?(content)
75
+ end
76
+
63
77
  ##
64
78
  ## Execute a block type
65
79
  ##
66
80
  def run_block
67
81
  Howzit.console.info "#{@prefix}{bg}Running block {bw}#{@title}{x}".c if Howzit.options[:log_level] < 2
68
82
  block = @action
83
+ env_mode = shell_env_block?(block)
69
84
  # Apply variable substitution to block content at execution time
70
85
  # (variables from previous run blocks are now available)
71
- block = block.render_arguments if block && !block.empty?
86
+ if env_mode
87
+ block = block.shellify_defaults
88
+ env = ScriptSupport.variables_env
89
+ script_args = Howzit.arguments || []
90
+ else
91
+ block = block.render_arguments if block && !block.empty?
92
+ env = {}
93
+ script_args = []
94
+ end
95
+ block = block.unescape_placeholders
72
96
  script = Tempfile.new('howzit_script')
73
97
  comm_file = ScriptComm.setup
74
98
  old_log_level = apply_log_level
@@ -116,9 +140,9 @@ module Howzit
116
140
  cmd = ScriptSupport.execution_command_for(script.path, interpreter)
117
141
  # If interpreter is nil, execute directly (will respect hashbang)
118
142
  res = if interpreter.nil?
119
- system(script.path)
143
+ system(env, script.path, *script_args)
120
144
  else
121
- system(cmd)
145
+ system(env, [cmd, *script_args.map { |arg| Shellwords.escape(arg) }].join(' '))
122
146
  end
123
147
  ensure
124
148
  # Restore original directory
@@ -141,7 +165,7 @@ module Howzit
141
165
  ##
142
166
  ## Execute an include task
143
167
  ##
144
- ## @return [Array] [[Array] output, [Integer] number of tasks executed]
168
+ ## @return [Array] [[Array] output, [Integer] number of tasks executed, [Boolean] all tasks succeeded]
145
169
  ##
146
170
  def run_include
147
171
  output = []
@@ -150,11 +174,13 @@ module Howzit
150
174
  matches = Howzit.buildnote.find_topic(action)
151
175
  raise "Topic not found: #{action}" if matches.empty?
152
176
 
153
- Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{matches[0].title}{x}".c)
154
- output.concat(matches[0].run(nested: true))
155
- Howzit.console.info("{by}End include: #{matches[0].all_tasks.count} tasks{x}".c)
177
+ topic = matches[0]
178
+ Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{topic.title}{x}".c)
179
+ output.concat(topic.run(nested: true))
180
+ Howzit.console.info("{by}End include: #{topic.all_tasks.count} tasks{x}".c)
156
181
  @last_status = nil
157
- [output, matches[0].all_tasks.count]
182
+ @include_results = topic.results.slice(:total, :success, :errors)
183
+ [output, @include_results[:total], @include_results[:errors].zero?]
158
184
  end
159
185
 
160
186
  ##
@@ -238,7 +264,7 @@ module Howzit
238
264
  Dir.chdir(expanded_exec_dir) if expanded_exec_dir != expanded_original
239
265
  end
240
266
 
241
- res = system(@action)
267
+ res = system(@action.unescape_placeholders)
242
268
  ensure
243
269
  # Restore original directory
244
270
  if exec_dir && Dir.exist?(exec_dir)
@@ -271,7 +297,7 @@ module Howzit
271
297
  @title && !@title.empty? ? @title : @action
272
298
  end
273
299
  Howzit.console.info("#{@prefix}{bg}Copied {bw}#{display_title}{bg} to clipboard{x}".c)
274
- Util.os_copy(@action)
300
+ Util.os_copy(@action.unescape_placeholders)
275
301
  @last_status = 0
276
302
  true
277
303
  end
@@ -287,7 +313,8 @@ module Howzit
287
313
  else
288
314
  case @type
289
315
  when :include
290
- output, tasks = run_include
316
+ output, tasks, include_success = run_include
317
+ include_success
291
318
  when :run
292
319
  run_run
293
320
  when :copy
data/lib/howzit/topic.rb CHANGED
@@ -157,7 +157,11 @@ module Howzit
157
157
  Howzit.console.warn "{r}--run: No {br}@directive{xr} found in {bw}#{@title}{x}".c
158
158
  end
159
159
 
160
- run_subtopics(output) unless halted?
160
+ if halted?
161
+ log_skipped_tasks(@subtopics.flat_map(&:all_tasks))
162
+ else
163
+ run_subtopics(output)
164
+ end
161
165
 
162
166
  if @results[:total].positive? || (all_tasks.any? && !sequential?)
163
167
  @results[:message] += results_message
@@ -225,37 +229,76 @@ module Howzit
225
229
  ## Run tasks without conditional evaluation
226
230
  ##
227
231
  def run_tasks(output)
228
- @tasks.each do |task|
232
+ @tasks.each_with_index do |task, idx|
229
233
  next if (task.optional || Howzit.options[:ask]) && !ask_task(task)
230
234
 
231
235
  run_output, total, success = task.run
232
236
 
233
237
  output.concat(run_output)
238
+ record_task_result(task, total, success)
239
+ next unless halted?
240
+
241
+ log_skipped_tasks(@tasks[(idx + 1)..])
242
+ break
243
+ end
244
+ output
245
+ end
246
+
247
+ ##
248
+ ## Add a task's results to the topic totals and the run report
249
+ ##
250
+ ## @param task [Task] The task that ran
251
+ ## @param total [Integer] Number of tasks it represents
252
+ ## @param success [Boolean] Whether it succeeded
253
+ ##
254
+ def record_task_result(task, total, success)
255
+ if task.type == :include && task.include_results
256
+ %i[total success errors].each { |key| @results[key] += task.include_results[key] }
257
+ else
234
258
  @results[:total] += total
259
+ @results[success ? :success : :errors] += total
260
+ end
235
261
 
236
- if success
237
- @results[:success] += total
238
- else
239
- Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c
262
+ log_task_result(task, success)
263
+ Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c unless success
264
+ end
240
265
 
241
- @results[:errors] += total
266
+ ##
267
+ ## Log tasks that won't run because an earlier task failed.
268
+ ## Include tasks are expanded to the tasks of the included topic.
269
+ ##
270
+ ## @param tasks [Array] Task objects
271
+ ## @param seen [Array] Titles of included topics already expanded
272
+ ##
273
+ def log_skipped_tasks(tasks, seen = [])
274
+ return unless Howzit.options[:run]
242
275
 
243
- break unless Howzit.options[:force]
276
+ Howzit.run_log ||= []
277
+ tasks.each do |task|
278
+ if task.type == :include
279
+ topic = Howzit.buildnote.find_topic(task.action)[0]
280
+ next if topic.nil? || seen.include?(topic.title)
281
+
282
+ topic.log_skipped_tasks(topic.all_tasks, seen + [topic.title])
283
+ next
244
284
  end
245
285
 
246
- log_task_result(task, success)
286
+ topic_title = task.parent.is_a?(Topic) ? task.parent.title : @title
287
+ Howzit.run_log << { topic: topic_title, task: task_log_title(task), success: false, skipped: true }
247
288
  end
248
- output
249
289
  end
250
290
 
251
291
  ##
252
292
  ## Run each subtopic in order, adding its results to this topic's
253
293
  ##
254
294
  def run_subtopics(output)
255
- @subtopics.each do |sub|
295
+ @subtopics.each_with_index do |sub, idx|
256
296
  output.concat(sub.run(nested: true, as_subtopic: true))
257
297
  %i[total success errors].each { |key| @results[key] += sub.results[key] }
258
- break if halted?
298
+ next unless halted?
299
+
300
+ log_skipped_tasks(@subtopics[(idx + 1)..].flat_map(&:all_tasks))
301
+ break
259
302
  end
260
303
  output
261
304
  end
@@ -538,22 +581,20 @@ module Howzit
538
581
  return if task.type == :include
539
582
 
540
583
  Howzit.run_log ||= []
541
-
542
- title = (task.title || '').strip
543
- if title.empty?
544
- action = (task.action || '').strip
545
- title = action.split(/\n/).first.to_s.strip
546
- end
547
- title = task.type.to_s.capitalize if title.nil? || title.empty?
548
-
549
584
  Howzit.run_log << {
550
585
  topic: @title,
551
- task: title,
586
+ task: task_log_title(task),
552
587
  success: success ? true : false,
553
588
  exit_status: task.last_status
554
589
  }
555
590
  end
556
591
 
592
+ def task_log_title(task)
593
+ title = (task.title || '').strip
594
+ title = (task.action || '').strip.split(/\n/).first.to_s.strip if title.empty?
595
+ title.empty? ? task.type.to_s.capitalize : title
596
+ end
597
+
557
598
  def gather_tasks
558
599
  runnable = []
559
600
  # Process conditional blocks first
@@ -968,20 +1009,14 @@ module Howzit
968
1009
  run_output, total, success = task.run
969
1010
 
970
1011
  output.concat(run_output)
971
- @results[:total] += total
972
-
973
- if success
974
- @results[:success] += total
975
- else
976
- Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c
977
-
978
- @results[:errors] += total
979
-
980
- break unless Howzit.options[:force]
1012
+ record_task_result(task, total, success)
1013
+ if halted?
1014
+ # Tasks inside conditionals are omitted, since their conditions were never evaluated
1015
+ remaining = @directives[directive_index..].select { |d| d.task? && (d.conditional_path || []).empty? }
1016
+ log_skipped_tasks(remaining.filter_map { |d| d.to_task(self) })
1017
+ break
981
1018
  end
982
1019
 
983
- log_task_result(task, success)
984
-
985
1020
  # Re-evaluate all open conditionals after task execution
986
1021
  re_evaluate_conditionals(conditional_state, directive_index - 1, context)
987
1022
  end
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.44'
6
+ VERSION = '2.1.46'
7
7
  end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ describe 'run report accuracy' do
8
+ let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_run_report_test')) }
9
+ let(:buildnote) { Howzit::BuildNote.new }
10
+
11
+ before do
12
+ File.write(
13
+ File.join(temp_dir, 'buildnotes.md'),
14
+ <<~NOTE
15
+ # Run Report Project
16
+
17
+ ## Failing
18
+
19
+ @run(true) First
20
+ @run(false) Second
21
+ @run(true) Third
22
+
23
+ ## Release
24
+
25
+ @include(Failing)
26
+ @run(true) After include
27
+
28
+ ## Early Failure
29
+
30
+ @run(false) Fails first
31
+ @include(Failing)
32
+
33
+ ## Conditional
34
+
35
+ @if 1 == 1
36
+ @run(false) Conditional failure
37
+ @run(true) Conditional after
38
+ @end
39
+ @run(true) Unconditional after
40
+
41
+ ## Parent
42
+
43
+ @run(true) Parent task
44
+
45
+ ### Broken Child
46
+
47
+ @run(false) Child failure
48
+ @run(true) Child after
49
+
50
+ ### Later Child
51
+
52
+ @run(true) Later child task
53
+ NOTE
54
+ )
55
+
56
+ Dir.chdir(temp_dir)
57
+ Howzit.instance_variable_set(:@buildnote, buildnote)
58
+ Howzit.options[:stack] = false
59
+ Howzit.options[:include_upstream] = false
60
+ Howzit.options[:run] = true
61
+ Howzit.options[:force] = false
62
+ Howzit.run_log = []
63
+ end
64
+
65
+ after do
66
+ Howzit.options[:run] = false
67
+ Howzit.options[:force] = false
68
+ Howzit.run_log = []
69
+ Dir.chdir(Dir.tmpdir)
70
+ FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
71
+ Howzit.instance_variable_set(:@buildnote, nil)
72
+ end
73
+
74
+ def report
75
+ Howzit.run_log.map do |entry|
76
+ status = if entry[:skipped]
77
+ :skipped
78
+ else
79
+ entry[:success] ? :ok : :failed
80
+ end
81
+ [entry[:task], status]
82
+ end
83
+ end
84
+
85
+ it 'logs the failed task and marks the rest as skipped' do
86
+ buildnote.find_topic('Failing')[0].run
87
+
88
+ expect(report).to eq([['First', :ok], ['Second', :failed], ['Third', :skipped]])
89
+ expect(Howzit.run_log[1][:exit_status]).to eq(1)
90
+ end
91
+
92
+ it 'treats a failed include as a failure and skips the rest of the including topic' do
93
+ topic = buildnote.find_topic('Release')[0]
94
+ topic.run
95
+
96
+ expect(report).to eq([['First', :ok], ['Second', :failed], ['Third', :skipped], ['After include', :skipped]])
97
+ expect(topic.results).to include(total: 2, success: 1, errors: 1)
98
+ end
99
+
100
+ it 'expands skipped includes into the included topic tasks' do
101
+ buildnote.find_topic('Early Failure')[0].run
102
+
103
+ expect(report).to eq([['Fails first', :failed], ['First', :skipped], ['Second', :skipped], ['Third', :skipped]])
104
+ expect(Howzit.run_log[1][:topic]).to eq('Failing')
105
+ end
106
+
107
+ it 'skips only unconditional tasks after a failure in a conditional topic' do
108
+ buildnote.find_topic('Conditional')[0].run
109
+
110
+ expect(report).to eq([['Conditional failure', :failed], ['Unconditional after', :skipped]])
111
+ end
112
+
113
+ it 'skips remaining subtopics after a subtopic fails' do
114
+ buildnote.find_topic('Parent')[0].run
115
+
116
+ expect(report).to eq([
117
+ ['Parent task', :ok],
118
+ ['Child failure', :failed],
119
+ ['Child after', :skipped],
120
+ ['Later child task', :skipped]
121
+ ])
122
+ expect(Howzit.run_log.last[:topic]).to eq('Later Child')
123
+ end
124
+
125
+ it 'continues past failures with --force and logs every result' do
126
+ Howzit.options[:force] = true
127
+ topic = buildnote.find_topic('Release')[0]
128
+ topic.run
129
+
130
+ expect(report).to eq([['First', :ok], ['Second', :failed], ['Third', :ok], ['After include', :ok]])
131
+ expect(topic.results).to include(total: 4, success: 3, errors: 1)
132
+ end
133
+
134
+ describe 'multi-topic runs' do
135
+ before do
136
+ allow(buildnote).to receive(:finalize_output)
137
+ end
138
+
139
+ let(:topics) { ['Failing', 'Parent'].map { |title| buildnote.find_topic(title)[0] } }
140
+
141
+ it 'stops after a topic fails and skips the remaining topics' do
142
+ buildnote.send(:process_topic_matches, topics, [])
143
+
144
+ expect(report).to eq([
145
+ ['First', :ok],
146
+ ['Second', :failed],
147
+ ['Third', :skipped],
148
+ ['Parent task', :skipped],
149
+ ['Child failure', :skipped],
150
+ ['Child after', :skipped],
151
+ ['Later child task', :skipped]
152
+ ])
153
+ end
154
+
155
+ it 'runs every topic with --force' do
156
+ Howzit.options[:force] = true
157
+ buildnote.send(:process_topic_matches, topics, [])
158
+
159
+ expect(report.map(&:first)).to include('Parent task', 'Later child task')
160
+ expect(report.map(&:last)).not_to include(:skipped)
161
+ end
162
+ end
163
+
164
+ it 'formats skipped tasks in the report' do
165
+ buildnote.find_topic('Failing')[0].run
166
+ plain = Howzit::RunReport.format.uncolor
167
+
168
+ expect(plain).to include('❌ Second (exit code 1)')
169
+ expect(plain).to include('⏭️ Third (skipped)')
170
+ end
171
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ describe 'shell variable handling' do
8
+ before do
9
+ Howzit.arguments = []
10
+ Howzit.named_arguments = { 'file' => 'README.md', 'version' => '1.2.3' }
11
+ end
12
+
13
+ after do
14
+ Howzit.arguments = []
15
+ Howzit.named_arguments = {}
16
+ end
17
+
18
+ describe 'String#render_arguments' do
19
+ it 'substitutes Howzit variables' do
20
+ expect('v${version}'.render_arguments).to eq('v1.2.3')
21
+ end
22
+
23
+ it 'leaves unknown variables for the shell' do
24
+ expect('echo "${HOME}"'.render_arguments).to eq('echo "${HOME}"')
25
+ end
26
+
27
+ it 'uses Howzit-style defaults for undefined variables' do
28
+ expect('${missing:fallback}'.render_arguments).to eq('fallback')
29
+ expect('${url:http://localhost:3000}'.render_arguments).to eq('http://localhost:3000')
30
+ end
31
+
32
+ it 'leaves shell parameter expansions alone' do
33
+ %w[${OUT:-/tmp} ${OUT:=/tmp} ${OUT:+set} ${OUT:?missing} ${NAME:0:3} ${#NAME} ${NAME/a/b}].each do |expansion|
34
+ expect(expansion.render_arguments).to eq(expansion)
35
+ end
36
+ end
37
+
38
+ it 'substitutes a defined Howzit variable in ${VAR:-default}' do
39
+ expect('${version:-0.0.0}'.render_arguments).to eq('1.2.3')
40
+ end
41
+
42
+ it 'leaves positional placeholders without a matching argument' do
43
+ expect('f() { echo "$1 ${2}"; }'.render_arguments).to eq('f() { echo "$1 ${2}"; }')
44
+ expect("awk '{print $1}'".render_arguments).to eq("awk '{print $1}'")
45
+ end
46
+
47
+ it 'leaves $@ and $* alone when no arguments were passed' do
48
+ expect('run() { "$@"; }'.render_arguments).to eq('run() { "$@"; }')
49
+ end
50
+
51
+ it 'substitutes positional arguments when passed' do
52
+ Howzit.arguments = ['one', 'two words']
53
+ expect('$1 ${2} ${3:three}'.render_arguments).to eq('one two words three')
54
+ expect('cmd $@'.render_arguments).to eq("cmd one two\\ words")
55
+ end
56
+
57
+ it 'leaves escaped $${VAR} placeholders until execution' do
58
+ rendered = 'echo $${file}'.render_arguments
59
+ expect(rendered).to eq('echo $${file}')
60
+ expect(rendered.unescape_placeholders).to eq('echo ${file}')
61
+ end
62
+ end
63
+
64
+ describe 'String#shellify_defaults' do
65
+ it 'converts Howzit defaults to shell defaults' do
66
+ expect('${name:world} ${1:first}'.shellify_defaults).to eq('${name:-world} ${1:-first}')
67
+ end
68
+
69
+ it 'leaves shell expansions alone' do
70
+ %w[${name} ${name:-x} ${name:0:3} ${name:$offset} ${name: -2}].each do |expansion|
71
+ expect(expansion.shellify_defaults).to eq(expansion)
72
+ end
73
+ end
74
+ end
75
+
76
+ describe 'ScriptSupport.shell_script?' do
77
+ it 'detects shell scripts' do
78
+ expect(Howzit::ScriptSupport.shell_script?("echo hi\n")).to be true
79
+ expect(Howzit::ScriptSupport.shell_script?("#!/bin/bash\necho hi\n")).to be true
80
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env zsh\n")).to be true
81
+ expect(Howzit::ScriptSupport.shell_script?("#!/bin/sh -e\n")).to be true
82
+ end
83
+
84
+ it 'excludes other interpreters' do
85
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env ruby\n")).to be false
86
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env fish\n")).to be false
87
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env python3\n")).to be false
88
+ end
89
+ end
90
+
91
+ describe 'running blocks' do
92
+ let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_shell_vars_test')) }
93
+ let(:out_file) { File.join(temp_dir, 'out.txt') }
94
+
95
+ before do
96
+ File.write(
97
+ File.join(temp_dir, 'buildnotes.md'),
98
+ <<~NOTE
99
+ # Shell Variables
100
+
101
+ ## Shell Block (target:prod)
102
+
103
+ ```run
104
+ #!/bin/bash
105
+ greet() { echo "arg=$1"; }
106
+ for file in a.rb; do echo "loop=${file}"; done > "#{out_file}"
107
+ echo "howzit=${target}" >> "#{out_file}"
108
+ echo "default=${undefined_var:fallback}" >> "#{out_file}"
109
+ echo "shell_default=${UNSET_THING:-shellval}" >> "#{out_file}"
110
+ echo "env=${HOWZIT_SPEC_ENV}" >> "#{out_file}"
111
+ greet hello >> "#{out_file}"
112
+ echo "script_arg=${1:-none}" >> "#{out_file}"
113
+ ```
114
+
115
+ ## Escaped Run
116
+
117
+ @run(echo "$${HOWZIT_SPEC_ENV}" > "#{out_file}") Escaped
118
+ NOTE
119
+ )
120
+
121
+ ENV['HOWZIT_SPEC_ENV'] = 'from_env'
122
+ Dir.chdir(temp_dir)
123
+ Howzit.instance_variable_set(:@buildnote, nil)
124
+ Howzit.options[:stack] = false
125
+ Howzit.options[:include_upstream] = false
126
+ Howzit.options[:log_level] = 3
127
+ Howzit.named_arguments = { 'file' => 'README.md' }
128
+ end
129
+
130
+ after do
131
+ ENV.delete('HOWZIT_SPEC_ENV')
132
+ Howzit.options[:shell_variables] = 'env'
133
+ Howzit.options[:log_level] = 1
134
+ Dir.chdir(Dir.tmpdir)
135
+ FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
136
+ Howzit.instance_variable_set(:@buildnote, nil)
137
+ end
138
+
139
+ def output_lines
140
+ File.read(out_file).lines.map(&:strip)
141
+ end
142
+
143
+ it 'passes Howzit variables to shell blocks through the environment' do
144
+ Howzit::BuildNote.new.find_topic('Shell Block')[0].run
145
+
146
+ expect(output_lines).to eq([
147
+ 'loop=a.rb',
148
+ 'howzit=prod',
149
+ 'default=fallback',
150
+ 'shell_default=shellval',
151
+ 'env=from_env',
152
+ 'arg=hello',
153
+ 'script_arg=none'
154
+ ])
155
+ end
156
+
157
+ it 'passes positional arguments to the script' do
158
+ Howzit.arguments = ['cli_arg']
159
+ Howzit::BuildNote.new.find_topic('Shell Block')[0].run
160
+
161
+ expect(output_lines).to include('arg=hello', 'script_arg=cli_arg')
162
+ end
163
+
164
+ it 'uses text substitution when shell_variables is substitute' do
165
+ Howzit.options[:shell_variables] = 'substitute'
166
+ Howzit::BuildNote.new.find_topic('Shell Block')[0].run
167
+
168
+ expect(output_lines).to include('loop=README.md', 'howzit=prod')
169
+ end
170
+
171
+ it 'passes escaped placeholders to the shell in @run commands' do
172
+ Howzit::BuildNote.new.find_topic('Escaped Run')[0].run
173
+
174
+ expect(output_lines).to eq(['from_env'])
175
+ end
176
+ end
177
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: howzit
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.44
4
+ version: 2.1.46
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -324,10 +324,12 @@ files:
324
324
  - spec/log_level_spec.rb
325
325
  - spec/prompt_spec.rb
326
326
  - spec/ruby_gem_spec.rb
327
+ - spec/run_report_accuracy_spec.rb
327
328
  - spec/run_report_spec.rb
328
329
  - spec/script_comm_spec.rb
329
330
  - spec/sequential_conditional_spec.rb
330
331
  - spec/set_var_spec.rb
332
+ - spec/shell_variables_spec.rb
331
333
  - spec/spec_helper.rb
332
334
  - spec/stack_mode_spec.rb
333
335
  - spec/stringutils_spec.rb
@@ -371,10 +373,12 @@ test_files:
371
373
  - spec/log_level_spec.rb
372
374
  - spec/prompt_spec.rb
373
375
  - spec/ruby_gem_spec.rb
376
+ - spec/run_report_accuracy_spec.rb
374
377
  - spec/run_report_spec.rb
375
378
  - spec/script_comm_spec.rb
376
379
  - spec/sequential_conditional_spec.rb
377
380
  - spec/set_var_spec.rb
381
+ - spec/shell_variables_spec.rb
378
382
  - spec/spec_helper.rb
379
383
  - spec/stack_mode_spec.rb
380
384
  - spec/stringutils_spec.rb