meshtastic 0.0.179 → 0.0.181

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/README.md +3 -2
  4. data/documentation/admin-channel.md +64 -0
  5. data/documentation/admin-config.md +59 -0
  6. data/documentation/admin-firmware-nordic.md +86 -0
  7. data/documentation/admin-firmware-serial.md +180 -0
  8. data/documentation/admin-firmware.md +126 -0
  9. data/documentation/admin.md +88 -35
  10. data/documentation/atak.md +93 -10
  11. data/documentation/channel.md +1 -28
  12. data/documentation/config.md +1 -27
  13. data/documentation/mesh-interface.md +7 -0
  14. data/documentation/meshtastic.md +2 -2
  15. data/lib/meshtastic/admin/channel.rb +164 -0
  16. data/lib/meshtastic/admin/config.rb +213 -0
  17. data/lib/meshtastic/admin/firmware/ble.rb +207 -0
  18. data/lib/meshtastic/admin/firmware/nordic_dfu.rb +218 -0
  19. data/lib/meshtastic/admin/firmware/serial_bootloader.rb +233 -0
  20. data/lib/meshtastic/admin/firmware.rb +288 -0
  21. data/lib/meshtastic/admin.rb +688 -34
  22. data/lib/meshtastic/apponly.rb +19 -7
  23. data/lib/meshtastic/atak.rb +295 -17
  24. data/lib/meshtastic/bluetooth/bluez.rb +20 -1
  25. data/lib/meshtastic/bluetooth.rb +70 -59
  26. data/lib/meshtastic/cannedmessages.rb +13 -6
  27. data/lib/meshtastic/channel.rb +3 -25
  28. data/lib/meshtastic/clientonly.rb +19 -7
  29. data/lib/meshtastic/config.rb +3 -24
  30. data/lib/meshtastic/config_pb.rb +2 -1
  31. data/lib/meshtastic/connection_status.rb +12 -5
  32. data/lib/meshtastic/deviceonly.rb +19 -7
  33. data/lib/meshtastic/localonly.rb +19 -7
  34. data/lib/meshtastic/mesh_interface.rb +19 -0
  35. data/lib/meshtastic/module_config.rb +13 -7
  36. data/lib/meshtastic/mqtt.rb +72 -35
  37. data/lib/meshtastic/paxcount.rb +17 -6
  38. data/lib/meshtastic/portnums.rb +13 -6
  39. data/lib/meshtastic/position.rb +16 -6
  40. data/lib/meshtastic/remote_hardware.rb +27 -11
  41. data/lib/meshtastic/rtttl.rb +17 -8
  42. data/lib/meshtastic/serial.rb +81 -70
  43. data/lib/meshtastic/storeforward.rb +13 -6
  44. data/lib/meshtastic/storeforward_pb.rb +1 -1
  45. data/lib/meshtastic/stream_interface.rb +11 -0
  46. data/lib/meshtastic/tcp.rb +73 -22
  47. data/lib/meshtastic/telemetry.rb +15 -6
  48. data/lib/meshtastic/traceroute.rb +15 -6
  49. data/lib/meshtastic/util.rb +3 -1
  50. data/lib/meshtastic/version.rb +1 -1
  51. data/lib/meshtastic/xmodem.rb +12 -5
  52. data/lib/meshtastic.rb +22 -3
  53. data/spec/conventions_spec.rb +321 -0
  54. data/spec/lib/meshtastic/admin/channel_spec.rb +202 -0
  55. data/spec/lib/meshtastic/admin/config_spec.rb +114 -0
  56. data/spec/lib/meshtastic/admin/firmware/ble_spec.rb +170 -0
  57. data/spec/lib/meshtastic/admin/firmware/nordic_dfu_spec.rb +248 -0
  58. data/spec/lib/meshtastic/admin/firmware/serial_bootloader_spec.rb +263 -0
  59. data/spec/lib/meshtastic/admin/firmware_spec.rb +315 -0
  60. data/spec/lib/meshtastic/admin_spec.rb +412 -1
  61. data/spec/lib/meshtastic/apponly_spec.rb +1 -1
  62. data/spec/lib/meshtastic/atak_spec.rb +129 -4
  63. data/spec/lib/meshtastic/bluetooth_spec.rb +1 -1
  64. data/spec/lib/meshtastic/channel_spec.rb +2 -20
  65. data/spec/lib/meshtastic/config_spec.rb +2 -20
  66. data/spec/lib/meshtastic/connection_status_spec.rb +1 -1
  67. data/spec/lib/meshtastic/deviceonly_spec.rb +1 -1
  68. data/spec/lib/meshtastic/localonly_spec.rb +1 -1
  69. data/spec/lib/meshtastic/mesh_interface_spec.rb +31 -0
  70. data/spec/lib/meshtastic/portnums_spec.rb +2 -2
  71. metadata +24 -6
@@ -4,13 +4,13 @@ require 'meshtastic/telemetry_pb'
4
4
 
5
5
  module Meshtastic
6
6
  class Telemetry
7
- def self.build(opts = {})
7
+ public_class_method def self.build(opts = {})
8
8
  telemetry = new
9
9
  telemetry.time = opts[:time].to_i if opts[:time]
10
10
  telemetry
11
11
  end
12
12
 
13
- def self.request(opts = {})
13
+ public_class_method def self.request(opts = {})
14
14
  data = Meshtastic::Data.new(
15
15
  portnum: :TELEMETRY_APP,
16
16
  payload: build(opts).to_proto,
@@ -19,14 +19,23 @@ module Meshtastic
19
19
  Meshtastic.deliver_data(opts.merge(data: data, port_num: Meshtastic::PortNum::TELEMETRY_APP, want_response: true))
20
20
  end
21
21
 
22
- def self.authors
22
+ public_class_method def self.authors
23
23
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
24
24
  end
25
25
 
26
- def self.help
27
- puts "USAGE:
28
- #{self}.request(serial_obj: serial_obj, to: '!aabbccdd')
26
+ public_class_method def self.help
27
+ puts " USAGE:
28
+ # Run the build class method for this module.
29
+ #{self}.build(
30
+ time: 'optional - value for time passed into build'
31
+ )
32
+
33
+ # Run the request class method for this module.
34
+ #{self}.request
35
+
36
+ # Run the authors class method for this module.
29
37
  #{self}.authors
38
+
30
39
  "
31
40
  end
32
41
  end
@@ -4,13 +4,13 @@ require 'meshtastic/mesh_pb'
4
4
 
5
5
  module Meshtastic
6
6
  module Traceroute
7
- def self.encode(opts = {})
7
+ public_class_method def self.encode(opts = {})
8
8
  discovery = Meshtastic::RouteDiscovery.new
9
9
  Array(opts[:route]).each { |hop| discovery.route << hop }
10
10
  discovery
11
11
  end
12
12
 
13
- def self.send(opts = {})
13
+ public_class_method def self.send(opts = {})
14
14
  data = Meshtastic::Data.new(
15
15
  portnum: :TRACEROUTE_APP,
16
16
  payload: encode(opts).to_proto,
@@ -19,14 +19,23 @@ module Meshtastic
19
19
  Meshtastic.deliver_data(opts.merge(data: data, port_num: Meshtastic::PortNum::TRACEROUTE_APP, want_response: true))
20
20
  end
21
21
 
22
- def self.authors
22
+ public_class_method def self.authors
23
23
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
24
24
  end
25
25
 
26
- def self.help
27
- puts "USAGE:
28
- #{self}.send(serial_obj: serial_obj, to: '!aabbccdd')
26
+ public_class_method def self.help
27
+ puts " USAGE:
28
+ # Run the encode class method for this module.
29
+ #{self}.encode(
30
+ route: 'optional - value for route passed into encode'
31
+ )
32
+
33
+ # Run the send class method for this module.
34
+ #{self}.send
35
+
36
+ # Run the authors class method for this module.
29
37
  #{self}.authors
38
+
30
39
  "
31
40
  end
32
41
  end
@@ -62,8 +62,10 @@ module Meshtastic
62
62
  # Display Usage for this Module
63
63
 
64
64
  public_class_method def self.help
65
- puts "USAGE:
65
+ puts " USAGE:
66
+ # Run the authors class method for this module.
66
67
  #{self}.authors
68
+
67
69
  "
68
70
  end
69
71
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Meshtastic
4
- VERSION = '0.0.179'
4
+ VERSION = '0.0.181'
5
5
  end
@@ -4,7 +4,7 @@ require 'meshtastic/xmodem_pb'
4
4
 
5
5
  module Meshtastic
6
6
  module Xmodem
7
- def self.encode(opts = {})
7
+ public_class_method def self.encode(opts = {})
8
8
  packet = Meshtastic::XModem.new
9
9
  packet.control = opts.fetch(:control, :SOH)
10
10
  packet.seq = opts[:seq].to_i if opts[:seq]
@@ -12,14 +12,21 @@ module Meshtastic
12
12
  packet
13
13
  end
14
14
 
15
- def self.authors
15
+ public_class_method def self.authors
16
16
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
17
17
  end
18
18
 
19
- def self.help
20
- puts "USAGE:
21
- #{self}.encode(control: :SOH, seq: 1, buffer: data)
19
+ public_class_method def self.help
20
+ puts " USAGE:
21
+ # Run the encode class method for this module.
22
+ #{self}.encode(
23
+ seq: 'optional - value for seq passed into encode',
24
+ buffer: 'optional - value for buffer passed into encode'
25
+ )
26
+
27
+ # Run the authors class method for this module.
22
28
  #{self}.authors
29
+
23
30
  "
24
31
  end
25
32
  end
data/lib/meshtastic.rb CHANGED
@@ -67,11 +67,28 @@ module Meshtastic
67
67
 
68
68
  # Display a List of Every Meshtastic Module
69
69
 
70
+ public_class_method def self.authors
71
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
72
+ end
73
+
70
74
  public_class_method def self.help
71
- constants.sort
75
+ puts " USAGE:
76
+ # Run the deliver_data class method for this module.
77
+ #{self}.deliver_data(
78
+ data: 'optional - value for data passed into deliver_data',
79
+ serial_obj: 'optional - value for serial_obj passed into deliver_data',
80
+ bluetooth_obj: 'optional - value for bluetooth_obj passed into deliver_data',
81
+ tcp_obj: 'optional - value for tcp_obj passed into deliver_data',
82
+ mqtt_obj: 'optional - value for mqtt_obj passed into deliver_data'
83
+ )
84
+
85
+ # Run the authors class method for this module.
86
+ #{self}.authors
87
+
88
+ "
72
89
  end
73
90
 
74
- def self.deliver_data(opts = {})
91
+ public_class_method def self.deliver_data(opts = {})
75
92
  raise ArgumentError, 'data is required' unless opts[:data].is_a?(Meshtastic::Data)
76
93
 
77
94
  if opts[:serial_obj]
@@ -80,8 +97,10 @@ module Meshtastic
80
97
  Bluetooth.send_data(opts)
81
98
  elsif opts[:tcp_obj]
82
99
  TCP.send_data(opts)
100
+ elsif opts[:mqtt_obj]
101
+ MQTT.send_data(opts)
83
102
  else
84
- raise ArgumentError, 'serial_obj, bluetooth_obj, or tcp_obj is required'
103
+ raise ArgumentError, 'serial_obj, bluetooth_obj, tcp_obj, or mqtt_obj is required'
85
104
  end
86
105
  end
87
106
  end
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Global source-level conventions for every Ruby module under lib/meshtastic
6
+ # (same rules as /opt/pwn/spec/conventions_spec.rb).
7
+ #
8
+ # RULES
9
+ # 0. Every method on a Meshtastic namespace *module* MUST be referenced via
10
+ # `self.<method_name>` with public_class_method / private_class_method.
11
+ # Bare `def foo` at module scope fails. So does `module_function`.
12
+ # Nested `class` instance methods and class_eval / `class <<` are exempt.
13
+ # 1. Module methods MUST be declared with an explicit visibility decorator
14
+ # immediately preceding `def self.<name>` on the same line.
15
+ # 2. Methods that accept arguments MUST take exactly `(opts = {})`.
16
+ # 3. Methods declared `(opts = {})` MUST consume opts in the body.
17
+ # 4. Every leaf module file MUST define `def self.help`.
18
+ # 5. Every leaf module file MUST define `def self.authors`.
19
+ # 6. Every public class method except `help` MUST appear in that module's
20
+ # `help` method — including `authors`. Directly above each method, `help`
21
+ # MUST print a brief purpose line. Methods that take `(opts = {})` MUST
22
+ # document each `opts[:key]` they read as `required` or `optional` plus an
23
+ # operator-usable description — same shape as PWN::WWW::Google.help.
24
+
25
+ module MeshtasticConventions
26
+ ROOT = File.expand_path('..', __dir__)
27
+ LIB = File.join(ROOT, 'lib', 'meshtastic')
28
+
29
+ GLOBAL_ALLOWLIST = [
30
+ 'lib/meshtastic/version.rb' # VERSION constant only
31
+ ].freeze
32
+
33
+ NAMESPACE_INDEX_FILES = [].freeze
34
+
35
+ RB_FILES = (
36
+ Dir[File.join(LIB, '**', '*.rb')] + [File.join(ROOT, 'lib', 'meshtastic.rb')]
37
+ ).reject do |f|
38
+ rel = f.sub("#{ROOT}/", '')
39
+ GLOBAL_ALLOWLIST.include?(rel) || rel.end_with?('_pb.rb')
40
+ end.sort.freeze
41
+
42
+ MODULE_FILES = RB_FILES.select { |f| File.read(f).match?(/^\s*module\s+\w/) }.freeze
43
+
44
+ SELF_DEF_RE = /^([ \t]*)((?:public_class_method|private_class_method)\s+)?def self\.([a-z_][\w?!]*)(?:\(([^)]*)\))?/
45
+
46
+ BARE_DEF_RE = /^[ \t]*def ([a-z_][\w?!]*)(?:\(([^)]*)\))?/
47
+
48
+ MODULE_FUNCTION_RE = /^\s*module_function\b/
49
+
50
+ CLASS_LIKE_OPEN_RE = /
51
+ \Aclass\b
52
+ | \.class_eval\b
53
+ | \.module_eval\b
54
+ | \Aclass_eval\b
55
+ | \Amodule_eval\b
56
+ | \Aclass\s*<<
57
+ | \bClass\.new\b
58
+ | \bStruct\.new\b
59
+ | \bModule\.new\b
60
+ | \bData\.define\b
61
+ /x
62
+
63
+ MODULE_OPEN_RE = /\Amodule\s+[A-Z]/
64
+
65
+ module_function
66
+
67
+ def line_indent(line)
68
+ line[/^[ \t]*/].to_s.length
69
+ end
70
+
71
+ def scan_methods(path)
72
+ src = File.read(path)
73
+ lines = src.lines
74
+ out = []
75
+ lines.each_with_index do |line, idx|
76
+ m = line.match(SELF_DEF_RE)
77
+ next unless m
78
+
79
+ out << {
80
+ file: path,
81
+ line: idx + 1,
82
+ decorator: m[2]&.strip,
83
+ name: m[3],
84
+ arglist: m[4],
85
+ body_excerpt: lines[(idx + 1)..(idx + 60)]&.join.to_s,
86
+ method_body: method_body_excerpt(lines, idx)
87
+ }
88
+ end
89
+ out
90
+ end
91
+
92
+ def method_body_excerpt(lines, idx)
93
+ indent = lines[idx][/^[ \t]*/]
94
+ buf = []
95
+ ((idx + 1)...lines.length).each do |i|
96
+ break if lines[i].match?(/^#{Regexp.escape(indent)}end\b/)
97
+
98
+ buf << lines[i]
99
+ break if buf.length >= 120
100
+ end
101
+ buf.join
102
+ end
103
+
104
+ def scan_bare_defs(path)
105
+ stack = []
106
+ out = []
107
+ File.readlines(path).each_with_index do |line, idx|
108
+ stripped = line.strip
109
+ next if stripped.empty? || stripped.start_with?('#')
110
+
111
+ indent = line_indent(line)
112
+
113
+ if stripped == 'end' || stripped.match?(/\Aend\b/)
114
+ stack.pop while stack.any? && stack.last[:indent] >= indent
115
+ next
116
+ end
117
+
118
+ if stripped.match?(MODULE_OPEN_RE) && !stripped.match?(/\bend\b/)
119
+ stack.pop while stack.any? && stack.last[:indent] >= indent
120
+ stack << { type: :module, indent: indent }
121
+ next
122
+ end
123
+
124
+ if stripped.match?(CLASS_LIKE_OPEN_RE) && !stripped.match?(/\bend\b/)
125
+ stack.pop while stack.any? && stack.last[:indent] >= indent
126
+ stack << { type: :class, indent: indent }
127
+ next
128
+ end
129
+
130
+ next if line.match?(SELF_DEF_RE)
131
+
132
+ m = line.match(BARE_DEF_RE)
133
+ next unless m
134
+
135
+ container = stack.rfind { |frame| frame[:indent] < indent }
136
+ next if container.nil?
137
+ next if container[:type] == :class
138
+
139
+ out << {
140
+ file: path,
141
+ line: idx + 1,
142
+ name: m[1],
143
+ arglist: m[2]
144
+ }
145
+ end
146
+ out
147
+ end
148
+
149
+ def scan_module_function(path)
150
+ out = []
151
+ File.readlines(path).each_with_index do |line, idx|
152
+ next if line.match?(/^\s*#/)
153
+ next unless line.match?(MODULE_FUNCTION_RE)
154
+
155
+ out << { file: path, line: idx + 1 }
156
+ end
157
+ out
158
+ end
159
+
160
+ ALL_METHODS = MODULE_FILES.flat_map { |f| scan_methods(f) }.freeze
161
+ ALL_BARE_DEFS = MODULE_FILES.flat_map { |f| scan_bare_defs(f) }.freeze
162
+ ALL_MODULE_FUNCTIONS = MODULE_FILES.flat_map { |f| scan_module_function(f) }.freeze
163
+
164
+ HELP_SKIP = %w[help].freeze
165
+
166
+ def help_text(path)
167
+ lines = File.readlines(path)
168
+ idx = lines.find_index { |l| l.match?(/def self\.help\b/) }
169
+ return '' unless idx
170
+
171
+ indent = lines[idx][/^[ \t]*/]
172
+ buf = []
173
+ ((idx + 1)...lines.length).each do |i|
174
+ break if lines[i].match?(/^#{Regexp.escape(indent)}end\b/)
175
+
176
+ buf << lines[i]
177
+ end
178
+ buf.join
179
+ end
180
+
181
+ def opts_keys(body)
182
+ body.to_s.scan(/opts\[\s*:([A-Za-z_]\w*)\s*\]/).flatten.uniq
183
+ end
184
+
185
+ def thin_option_desc?(key, desc)
186
+ rest = desc.to_s.strip.sub(/\A(?:required|optional)\s*-\s*/i, '')
187
+ return true if rest.empty?
188
+
189
+ norm = rest.downcase.gsub(/[^a-z0-9]+/, ' ').strip
190
+ key_norm = key.to_s.downcase.tr('_', ' ')
191
+ return true if norm == key_norm
192
+
193
+ words = rest.split
194
+ words.length < 2 && rest.length < 18
195
+ end
196
+
197
+ def purpose_above?(help, name)
198
+ lines = help.to_s.lines
199
+ idx = lines.find_index { |l| l.match?(/\#\{self\}(?:::[A-Z]\w*)*\.#{Regexp.escape(name)}(?!\w)/) }
200
+ return false unless idx
201
+
202
+ prev = nil
203
+ (idx - 1).downto(0) do |i|
204
+ next if lines[i].strip.empty?
205
+
206
+ prev = lines[i]
207
+ break
208
+ end
209
+ return false unless prev
210
+
211
+ text = prev.strip.sub(/\A#\s*/, '')
212
+ return false if text.match?(/\AUSAGE:/i)
213
+ return false if text.match?(/\.\w+\s*\(/) || text.match?(/\#\{self\}/)
214
+ return false if text.match?(/\A\w+\s*=\s*(?:PWN|Meshtastic)::/)
215
+ return false if text.downcase.gsub(/[^a-z0-9]+/, ' ').strip == name.downcase.tr('_', ' ')
216
+
217
+ words = text.split
218
+ words.length >= 3 || text.length >= 20
219
+ end
220
+
221
+ def rel(path)
222
+ path.sub("#{ROOT}/", '')
223
+ end
224
+ end
225
+
226
+ describe 'Meshtastic module conventions' do
227
+ c = MeshtasticConventions
228
+
229
+ it '0) every Meshtastic module method is referenced via `self.<method_name>` (no bare module-level `def`, no module_function)' do
230
+ bare = c::ALL_BARE_DEFS
231
+ mfun = c::ALL_MODULE_FUNCTIONS
232
+ parts = []
233
+ unless bare.empty?
234
+ parts << 'bare module-level `def <name>` (use `public_class_method def self.<name>` / `private_class_method def self.<name>`):'
235
+ parts.concat(bare.map { |m| " #{c.rel(m[:file])}:#{m[:line]} def #{m[:name]}" })
236
+ end
237
+ unless mfun.empty?
238
+ parts << '`module_function` is forbidden (declare each method with def self.<name>):'
239
+ parts.concat(mfun.map { |m| " #{c.rel(m[:file])}:#{m[:line]} module_function" })
240
+ end
241
+ msg = parts.join("\n")
242
+ expect(bare + mfun).to be_empty, msg
243
+ end
244
+
245
+ it '1) every `def self.<name>` is decorated with public_class_method or private_class_method' do
246
+ bare = c::ALL_METHODS.select { |m| m[:decorator].nil? }
247
+ msg = bare.map { |m| " #{c.rel(m[:file])}:#{m[:line]} def self.#{m[:name]}" }.join("\n")
248
+ expect(bare).to be_empty, "bare `def self.*` (add public_class_method / private_class_method):\n#{msg}"
249
+ end
250
+
251
+ it '2) every argument-accepting module method takes exactly `(opts = {})`' do
252
+ bad = c::ALL_METHODS.reject do |m|
253
+ a = m[:arglist]
254
+ a.nil? || a.strip.empty? || a.strip == 'opts = {}'
255
+ end
256
+ msg = bad.map { |m| " #{c.rel(m[:file])}:#{m[:line]} def self.#{m[:name]}(#{m[:arglist]})" }.join("\n")
257
+ expect(bad).to be_empty, "non-conforming arglists (use `(opts = {})`):\n#{msg}"
258
+ end
259
+
260
+ it '3) methods declared `(opts = {})` actually consume opts in the body' do
261
+ unused = c::ALL_METHODS.select do |m|
262
+ m[:arglist]&.strip == 'opts = {}' &&
263
+ !m[:body_excerpt].match?(/\bopts\s*\[|\bopts\.(?:dig|fetch|key\?|keys|values|merge|each|delete|map|\[)/)
264
+ end
265
+ msg = unused.map { |m| " #{c.rel(m[:file])}:#{m[:line]} def self.#{m[:name]}(opts = {}) # opts never read" }.join("\n")
266
+ expect(unused).to be_empty, "declared `(opts = {})` but never read opts (unpack at top of method):\n#{msg}"
267
+ end
268
+
269
+ it '4) every module file defines `def self.help`' do
270
+ missing = c::MODULE_FILES.reject do |f|
271
+ c::NAMESPACE_INDEX_FILES.include?(c.rel(f)) || File.read(f).match?(/def self\.help\b/)
272
+ end
273
+ msg = missing.map { |f| " #{c.rel(f)}" }.join("\n")
274
+ expect(missing).to be_empty, "modules missing `def self.help`:\n#{msg}"
275
+ end
276
+
277
+ it '5) every module file defines `def self.authors`' do
278
+ missing = c::MODULE_FILES.reject do |f|
279
+ c::NAMESPACE_INDEX_FILES.include?(c.rel(f)) || File.read(f).match?(/def self\.authors\b/)
280
+ end
281
+ msg = missing.map { |f| " #{c.rel(f)}" }.join("\n")
282
+ expect(missing).to be_empty, "modules missing `def self.authors`:\n#{msg}"
283
+ end
284
+
285
+ it '6) every public class method is documented in help with required/optional options (Google.help shape)' do
286
+ misses = []
287
+ c::MODULE_FILES.each do |path|
288
+ next if c::NAMESPACE_INDEX_FILES.include?(c.rel(path))
289
+
290
+ help = c.help_text(path)
291
+ misses << "#{c.rel(path)} missing method authors in help" unless help.match?(/\.authors(?!\w)/)
292
+ pubs = c::ALL_METHODS.select do |m|
293
+ m[:file] == path &&
294
+ m[:decorator] == 'public_class_method' &&
295
+ !c::HELP_SKIP.include?(m[:name])
296
+ end
297
+ pubs.each do |meth|
298
+ name = meth[:name]
299
+ unless help.match?(/\.#{Regexp.escape(name)}(?!\w)/)
300
+ misses << "#{c.rel(path)} missing method #{name} in help"
301
+ next
302
+ end
303
+ misses << "#{c.rel(path)} #{name}: missing purpose line above the method in help" unless c.purpose_above?(help, name)
304
+ next unless meth[:arglist]&.strip == 'opts = {}'
305
+
306
+ c.opts_keys(meth[:method_body]).each do |key|
307
+ descs = help.scan(/#{Regexp.escape(key)}:\s*['"]((?:required|optional)\s*-\s*[^'"]+)/i).flatten
308
+ if descs.empty?
309
+ misses << "#{c.rel(path)} #{name} option #{key}: missing required/optional description"
310
+ next
311
+ end
312
+ next unless descs.any? { |d| c.thin_option_desc?(key, d) }
313
+
314
+ misses << "#{c.rel(path)} #{name} option #{key}: description too thin (say what to pass, not just the key name)"
315
+ end
316
+ end
317
+ end
318
+ extra = misses.length > 80 ? "\n... #{misses.length} total" : ''
319
+ expect(misses).to be_empty, "#{misses.first(80).join("\n")}#{extra}"
320
+ end
321
+ end