howzit 2.1.41 → 2.1.44

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: 30837683e82b813fd1fd8ff3e2df3177cbee3c735fc5afb2cb3394f08fb341eb
4
- data.tar.gz: 4a0113424a3e36169a0f7a66f31b6dcc3adc24785d9a1a54829db7d9d907c585
3
+ metadata.gz: 881188642b99f4be83a7697440251ec8890e6d847468c3984a72183939196857
4
+ data.tar.gz: c622b23318a075aa277a0a197e1ec93d8023e52e99018557e7eb6f8309c76927
5
5
  SHA512:
6
- metadata.gz: 1c0245bdb6e801d1efb96e800c04a7ac1177a589e9d25753322bb70a6690ce22b738fdfdc12956aae216565e93ea23b7cdcc14b6f278937e329b116b8d40b5cd
7
- data.tar.gz: 986c7c270fc4d8aa21c395fab7a1edaa5669c4a72e6160dede78e45c9c9b6065e69ca47c6c4253b1eae1ec2909d4e31224dee333ba74e6b392e937fe1f7f25ae
6
+ metadata.gz: 219ae789410d1ec44619f3bfb00b7f8b539a33632e9b9d9e778f384205e03c653efa83a7a090d469dd769a2cdda53ae7026e40b495363879e59f24be38da09ae
7
+ data.tar.gz: d0c1dd2d03ab11923e93440fb9f04bfdfb0292d63522fd21483a38a7d2ca31c691dc64e4a894063944b586ef1b2af66fd8a4bd55ea132ce743b6e69e7fd812db
data/CHANGELOG.md CHANGED
@@ -1,3 +1,67 @@
1
+ ### 2.1.44
2
+
3
+ 2026-09-27 11:38
4
+
5
+ #### CHANGED
6
+
7
+ - **Task failures** in a parent or subtopic stop the remaining subtopics from running unless --force is used.
8
+ - **@after blocks** on a parent are shown after the parent and all of its subtopics finish.
9
+
10
+ #### NEW
11
+
12
+ - **Subtopics**: deeper headers (### under ##, #### under ###, etc.) are now nested under the preceding shallower topic.
13
+ - **Displaying a parent topic** shows all of its subtopics after it, with subtopic headers drawn in a lighter style (cyan title, dotted rule).
14
+ - **Running a parent topic** runs its own tasks, then every subtopic's tasks in order (depth-first), with a single combined "Ran N tasks" summary.
15
+
16
+ #### IMPROVED
17
+
18
+ - **Showing all topics** (howzit with no arguments, or howzit -r with no default) only outputs top-level topics so subtopics are not repeated.
19
+ - **Selecting both a parent and its subtopic** outputs/runs the subtopic only once, as part of the parent.
20
+ - **Topic lists** (howzit -l and the runnable topics list) indent subtopics under their parents.
21
+ - **@include(Topic)** on a parent runs the parent and all of its subtopics, and task counts in include notes and menus now count subtopic tasks.
22
+
23
+ #### FIXED
24
+
25
+ - **Parent topics with no tasks of their own** no longer warn "No @directive found" when their subtopics contain tasks.
26
+
27
+ ### 2.1.43
28
+
29
+ 2026-09-27 11:35
30
+
31
+ #### CHANGED
32
+
33
+ - **Task failures** in a parent or subtopic stop the remaining subtopics from running unless --force is used.
34
+ - **@after blocks** on a parent are shown after the parent and all of its subtopics finish.
35
+
36
+ #### NEW
37
+
38
+ - **Subtopics**: deeper headers (### under ##, #### under ###, etc.) are now nested under the preceding shallower topic.
39
+ - **Displaying a parent topic** shows all of its subtopics after it, with subtopic headers drawn in a lighter style (cyan title, dotted rule).
40
+ - **Running a parent topic** runs its own tasks, then every subtopic's tasks in order (depth-first), with a single combined "Ran N tasks" summary.
41
+
42
+ #### IMPROVED
43
+
44
+ - **Showing all topics** (howzit with no arguments, or howzit -r with no default) only outputs top-level topics so subtopics are not repeated.
45
+ - **Selecting both a parent and its subtopic** outputs/runs the subtopic only once, as part of the parent.
46
+ - **Topic lists** (howzit -l and the runnable topics list) indent subtopics under their parents.
47
+ - **@include(Topic)** on a parent runs the parent and all of its subtopics, and task counts in include notes and menus now count subtopic tasks.
48
+
49
+ #### FIXED
50
+
51
+ - **Parent topics with no tasks of their own** no longer warn "No @directive found" when their subtopics contain tasks.
52
+
53
+ ### 2.1.42
54
+
55
+ 2026-06-29 06:52
56
+
57
+ #### NEW
58
+
59
+ - `includes:` metadata to pull topics from external build note files or project directories (comma-separated paths, optional `[topic | filter]` like templates); directory paths auto-detect the build note; included topics are prefixed by basename; local topics always win on duplicate names.
60
+
61
+ #### FIXED
62
+
63
+ - Paged build note output no longer opens with the first line scrolled off screen (pager receives the full buffer via IO.popen instead of starting on partial pipe input).
64
+
1
65
  ### 2.1.41
2
66
 
3
67
  2026-05-10 08:18
data/Rakefile CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rspec, rubocop plugins, and the .rubocop_todo.yml cops depend on the locked bundle
4
+ exec('bundle', 'exec', 'rake', *ARGV) unless ENV['BUNDLE_GEMFILE']
5
+
3
6
  # require 'bump/tasks'
4
7
  require 'English'
5
8
  require 'bundler/gem_tasks'
data/howzit.gemspec CHANGED
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
25
25
 
26
26
  spec.required_ruby_version = '>= 2.6.0'
27
27
 
28
- spec.add_development_dependency 'bundler', '~> 2.2'
28
+ spec.add_development_dependency 'bundler', '>= 2.2'
29
29
  spec.add_development_dependency 'rake', '~> 13.0'
30
30
 
31
31
  spec.add_development_dependency 'guard', '~> 2.11'
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module Howzit
4
6
  # BuildNote Class
5
7
  class BuildNote
@@ -12,9 +14,11 @@ module Howzit
12
14
  ##
13
15
  ## @param file [String] The path to the build note file
14
16
  ##
15
- def initialize(file: nil, meta: nil)
17
+ def initialize(file: nil, meta: nil, skip_includes: false, included_paths: nil)
16
18
  # Track if an explicit file was provided
17
19
  @explicit_file = file ? File.expand_path(file) : nil
20
+ @skip_includes = skip_includes
21
+ @included_paths = included_paths || Set.new
18
22
 
19
23
  # Set @note_file if an explicit file was provided, before calling note_file getter
20
24
  if file
@@ -165,11 +169,37 @@ module Howzit
165
169
  output = []
166
170
  output.push("{bg}Topics:{x}\n".c)
167
171
  @topics.each do |topic|
168
- output.push("- {bw}#{topic.title}{x}".c)
172
+ output.push("#{subtopic_indent(topic)}- {bw}#{topic.title}{x}".c)
169
173
  end
170
174
  output.join("\n")
171
175
  end
172
176
 
177
+ ##
178
+ ## Indentation for a topic in list output, based on
179
+ ## how many of its parent topics are in the list
180
+ ##
181
+ ## @param topic [Topic] The topic
182
+ ##
183
+ ## @return [String] leading whitespace
184
+ ##
185
+ def subtopic_indent(topic)
186
+ ' ' * topic.ancestors.count { |parent| @topics.any? { |t| t.equal?(parent) } }
187
+ end
188
+
189
+ ##
190
+ ## Topics that aren't nested under another topic in
191
+ ## the build note (subtopics are output with their parent)
192
+ ##
193
+ ## @param topics [Array] Topics to filter, defaults to all topics
194
+ ##
195
+ ## @return [Array] array of Topics
196
+ ##
197
+ def top_level_topics(topics = @topics)
198
+ topics.reject do |topic|
199
+ topic.ancestors.any? { |parent| topics.any? { |t| t.equal?(parent) } }
200
+ end
201
+ end
202
+
173
203
  ##
174
204
  ## Return an array of topic titles
175
205
  ##
@@ -200,7 +230,7 @@ module Howzit
200
230
  def list_runnable_completions
201
231
  output = []
202
232
  @topics.each do |topic|
203
- next unless topic.tasks.count.positive?
233
+ next unless topic.all_tasks.any?
204
234
 
205
235
  output.push(topic.title)
206
236
  end
@@ -218,11 +248,10 @@ module Howzit
218
248
  output.push(%({bg}"Runnable" Topics:{x}\n).c)
219
249
 
220
250
  find_topic(Howzit.options[:for_topic]).each do |topic|
221
- s_out = []
251
+ next if topic.all_tasks.empty?
222
252
 
223
- topic.tasks.each { |task| s_out.push(task.to_list) }
224
-
225
- next if s_out.empty?
253
+ indent = subtopic_indent(topic)
254
+ s_out = topic.tasks.map { |task| "#{indent}#{task.to_list}" }
226
255
 
227
256
  title = topic.title
228
257
  # Show argument definitions with colorized formatting
@@ -231,8 +260,8 @@ module Howzit
231
260
  title += " {l}({x}#{formatted_args}{l}){x}".c
232
261
  end
233
262
 
234
- output.push("- {g}#{title}{x}".c)
235
- output.push(s_out.join("\n"))
263
+ output.push("#{indent}- {g}#{title}{x}".c)
264
+ output.push(s_out.join("\n")) unless s_out.empty?
236
265
  end
237
266
 
238
267
  output.join("\n")
@@ -653,7 +682,7 @@ module Howzit
653
682
  ## @return [Array] extracted topics
654
683
  ##
655
684
  def read_template(template, file, subtopics = nil)
656
- note = BuildNote.new(file: file, meta: @metadata)
685
+ note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
657
686
 
658
687
  template_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
659
688
  template_topics.map do |topic|
@@ -751,6 +780,24 @@ module Howzit
751
780
  files.min
752
781
  end
753
782
 
783
+ ##
784
+ ## Glob a directory for a valid build note filename
785
+ ##
786
+ ## @param dir [String] Directory to search
787
+ ##
788
+ ## @return [String, nil] Absolute path to build note file
789
+ ##
790
+ def glob_note_in(dir)
791
+ pattern = File.join(File.expand_path(dir), '*.{txt,md,markdown}')
792
+ files = Dir.glob(pattern).select { |f| File.basename(f).build_note? }
793
+ return nil if files.empty?
794
+
795
+ priority_files = files.select { |f| File.basename(f) =~ /^(buildnotes|howzit)\./i }
796
+ return priority_files.min unless priority_files.empty?
797
+
798
+ files.min
799
+ end
800
+
754
801
  ##
755
802
  ## Search for a valid build note, checking current
756
803
  ## directory, git top level directory, and parent
@@ -794,26 +841,117 @@ module Howzit
794
841
  end
795
842
 
796
843
  ##
797
- ## Read a list of topics from an included template
844
+ ## Read a list of topics from included templates and includes: metadata
798
845
  ##
799
- ## @param content [String] The template contents
846
+ ## @param content [String] The build note contents
800
847
  ##
801
848
  def get_template_topics(content)
802
849
  leader = content.split(/^#/)[0].strip
803
850
 
804
- template_topics = []
851
+ external_topics = []
805
852
 
806
- return template_topics if leader.empty?
853
+ return external_topics if leader.empty?
807
854
 
808
855
  data = leader.metadata
809
856
 
810
857
  if data.key?('template')
811
858
  templates = data['template'].strip.split(/\s*,\s*/)
812
859
 
813
- template_topics.concat(gather_templates(templates))
860
+ external_topics.concat(gather_templates(templates))
814
861
  end
815
862
 
816
- template_topics
863
+ if data.key?('includes') && !@skip_includes
864
+ includes = data['includes'].strip.split(/\s*,\s*/)
865
+
866
+ external_topics.concat(gather_includes(includes))
867
+ end
868
+
869
+ external_topics
870
+ end
871
+
872
+ ##
873
+ ## Resolve an includes: metadata entry to a label and build note file path
874
+ ##
875
+ ## @param path_spec [String] File path, directory, or ~ path
876
+ ##
877
+ ## @return [Array, nil] [label, absolute_file_path] or nil if not found
878
+ ##
879
+ def resolve_include_path(path_spec)
880
+ path = File.expand_path(path_spec.to_s.strip)
881
+ return nil unless File.exist?(path)
882
+
883
+ if File.directory?(path)
884
+ note_path = glob_note_in(path)
885
+ return nil unless note_path
886
+
887
+ [File.basename(path), File.expand_path(note_path)]
888
+ elsif File.file?(path)
889
+ [File.basename(path, File.extname(path)), path]
890
+ end
891
+ end
892
+
893
+ ##
894
+ ## Read topics from an external build note referenced by includes: metadata
895
+ ##
896
+ def read_include(label, file, subtopics = nil)
897
+ note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
898
+ included_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
899
+ included_topics.map do |topic|
900
+ topic.parent = label
901
+ topic.content = topic.content.render_template(@metadata)
902
+ topic.instance_variable_set(:@title, "#{label}:#{topic.title}") unless topic.title.include?(':')
903
+ topic
904
+ end
905
+ end
906
+
907
+ ##
908
+ ## Load topics from includes: metadata paths (comma-separated)
909
+ ##
910
+ def gather_includes(includes)
911
+ included_topics = []
912
+
913
+ includes.each do |spec|
914
+ spec = spec.strip
915
+ next if spec.empty?
916
+
917
+ path_spec, subtopics = detect_subtopics(spec.dup)
918
+ resolved = resolve_include_path(path_spec)
919
+ unless resolved
920
+ Howzit.console.warn "{br}WARNING:{xr} includes: path not found or has no build note: {bw}#{path_spec}{x}".c
921
+ next
922
+ end
923
+
924
+ label, file = resolved
925
+ abs_file = File.expand_path(file)
926
+ main_file = @note_file ? File.expand_path(@note_file) : nil
927
+
928
+ if main_file && abs_file == main_file
929
+ Howzit.console.warn "{br}WARNING:{xr} includes: skipping self-reference: {bw}#{path_spec}{x}".c
930
+ next
931
+ end
932
+
933
+ if @included_paths.include?(abs_file)
934
+ Howzit.console.warn "{br}WARNING:{xr} includes: already loaded, skipping: {bw}#{path_spec}{x}".c
935
+ next
936
+ end
937
+
938
+ @included_paths.add(abs_file)
939
+ included_topics.concat(read_include(label, abs_file, subtopics))
940
+ end
941
+
942
+ included_topics
943
+ end
944
+
945
+ ##
946
+ ## Merge external topics (templates, includes) into local topics; local wins on duplicate names
947
+ ##
948
+ def merge_external_topics(local_topics, external_topics)
949
+ external_topics.each do |topic|
950
+ base = base_topic_name(topic.title)
951
+ exists_in_local = local_topics.any? { |t| base_topic_name(t.title) == base }
952
+ local_topics.push(topic) unless exists_in_local
953
+ end
954
+ local_topics
817
955
  end
818
956
 
819
957
  # Read in the build notes file and output a hash of
@@ -843,11 +981,13 @@ module Howzit
843
981
 
844
982
  template_topics = get_template_topics(help)
845
983
 
846
- split = help.split(/^##+/)
847
- split.slice!(0)
848
- split.each do |sect|
849
- next if sect.strip.empty?
984
+ sections = help.split(/^(##+)/)
985
+ sections.shift
986
+ parent_stack = []
987
+ sections.each_slice(2) do |marks, sect|
988
+ next if sect.nil? || sect.strip.empty?
850
989
 
990
+ level = marks.length
851
991
  lines = sect.split(/\n/)
852
992
  title = lines.slice!(0).strip
853
993
  prefix = ''
@@ -865,18 +1005,17 @@ module Howzit
865
1005
  # Ensure source_file is always an absolute path
866
1006
  source_file = path || note_file
867
1007
  source_file = File.expand_path(source_file) if source_file
868
- topic = Topic.new(title, prefix + lines.join("\n").strip.render_template(@metadata), @metadata, source_file: source_file)
1008
+ topic = Topic.new(title, prefix + lines.join("\n").strip.render_template(@metadata), @metadata,
1009
+ source_file: source_file, level: level)
1010
+
1011
+ parent_stack.pop while parent_stack.any? && parent_stack.last.level >= level
1012
+ parent_stack.last&.add_subtopic(topic)
1013
+ parent_stack.push(topic)
869
1014
 
870
1015
  topics.push(topic)
871
1016
  end
872
1017
 
873
- template_topics.each do |topic|
874
- # Check against local topics array, not @topics, to avoid filtering out templates
875
- # when @topics already has topics (e.g., in stack mode)
876
- topic_base = topic.title.sub(/^.+:/, '').strip.downcase
877
- exists_in_local = topics.any? { |t| t.title.sub(/^.+:/, '').strip.downcase == topic_base }
878
- topics.push(topic) unless exists_in_local
879
- end
1018
+ merge_external_topics(topics, template_topics)
880
1019
 
881
1020
  topics
882
1021
  end
@@ -1266,13 +1405,13 @@ module Howzit
1266
1405
  process_default_metadata(output)
1267
1406
  else
1268
1407
  Howzit.run_log = []
1269
- Howzit.multi_topic_run = topics.length > 1
1270
- topics.each { |k| output.push(process_topic(k, false, single: false)) }
1408
+ Howzit.multi_topic_run = top_level_topics.length > 1
1409
+ top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
1271
1410
  finalize_output(output)
1272
1411
  end
1273
1412
  else
1274
1413
  # Show all topics
1275
- topics.each { |k| output.push(process_topic(k, false, single: false)) }
1414
+ top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
1276
1415
  finalize_output(output)
1277
1416
  end
1278
1417
  end
@@ -1418,9 +1557,12 @@ module Howzit
1418
1557
  end
1419
1558
  end
1420
1559
  topic_matches.compact! # Remove any nil values from failed matches
1421
- topic_matches.each { |topic_match| output.push(process_topic(topic_match, Howzit.options[:run], single: true)) }
1560
+ # A matched subtopic is already output/run with its matched parent
1561
+ top_level_topics(topic_matches).each do |topic_match|
1562
+ output.push(process_topic(topic_match, Howzit.options[:run], single: true))
1563
+ end
1422
1564
  else
1423
- topics.each { |k| output.push(process_topic(k, false, single: false)) }
1565
+ top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
1424
1566
  end
1425
1567
 
1426
1568
  finalize_output(output)
data/lib/howzit/task.rb CHANGED
@@ -152,9 +152,9 @@ module Howzit
152
152
 
153
153
  Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{matches[0].title}{x}".c)
154
154
  output.concat(matches[0].run(nested: true))
155
- Howzit.console.info("{by}End include: #{matches[0].tasks.count} tasks{x}".c)
155
+ Howzit.console.info("{by}End include: #{matches[0].all_tasks.count} tasks{x}".c)
156
156
  @last_status = nil
157
- [output, matches[0].tasks.count]
157
+ [output, matches[0].all_tasks.count]
158
158
  end
159
159
 
160
160
  ##
data/lib/howzit/topic.rb CHANGED
@@ -5,9 +5,10 @@ module Howzit
5
5
  class Topic
6
6
  attr_writer :parent
7
7
 
8
- attr_accessor :content
8
+ attr_accessor :content, :parent_topic
9
9
 
10
- attr_reader :title, :tasks, :prereqs, :postreqs, :results, :named_args, :directives, :arg_definitions, :source_file
10
+ attr_reader :title, :tasks, :prereqs, :postreqs, :results, :named_args, :directives, :arg_definitions, :source_file,
11
+ :level, :subtopics
11
12
 
12
13
  ##
13
14
  ## Initialize a topic object
@@ -16,11 +17,15 @@ module Howzit
16
17
  ## @param content [String] The raw topic content
17
18
  ## @param metadata [Hash] Optional metadata hash
18
19
  ## @param source_file [String] Optional path to the build note file this topic came from
20
+ ## @param level [Integer] Markdown header level (2 for ##, 3 for ###, etc.)
19
21
  ##
20
- def initialize(title, content, metadata = nil, source_file: nil)
22
+ def initialize(title, content, metadata = nil, source_file: nil, level: 2)
21
23
  @title = title
22
24
  @content = content
23
25
  @parent = nil
26
+ @parent_topic = nil
27
+ @subtopics = []
28
+ @level = level
24
29
  @nest_level = 0
25
30
  @named_args = {}
26
31
  @metadata = metadata
@@ -32,9 +37,44 @@ module Howzit
32
37
  @results = { total: 0, success: 0, errors: 0, message: ''.c }
33
38
  end
34
39
 
40
+ ##
41
+ ## Nest a topic under this one (a deeper header following this topic)
42
+ ##
43
+ ## @param topic [Topic] The subtopic
44
+ ##
45
+ def add_subtopic(topic)
46
+ topic.parent_topic = self
47
+ @subtopics << topic
48
+ end
49
+
50
+ ##
51
+ ## Tasks from this topic and all nested subtopics, in run order
52
+ ##
53
+ ## @return [Array] Array of Task objects
54
+ ##
55
+ def all_tasks
56
+ @tasks + @subtopics.flat_map(&:all_tasks)
57
+ end
58
+
59
+ ##
60
+ ## Parent topics from nearest to outermost
61
+ ##
62
+ ## @return [Array] Array of Topic objects
63
+ ##
64
+ def ancestors
65
+ list = []
66
+ node = @parent_topic
67
+ while node
68
+ list << node
69
+ node = node.parent_topic
70
+ end
71
+ list
72
+ end
73
+
35
74
  # Get named arguments from title
36
- # from_cli_snapshot: use Howzit.cli_topic_positional_args (set once from argv after ` -- `) so earlier topics' gather_tasks
37
- # cannot clobber positional binding. Re-entrant calls (e.g. @include with [a,b]) pass false to use live Howzit.arguments.
75
+ # from_cli_snapshot: use Howzit.cli_topic_positional_args (argv after `--`) so earlier
76
+ # topics' gather_tasks cannot clobber positional binding. Re-entrant @include [a,b]
77
+ # calls pass false to use live Howzit.arguments.
38
78
  def arguments(from_cli_snapshot: false)
39
79
  @arg_definitions = []
40
80
  return unless @title =~ /\(.*?\) *$/
@@ -79,7 +119,7 @@ module Howzit
79
119
 
80
120
  def ask_task(task)
81
121
  note = if task.type == :include
82
- task_count = Howzit.buildnote.find_topic(task.action)[0].tasks.count
122
+ task_count = Howzit.buildnote.find_topic(task.action)[0].all_tasks.count
83
123
  " (#{task_count} tasks)"
84
124
  else
85
125
  ''
@@ -94,80 +134,129 @@ module Howzit
94
134
  60
95
135
  end
96
136
 
97
- # Handle run command, execute directives in topic
98
- def run(nested: false)
137
+ ##
138
+ ## Handle run command, execute directives in topic, then in each subtopic
139
+ ##
140
+ ## @param nested [Boolean] Suppress the summary message (topic is being included)
141
+ ## @param as_subtopic [Boolean] Topic is being run as part of a parent topic
142
+ ##
143
+ ## @return [Array] output lines
144
+ ##
145
+ def run(nested: false, as_subtopic: false)
146
+ @results = { total: 0, success: 0, errors: 0, message: ''.c }
99
147
  output = []
100
-
101
148
  cols = check_cols
102
149
 
103
- # Use sequential processing if we have directives with conditionals
104
- if @directives && @directives.any?(&:conditional?)
105
- return run_sequential(nested: nested, output: output, cols: cols)
150
+ confirm_prereqs(cols) if !@prereqs.empty? && (all_tasks.any? || sequential?)
151
+
152
+ if sequential?
153
+ run_sequential(output: output)
154
+ elsif @tasks.any?
155
+ run_tasks(output)
156
+ elsif all_tasks.empty? && !as_subtopic && !directives?
157
+ Howzit.console.warn "{r}--run: No {br}@directive{xr} found in {bw}#{@title}{x}".c
106
158
  end
107
159
 
108
- # Fall back to old behavior for backward compatibility
109
- # Note: @set_var directives are already processed in gather_tasks for non-sequential path
110
- # This section is kept for backward compatibility but shouldn't be needed
160
+ run_subtopics(output) unless halted?
111
161
 
112
- if @tasks.count.positive?
113
- unless @prereqs.empty?
114
- begin
115
- puts TTY::Box.frame("{by}#{@prereqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols)
116
- rescue Errno::EPIPE
117
- # Pipe closed, ignore
118
- end
119
- res = Prompt.yn('Have the above prerequisites been met?', default: true)
120
- Process.exit 1 unless res
162
+ if @results[:total].positive? || (all_tasks.any? && !sequential?)
163
+ @results[:message] += results_message
164
+ output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
165
+ end
121
166
 
122
- end
167
+ show_postreqs(cols)
123
168
 
124
- @tasks.each do |task|
125
- next if (task.optional || Howzit.options[:ask]) && !ask_task(task)
169
+ output
170
+ end
126
171
 
127
- run_output, total, success = task.run
172
+ ##
173
+ ## Whether the topic contains conditional directives and must be run sequentially
174
+ ##
175
+ def sequential?
176
+ @directives&.any?(&:conditional?) || false
177
+ end
128
178
 
129
- output.concat(run_output)
130
- @results[:total] += total
179
+ ##
180
+ ## Whether this topic or any subtopic contains directives,
181
+ ## including non-task directives like @set_var and @log_level
182
+ ##
183
+ def directives?
184
+ @directives&.any? || @subtopics.any?(&:directives?)
185
+ end
131
186
 
132
- if success
133
- @results[:success] += total
134
- else
135
- Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c
187
+ def halted?
188
+ @results[:errors].positive? && !Howzit.options[:force]
189
+ end
136
190
 
137
- @results[:errors] += total
191
+ def confirm_prereqs(cols)
192
+ begin
193
+ puts TTY::Box.frame("{by}#{@prereqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols)
194
+ rescue Errno::EPIPE
195
+ # Pipe closed, ignore
196
+ end
197
+ res = Prompt.yn('Have the above prerequisites been met?', default: true)
198
+ Process.exit 1 unless res
199
+ end
138
200
 
139
- break unless Howzit.options[:force]
140
- end
201
+ def show_postreqs(cols)
202
+ return if @postreqs.empty?
141
203
 
142
- log_task_result(task, success)
143
- end
204
+ # Apply variable substitution to postreqs content, then wrap each line individually to preserve structure
205
+ postreqs_content = @postreqs.join("\n\n").render_arguments
206
+ wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
207
+ puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
208
+ rescue Errno::EPIPE
209
+ # Pipe closed, ignore
210
+ end
144
211
 
145
- total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
146
- errors = "{bw}#{@results[:errors]}{by} #{@results[:errors] == 1 ? 'error' : 'errors'}".c
147
- @results[:message] += if @results[:errors].zero?
148
- "{bg}\u{2713} {by}Ran #{total}{x}".c
149
- elsif Howzit.options[:force]
150
- "{br}\u{2715} {by}Completed #{total} with #{errors}{x}".c
151
- else
152
- "{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
153
- end
212
+ def results_message
213
+ total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
214
+ errors = "{bw}#{@results[:errors]}{by} #{@results[:errors] == 1 ? 'error' : 'errors'}".c
215
+ if @results[:errors].zero?
216
+ "{bg}\u{2713} {by}Ran #{total}{x}".c
217
+ elsif Howzit.options[:force]
218
+ "{br}\u{2715} {by}Completed #{total} with #{errors}{x}".c
154
219
  else
155
- Howzit.console.warn "{r}--run: No {br}@directive{xr} found in {bw}#{@title}{x}".c
220
+ "{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
156
221
  end
222
+ end
223
+
224
+ ##
225
+ ## Run tasks without conditional evaluation
226
+ ##
227
+ def run_tasks(output)
228
+ @tasks.each do |task|
229
+ next if (task.optional || Howzit.options[:ask]) && !ask_task(task)
157
230
 
158
- output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
231
+ run_output, total, success = task.run
232
+
233
+ output.concat(run_output)
234
+ @results[:total] += total
235
+
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
159
240
 
160
- unless @postreqs.empty?
161
- begin
162
- # Apply variable substitution to postreqs content, then wrap each line individually to preserve structure
163
- postreqs_content = @postreqs.join("\n\n").render_arguments
164
- wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
165
- puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
166
- rescue Errno::EPIPE
167
- # Pipe closed, ignore
241
+ @results[:errors] += total
242
+
243
+ break unless Howzit.options[:force]
168
244
  end
245
+
246
+ log_task_result(task, success)
169
247
  end
248
+ output
249
+ end
170
250
 
251
+ ##
252
+ ## Run each subtopic in order, adding its results to this topic's
253
+ ##
254
+ def run_subtopics(output)
255
+ @subtopics.each do |sub|
256
+ output.concat(sub.run(nested: true, as_subtopic: true))
257
+ %i[total success errors].each { |key| @results[key] += sub.results[key] }
258
+ break if halted?
259
+ end
171
260
  output
172
261
  end
173
262
 
@@ -177,7 +266,7 @@ module Howzit
177
266
  end
178
267
 
179
268
  def colored_option(color, topic, keys)
180
- if topic.tasks.empty?
269
+ if topic.all_tasks.empty?
181
270
  ''
182
271
  else
183
272
  optional = keys[:optional] =~ /[?!]+/ ? true : false
@@ -301,7 +390,8 @@ module Howzit
301
390
  formatted_args = @arg_definitions.map { |arg| format_arg_definition(arg) }.join('{l}, '.c)
302
391
  header_title += " {l}({x}#{formatted_args}{l}){x}".c
303
392
  end
304
- output.push(header_title.format_header)
393
+ header_opts = opt[:subtopic] ? { color: '{bc}', hr: "\u{2508}" } : {}
394
+ output.push(header_title.format_header(header_opts))
305
395
  output.push('')
306
396
  end
307
397
  # Process conditional blocks first
@@ -332,6 +422,8 @@ module Howzit
332
422
  end
333
423
  Howzit.named_arguments = @named_args
334
424
  output.push('')
425
+ @subtopics.each { |sub| output.concat(sub.print_out(opt.merge(header: true, subtopic: true))) }
426
+ output
335
427
  end
336
428
 
337
429
  ##
@@ -726,7 +818,7 @@ module Howzit
726
818
  ##
727
819
  ## Run directives sequentially with conditional re-evaluation
728
820
  ##
729
- def run_sequential(nested: false, output: [], cols: 80)
821
+ def run_sequential(output: [])
730
822
  # Initialize conditional state
731
823
  conditional_state = {} # { index => { evaluated: bool, result: bool, matched_chain: bool } }
732
824
  directive_index = 0
@@ -736,16 +828,6 @@ module Howzit
736
828
  Howzit.named_arguments ||= {}
737
829
  Howzit.named_arguments.merge!(@named_args) if @named_args
738
830
 
739
- unless @prereqs.empty?
740
- begin
741
- puts TTY::Box.frame("{by}#{@prereqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols)
742
- rescue Errno::EPIPE
743
- # Pipe closed, ignore
744
- end
745
- res = Prompt.yn('Have the above prerequisites been met?', default: true)
746
- Process.exit 1 unless res
747
- end
748
-
749
831
  # Process directives sequentially
750
832
  while directive_index < @directives.length
751
833
  directive = @directives[directive_index]
@@ -904,30 +986,6 @@ module Howzit
904
986
  re_evaluate_conditionals(conditional_state, directive_index - 1, context)
905
987
  end
906
988
 
907
- if @results[:total].positive?
908
- total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
909
- errors = "{bw}#{@results[:errors]}{by} #{@results[:errors] == 1 ? 'error' : 'errors'}".c
910
- @results[:message] += if @results[:errors].zero?
911
- "{bg}\u{2713} {by}Ran #{total}{x}".c
912
- elsif Howzit.options[:force]
913
- "{br}\u{2715} {by}Completed #{total} with #{errors}{x}".c
914
- else
915
- "{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
916
- end
917
- output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
918
- end
919
-
920
- unless @postreqs.empty?
921
- begin
922
- # Apply variable substitution to postreqs content, then wrap each line individually to preserve structure
923
- postreqs_content = @postreqs.join("\n\n").render_arguments
924
- wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
925
- puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
926
- rescue Errno::EPIPE
927
- # Pipe closed, ignore
928
- end
929
- end
930
-
931
989
  output
932
990
  end
933
991
 
@@ -944,7 +1002,7 @@ module Howzit
944
1002
  last_idx = path_to_check.last
945
1003
  last_state = conditional_state[last_idx]
946
1004
  if last_state && %w[elsif else].include?(last_state[:directive_type])
947
- parent_if_idx = path_to_check[path_to_check.length - 2]
1005
+ parent_if_idx = path_to_check[-2]
948
1006
  parent_if_state = conditional_state[parent_if_idx]
949
1007
  if parent_if_state && %w[if unless].include?(parent_if_state[:directive_type])
950
1008
  path_to_check.delete(parent_if_idx)
data/lib/howzit/util.rb CHANGED
@@ -123,42 +123,29 @@ module Howzit
123
123
  rescue Errno::EPIPE
124
124
  # Pipe closed, ignore
125
125
  end
126
- return
126
+ return true
127
127
  end
128
128
 
129
- read_io, write_io = IO.pipe
130
-
131
- input = $stdin
132
-
133
- pid = Kernel.fork do
134
- write_io.close
135
- input.reopen(read_io)
136
- read_io.close
137
-
138
- # Wait until we have input before we start the pager
139
- IO.select [input]
140
-
141
- pager = which_pager
142
-
129
+ pager = which_pager
130
+ if pager.nil?
143
131
  begin
144
- exec(pager)
145
- rescue SystemCallError => e
146
- Howzit.console.error(e)
147
- exit 1
132
+ puts text
133
+ rescue Errno::EPIPE
134
+ # Pipe closed, ignore
148
135
  end
136
+ return true
149
137
  end
150
138
 
151
- read_io.close
152
- begin
153
- write_io.write(text)
139
+ # Write the full buffer to the pager stdin before closing it. The old
140
+ # fork + IO.select approach started less as soon as the first bytes
141
+ # arrived, which could leave the initial viewport scrolled past line 1.
142
+ IO.popen(pager, 'w') do |io|
143
+ io.write(text)
154
144
  rescue Errno::EPIPE
155
145
  # User quit pager before we finished writing, ignore
156
146
  end
157
- write_io.close
158
-
159
- _, status = Process.waitpid2(pid)
160
147
 
161
- status.success?
148
+ Process.last_status.success?
162
149
  end
163
150
 
164
151
  # Strip safely when external encoding rejects multibyte content (e.g. US-ASCII default).
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.41'
6
+ VERSION = '2.1.44'
7
7
  end
data/spec/cli_spec.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'cli-test'
4
5
 
5
6
  # https://github.com/thoiberg/cli-test
6
7
  describe 'CLI' do
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ describe 'includes: metadata' do
8
+ let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_includes_test')) }
9
+ let(:external_dir) { File.expand_path(File.join(temp_dir, 'external_project')) }
10
+ let(:main_dir) { File.expand_path(File.join(temp_dir, 'main_project')) }
11
+
12
+ before do
13
+ FileUtils.mkdir_p(external_dir)
14
+ FileUtils.mkdir_p(main_dir)
15
+
16
+ File.write(
17
+ File.join(external_dir, 'buildnotes.md'),
18
+ <<~NOTE
19
+ # External Project
20
+
21
+ ## Shared Topic
22
+
23
+ @run(echo external) External task
24
+
25
+ ## External Only
26
+
27
+ @run(echo only_external) Only in external
28
+ NOTE
29
+ )
30
+
31
+ File.write(
32
+ File.join(main_dir, 'buildnotes.md'),
33
+ <<~NOTE
34
+ includes: #{external_dir}
35
+
36
+ # Main Project
37
+
38
+ ## Shared Topic
39
+
40
+ @run(echo local) Local task wins
41
+
42
+ ## Main Only
43
+
44
+ @run(echo main) Main task
45
+ NOTE
46
+ )
47
+
48
+ Dir.chdir(main_dir)
49
+ Howzit.instance_variable_set(:@buildnote, nil)
50
+ Howzit.options[:stack] = false
51
+ Howzit.options[:include_upstream] = false
52
+ end
53
+
54
+ after do
55
+ Dir.chdir(Dir.tmpdir)
56
+ FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
57
+ Howzit.instance_variable_set(:@buildnote, nil)
58
+ end
59
+
60
+ it 'loads topics from an included project directory' do
61
+ buildnote = Howzit::BuildNote.new
62
+ external = buildnote.find_topic('External Only')
63
+
64
+ expect(external).not_to be_empty
65
+ expect(external[0].title).to eq('external_project:External Only')
66
+ end
67
+
68
+ it 'prefers local topics over included topics with the same name' do
69
+ buildnote = Howzit::BuildNote.new
70
+ matches = buildnote.find_topic('Shared Topic')
71
+
72
+ expect(matches.length).to eq(1)
73
+ expect(matches[0].title).to eq('Shared Topic')
74
+ expect(matches[0].tasks.first.action).to include('local')
75
+ end
76
+
77
+ it 'includes topics from an explicit file path' do
78
+ shared_file = File.join(temp_dir, 'shared.md')
79
+ File.write(
80
+ shared_file,
81
+ <<~NOTE
82
+ # Shared file
83
+
84
+ ## From File
85
+
86
+ @run(echo from_file) From file
87
+ NOTE
88
+ )
89
+
90
+ File.write(
91
+ File.join(main_dir, 'buildnotes.md'),
92
+ <<~NOTE
93
+ includes: #{shared_file}
94
+
95
+ # Main
96
+
97
+ ## Local
98
+ NOTE
99
+ )
100
+
101
+ Howzit.instance_variable_set(:@buildnote, nil)
102
+ buildnote = Howzit::BuildNote.new
103
+ topic = buildnote.find_topic('From File')[0]
104
+
105
+ expect(topic.title).to eq('shared:From File')
106
+ end
107
+
108
+ it 'supports subtopic filters like templates' do
109
+ File.write(
110
+ File.join(main_dir, 'buildnotes.md'),
111
+ <<~NOTE
112
+ includes: #{external_dir}[External Only]
113
+
114
+ # Main
115
+ NOTE
116
+ )
117
+
118
+ Howzit.instance_variable_set(:@buildnote, nil)
119
+ buildnote = Howzit::BuildNote.new
120
+
121
+ expect(buildnote.find_topic('External Only')).not_to be_empty
122
+ expect(buildnote.find_topic('Shared Topic')).to be_empty
123
+ end
124
+ end
data/spec/spec_helper.rb CHANGED
@@ -8,7 +8,6 @@ unless ENV['CI'] == 'true'
8
8
  end
9
9
 
10
10
  require 'howzit'
11
- require 'cli-test'
12
11
 
13
12
  RSpec.configure do |c|
14
13
  c.expect_with(:rspec) { |e| e.syntax = :expect }
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ describe 'subtopics' do
8
+ let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_subtopics_test')) }
9
+ let(:buildnote) { Howzit::BuildNote.new }
10
+ let(:parent) { buildnote.find_topic('Parent')[0] }
11
+
12
+ before do
13
+ File.write(
14
+ File.join(temp_dir, 'buildnotes.md'),
15
+ <<~NOTE
16
+ # Subtopic Project
17
+
18
+ ## Parent
19
+
20
+ Parent content
21
+
22
+ @run(echo parent) Parent task
23
+
24
+ ### Child One
25
+
26
+ @run(echo child1) Child one task
27
+
28
+ #### Grandchild
29
+
30
+ @run(echo grand) Grandchild task
31
+
32
+ ### Child Two
33
+
34
+ @run(echo child2) Child two task
35
+
36
+ ## Sibling
37
+
38
+ @run(echo sibling) Sibling task
39
+ NOTE
40
+ )
41
+
42
+ Dir.chdir(temp_dir)
43
+ Howzit.instance_variable_set(:@buildnote, nil)
44
+ Howzit.options[:stack] = false
45
+ Howzit.options[:include_upstream] = false
46
+ end
47
+
48
+ after do
49
+ Dir.chdir(Dir.tmpdir)
50
+ FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
51
+ Howzit.instance_variable_set(:@buildnote, nil)
52
+ end
53
+
54
+ def topic_named(title)
55
+ buildnote.topics.find { |t| t.title == title }
56
+ end
57
+
58
+ it 'nests deeper headers under the preceding shallower header' do
59
+ expect(parent.subtopics.map(&:title)).to eq(['Child One', 'Child Two'])
60
+ expect(topic_named('Child One').subtopics.map(&:title)).to eq(['Grandchild'])
61
+ expect(topic_named('Grandchild').ancestors.map(&:title)).to eq(['Child One', 'Parent'])
62
+ expect(topic_named('Sibling').parent_topic).to be_nil
63
+ end
64
+
65
+ it 'keeps subtopics selectable on their own' do
66
+ expect(buildnote.find_topic('Child Two')[0].title).to eq('Child Two')
67
+ end
68
+
69
+ it 'collects tasks from subtopics in order' do
70
+ expect(parent.all_tasks.map(&:title)).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
71
+ end
72
+
73
+ it 'displays subtopics with the parent topic' do
74
+ output = parent.print_out.join("\n").uncolor
75
+
76
+ expect(output).to include('Parent content')
77
+ expect(output).to include('Child One')
78
+ expect(output).to include('Grandchild')
79
+ expect(output).to include('echo child2')
80
+ expect(output).not_to include('Sibling')
81
+ end
82
+
83
+ it 'only outputs top-level topics when showing all topics' do
84
+ expect(buildnote.top_level_topics.map(&:title)).to eq(%w[Parent Sibling])
85
+ end
86
+
87
+ it 'indents subtopics in the topic list' do
88
+ list = buildnote.list.uncolor
89
+
90
+ expect(list).to include("\n- Parent")
91
+ expect(list).to include("\n - Child One")
92
+ expect(list).to include("\n - Grandchild")
93
+ end
94
+
95
+ describe 'running a parent topic' do
96
+ let(:ran) { [] }
97
+
98
+ it 'runs its own tasks followed by all subtopic tasks' do
99
+ ran_titles = ran
100
+ allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
101
+ ran_titles << task.title
102
+ [[], 1, true]
103
+ end
104
+
105
+ parent.run
106
+
107
+ expect(ran).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
108
+ expect(parent.results[:total]).to eq(4)
109
+ end
110
+
111
+ it 'stops running subtopics after an error' do
112
+ ran_titles = ran
113
+ allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
114
+ ran_titles << task.title
115
+ [[], 1, task.title != 'Child one task']
116
+ end
117
+
118
+ parent.run
119
+
120
+ expect(ran).to eq(['Parent task', 'Child one task'])
121
+ expect(parent.results[:errors]).to eq(1)
122
+ end
123
+
124
+ it 'continues past errors with --force' do
125
+ Howzit.options[:force] = true
126
+ ran_titles = ran
127
+ allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
128
+ ran_titles << task.title
129
+ [[], 1, task.title != 'Child one task']
130
+ end
131
+
132
+ parent.run
133
+
134
+ expect(ran).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
135
+ ensure
136
+ Howzit.options[:force] = false
137
+ end
138
+ end
139
+ 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.41
4
+ version: 2.1.44
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -13,14 +13,14 @@ dependencies:
13
13
  name: bundler
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '2.2'
19
19
  type: :development
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - "~>"
23
+ - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '2.2'
26
26
  - !ruby/object:Gem::Dependency
@@ -320,6 +320,7 @@ files:
320
320
  - spec/condition_evaluator_spec.rb
321
321
  - spec/conditional_blocks_integration_spec.rb
322
322
  - spec/conditional_content_spec.rb
323
+ - spec/includes_metadata_spec.rb
323
324
  - spec/log_level_spec.rb
324
325
  - spec/prompt_spec.rb
325
326
  - spec/ruby_gem_spec.rb
@@ -330,6 +331,7 @@ files:
330
331
  - spec/spec_helper.rb
331
332
  - spec/stack_mode_spec.rb
332
333
  - spec/stringutils_spec.rb
334
+ - spec/subtopics_spec.rb
333
335
  - spec/task_spec.rb
334
336
  - spec/topic_positional_snapshot_spec.rb
335
337
  - spec/topic_spec.rb
@@ -355,7 +357,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
355
357
  - !ruby/object:Gem::Version
356
358
  version: '0'
357
359
  requirements: []
358
- rubygems_version: 3.6.7
360
+ rubygems_version: 4.0.18
359
361
  specification_version: 4
360
362
  summary: Provides a way to access Markdown project notes by topic with query capabilities
361
363
  and the ability to execute the tasks it describes.
@@ -365,6 +367,7 @@ test_files:
365
367
  - spec/condition_evaluator_spec.rb
366
368
  - spec/conditional_blocks_integration_spec.rb
367
369
  - spec/conditional_content_spec.rb
370
+ - spec/includes_metadata_spec.rb
368
371
  - spec/log_level_spec.rb
369
372
  - spec/prompt_spec.rb
370
373
  - spec/ruby_gem_spec.rb
@@ -375,6 +378,7 @@ test_files:
375
378
  - spec/spec_helper.rb
376
379
  - spec/stack_mode_spec.rb
377
380
  - spec/stringutils_spec.rb
381
+ - spec/subtopics_spec.rb
378
382
  - spec/task_spec.rb
379
383
  - spec/topic_positional_snapshot_spec.rb
380
384
  - spec/topic_spec.rb