howzit 2.1.41 → 2.1.42
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 +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/howzit/buildnote.rb +122 -15
- data/lib/howzit/topic.rb +4 -3
- data/lib/howzit/util.rb +13 -26
- data/lib/howzit/version.rb +1 -1
- data/spec/cli_spec.rb +1 -0
- data/spec/includes_metadata_spec.rb +124 -0
- data/spec/spec_helper.rb +0 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8916b2ac37cd920db8008fe7a9aa28a80b4c5265083b4b257fb8ea1113938efb
|
|
4
|
+
data.tar.gz: 9486d6ca52f82ea5059bffd0de58333b82eedfc623d03f6325817caf8c956d4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5819786618c45538e42f21243102043b1ed408f516b0b946adaa9a2819d0080c2804cba47987f4ba0ed98b21ad7499d42cc825c2f89efb63cc4f6d8013bd743d
|
|
7
|
+
data.tar.gz: 2a90edf5e41602e1c6fc50ab21a6bfc3531b598f3a5cc381492f284bbb588b2590589fbc23da0f007a021605d46f7246e2908eed89895c175c24838b4755b285
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
### 2.1.42
|
|
2
|
+
|
|
3
|
+
2026-06-29 06:52
|
|
4
|
+
|
|
5
|
+
#### NEW
|
|
6
|
+
|
|
7
|
+
- `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.
|
|
8
|
+
|
|
9
|
+
#### FIXED
|
|
10
|
+
|
|
11
|
+
- 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).
|
|
12
|
+
|
|
1
13
|
### 2.1.41
|
|
2
14
|
|
|
3
15
|
2026-05-10 08:18
|
data/lib/howzit/buildnote.rb
CHANGED
|
@@ -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
|
|
@@ -653,7 +657,7 @@ module Howzit
|
|
|
653
657
|
## @return [Array] extracted topics
|
|
654
658
|
##
|
|
655
659
|
def read_template(template, file, subtopics = nil)
|
|
656
|
-
note = BuildNote.new(file: file, meta: @metadata)
|
|
660
|
+
note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
|
|
657
661
|
|
|
658
662
|
template_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
|
|
659
663
|
template_topics.map do |topic|
|
|
@@ -751,6 +755,24 @@ module Howzit
|
|
|
751
755
|
files.min
|
|
752
756
|
end
|
|
753
757
|
|
|
758
|
+
##
|
|
759
|
+
## Glob a directory for a valid build note filename
|
|
760
|
+
##
|
|
761
|
+
## @param dir [String] Directory to search
|
|
762
|
+
##
|
|
763
|
+
## @return [String, nil] Absolute path to build note file
|
|
764
|
+
##
|
|
765
|
+
def glob_note_in(dir)
|
|
766
|
+
pattern = File.join(File.expand_path(dir), '*.{txt,md,markdown}')
|
|
767
|
+
files = Dir.glob(pattern).select { |f| File.basename(f).build_note? }
|
|
768
|
+
return nil if files.empty?
|
|
769
|
+
|
|
770
|
+
priority_files = files.select { |f| File.basename(f) =~ /^(buildnotes|howzit)\./i }
|
|
771
|
+
return priority_files.min unless priority_files.empty?
|
|
772
|
+
|
|
773
|
+
files.min
|
|
774
|
+
end
|
|
775
|
+
|
|
754
776
|
##
|
|
755
777
|
## Search for a valid build note, checking current
|
|
756
778
|
## directory, git top level directory, and parent
|
|
@@ -794,26 +816,117 @@ module Howzit
|
|
|
794
816
|
end
|
|
795
817
|
|
|
796
818
|
##
|
|
797
|
-
## Read a list of topics from
|
|
819
|
+
## Read a list of topics from included templates and includes: metadata
|
|
798
820
|
##
|
|
799
|
-
## @param content [String] The
|
|
821
|
+
## @param content [String] The build note contents
|
|
800
822
|
##
|
|
801
823
|
def get_template_topics(content)
|
|
802
824
|
leader = content.split(/^#/)[0].strip
|
|
803
825
|
|
|
804
|
-
|
|
826
|
+
external_topics = []
|
|
805
827
|
|
|
806
|
-
return
|
|
828
|
+
return external_topics if leader.empty?
|
|
807
829
|
|
|
808
830
|
data = leader.metadata
|
|
809
831
|
|
|
810
832
|
if data.key?('template')
|
|
811
833
|
templates = data['template'].strip.split(/\s*,\s*/)
|
|
812
834
|
|
|
813
|
-
|
|
835
|
+
external_topics.concat(gather_templates(templates))
|
|
814
836
|
end
|
|
815
837
|
|
|
816
|
-
|
|
838
|
+
if data.key?('includes') && !@skip_includes
|
|
839
|
+
includes = data['includes'].strip.split(/\s*,\s*/)
|
|
840
|
+
|
|
841
|
+
external_topics.concat(gather_includes(includes))
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
external_topics
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
##
|
|
848
|
+
## Resolve an includes: metadata entry to a label and build note file path
|
|
849
|
+
##
|
|
850
|
+
## @param path_spec [String] File path, directory, or ~ path
|
|
851
|
+
##
|
|
852
|
+
## @return [Array, nil] [label, absolute_file_path] or nil if not found
|
|
853
|
+
##
|
|
854
|
+
def resolve_include_path(path_spec)
|
|
855
|
+
path = File.expand_path(path_spec.to_s.strip)
|
|
856
|
+
return nil unless File.exist?(path)
|
|
857
|
+
|
|
858
|
+
if File.directory?(path)
|
|
859
|
+
note_path = glob_note_in(path)
|
|
860
|
+
return nil unless note_path
|
|
861
|
+
|
|
862
|
+
[File.basename(path), File.expand_path(note_path)]
|
|
863
|
+
elsif File.file?(path)
|
|
864
|
+
[File.basename(path, File.extname(path)), path]
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
##
|
|
869
|
+
## Read topics from an external build note referenced by includes: metadata
|
|
870
|
+
##
|
|
871
|
+
def read_include(label, file, subtopics = nil)
|
|
872
|
+
note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
|
|
873
|
+
included_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
|
|
874
|
+
included_topics.map do |topic|
|
|
875
|
+
topic.parent = label
|
|
876
|
+
topic.content = topic.content.render_template(@metadata)
|
|
877
|
+
topic.instance_variable_set(:@title, "#{label}:#{topic.title}") unless topic.title.include?(':')
|
|
878
|
+
topic
|
|
879
|
+
end
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
##
|
|
883
|
+
## Load topics from includes: metadata paths (comma-separated)
|
|
884
|
+
##
|
|
885
|
+
def gather_includes(includes)
|
|
886
|
+
included_topics = []
|
|
887
|
+
|
|
888
|
+
includes.each do |spec|
|
|
889
|
+
spec = spec.strip
|
|
890
|
+
next if spec.empty?
|
|
891
|
+
|
|
892
|
+
path_spec, subtopics = detect_subtopics(spec.dup)
|
|
893
|
+
resolved = resolve_include_path(path_spec)
|
|
894
|
+
unless resolved
|
|
895
|
+
Howzit.console.warn "{br}WARNING:{xr} includes: path not found or has no build note: {bw}#{path_spec}{x}".c
|
|
896
|
+
next
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
label, file = resolved
|
|
900
|
+
abs_file = File.expand_path(file)
|
|
901
|
+
main_file = @note_file ? File.expand_path(@note_file) : nil
|
|
902
|
+
|
|
903
|
+
if main_file && abs_file == main_file
|
|
904
|
+
Howzit.console.warn "{br}WARNING:{xr} includes: skipping self-reference: {bw}#{path_spec}{x}".c
|
|
905
|
+
next
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
if @included_paths.include?(abs_file)
|
|
909
|
+
Howzit.console.warn "{br}WARNING:{xr} includes: already loaded, skipping: {bw}#{path_spec}{x}".c
|
|
910
|
+
next
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
@included_paths.add(abs_file)
|
|
914
|
+
included_topics.concat(read_include(label, abs_file, subtopics))
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
included_topics
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
##
|
|
921
|
+
## Merge external topics (templates, includes) into local topics; local wins on duplicate names
|
|
922
|
+
##
|
|
923
|
+
def merge_external_topics(local_topics, external_topics)
|
|
924
|
+
external_topics.each do |topic|
|
|
925
|
+
base = base_topic_name(topic.title)
|
|
926
|
+
exists_in_local = local_topics.any? { |t| base_topic_name(t.title) == base }
|
|
927
|
+
local_topics.push(topic) unless exists_in_local
|
|
928
|
+
end
|
|
929
|
+
local_topics
|
|
817
930
|
end
|
|
818
931
|
|
|
819
932
|
# Read in the build notes file and output a hash of
|
|
@@ -870,13 +983,7 @@ module Howzit
|
|
|
870
983
|
topics.push(topic)
|
|
871
984
|
end
|
|
872
985
|
|
|
873
|
-
template_topics
|
|
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
|
|
986
|
+
merge_external_topics(topics, template_topics)
|
|
880
987
|
|
|
881
988
|
topics
|
|
882
989
|
end
|
data/lib/howzit/topic.rb
CHANGED
|
@@ -33,8 +33,9 @@ module Howzit
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# Get named arguments from title
|
|
36
|
-
# from_cli_snapshot: use Howzit.cli_topic_positional_args (
|
|
37
|
-
# cannot clobber positional binding. Re-entrant
|
|
36
|
+
# from_cli_snapshot: use Howzit.cli_topic_positional_args (argv after `--`) so earlier
|
|
37
|
+
# topics' gather_tasks cannot clobber positional binding. Re-entrant @include [a,b]
|
|
38
|
+
# calls pass false to use live Howzit.arguments.
|
|
38
39
|
def arguments(from_cli_snapshot: false)
|
|
39
40
|
@arg_definitions = []
|
|
40
41
|
return unless @title =~ /\(.*?\) *$/
|
|
@@ -944,7 +945,7 @@ module Howzit
|
|
|
944
945
|
last_idx = path_to_check.last
|
|
945
946
|
last_state = conditional_state[last_idx]
|
|
946
947
|
if last_state && %w[elsif else].include?(last_state[:directive_type])
|
|
947
|
-
parent_if_idx = path_to_check[
|
|
948
|
+
parent_if_idx = path_to_check[-2]
|
|
948
949
|
parent_if_state = conditional_state[parent_if_idx]
|
|
949
950
|
if parent_if_state && %w[if unless].include?(parent_if_state[:directive_type])
|
|
950
951
|
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
|
-
|
|
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
|
-
|
|
145
|
-
rescue
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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).
|
data/lib/howzit/version.rb
CHANGED
data/spec/cli_spec.rb
CHANGED
|
@@ -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
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.
|
|
4
|
+
version: 2.1.42
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brett Terpstra
|
|
@@ -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
|
|
@@ -365,6 +366,7 @@ test_files:
|
|
|
365
366
|
- spec/condition_evaluator_spec.rb
|
|
366
367
|
- spec/conditional_blocks_integration_spec.rb
|
|
367
368
|
- spec/conditional_content_spec.rb
|
|
369
|
+
- spec/includes_metadata_spec.rb
|
|
368
370
|
- spec/log_level_spec.rb
|
|
369
371
|
- spec/prompt_spec.rb
|
|
370
372
|
- spec/ruby_gem_spec.rb
|