ruby_native 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a20e456fe2c38a3885bd29bc796da70a6ef238bf30de87297ca14f9f407d783d
4
- data.tar.gz: b266e12b4c911bb57ba5c21eb6748f270a13a7449e27296fb5bf5fefb4b20d96
3
+ metadata.gz: 6943c548d9910886fefa4a121b493c1cdc40b06ab34059c4deac3fac3ac86018
4
+ data.tar.gz: def52388edde0250a098f4ffa4c02e0fe691d7aa0383b1049c2ac1c178be559b
5
5
  SHA512:
6
- metadata.gz: 7a59080e79c82b040910d146ad1b3f214ef247a1f251783ba951400bf099bc1f161b41195b2230bb0da977988ae7850be1be6aff1e4f89c16a539b7b7dcef8f3
7
- data.tar.gz: 289783ff033617cc3b77f697334e861985f98786562d978b923118c56638c0411931df89929bf9c771cbe77a88d71f38d6f177fbb1762c66badfdb039fa7379c
6
+ metadata.gz: 7bdaecaa1892409cfdde1f9e6f7161acd430065e470c35a73e95a25ace1bad76f303802226952965fa8e109b8848ee4fc42c6a6db3fefb5bc0b691140b705bb1
7
+ data.tar.gz: b67256819c46021c2de3e11e89fa5d6c7acc51064256913d5c9e5b44ebec3184c9b0b68e9461e0d4141246c32f0363da6f423fb306781287d78f3d04b23982bd
data/README.md CHANGED
@@ -18,7 +18,13 @@ Run the install generator:
18
18
  rails generate ruby_native:install
19
19
  ```
20
20
 
21
- This creates `config/ruby_native.yml`. Then:
21
+ This creates `config/ruby_native.yml` with tabs taken from your routes. If you use a coding agent, hand it the rest here:
22
+
23
+ ```bash
24
+ bundle exec ruby_native mcp install
25
+ ```
26
+
27
+ Then say: set up Ruby Native in this app. It wires the layout, checks its own work, and later drafts the listing, captures screenshots, and submits for review, stopping only for the few clicks Apple keeps for a person. By hand, the same steps are:
22
28
 
23
29
  1. Edit the config with your colors and tabs (see below).
24
30
  2. Add the stylesheet and `viewport-fit=cover` to your layout `<head>`:
@@ -84,6 +90,52 @@ Building with Inertia instead of ERB? `@ruby-native/react` and `@ruby-native/vue
84
90
 
85
91
  Every docs page serves markdown: append `.md` to the URL or request `Accept: text/markdown`. For an index of everything, fetch [rubynative.com/llms.txt](https://rubynative.com/llms.txt), or [llms-full.txt](https://rubynative.com/llms-full.txt) for the complete docs as a single file.
86
92
 
93
+ ### MCP server
94
+
95
+ Docs tell an agent what to write. They don't tell it whether what it wrote works, and Ruby Native's signals fail silently on purpose: an app that doesn't recognize a `data-native-*` attribute ignores it. A typo renders nothing, raises nothing, and logs nothing, so it survives right up until someone opens the build and notices the missing button.
96
+
97
+ `ruby_native mcp` closes that loop. It speaks [MCP](https://modelcontextprotocol.io) over stdin and stdout. Three tools answer from your working copy, with no account involved:
98
+
99
+ | Tool | Answers |
100
+ |---|---|
101
+ | `check_views` | Which signals in your templates are misspelled, duplicated, or newer than the installed gem |
102
+ | `lookup_signals` | Every `data-native-*` attribute, the gem version it needs, and the helper that emits it |
103
+ | `validate_config` | What in `config/ruby_native.yml` the apps reject outright, and what they quietly ignore |
104
+
105
+ Four more read your account, using the token `ruby_native login` already stored:
106
+
107
+ | Tool | Answers |
108
+ |---|---|
109
+ | `status` | Where the app is on the way to the App Store, and what is in the way, with who clears each thing |
110
+ | `config_errors` | What real devices reported failing over the last 48 hours, with the decode error behind each one |
111
+ | `deployed_builds` | The build your users can actually install, and which signals it's too old to understand |
112
+ | `build_status` | Whether one build succeeded, and the error if it didn't |
113
+
114
+ The pairs are the point. `validate_config` says what *will* fail to decode; `config_errors` says what did, on which devices. `check_views` compares your templates against the gem in your Gemfile; `deployed_builds` compares them against the binary in the store, which is usually the answer to "the attribute is right but nothing happens on my phone".
115
+
116
+ Full reference at [rubynative.com/docs/cli](https://rubynative.com/docs/cli#mcp-server). Register it in the project's `.mcp.json`, which Claude Code reads:
117
+
118
+ ```bash
119
+ bundle exec ruby_native mcp install
120
+ ```
121
+
122
+ Then say: set up Ruby Native in this app.
123
+
124
+ Or, in a client that takes JSON:
125
+
126
+ ```json
127
+ {
128
+ "mcpServers": {
129
+ "ruby-native": {
130
+ "command": "bundle",
131
+ "args": ["exec", "ruby_native", "mcp"]
132
+ }
133
+ }
134
+ }
135
+ ```
136
+
137
+ The rest stage things with Ruby Native and reach Apple only on a build or a click on the dashboard: `permissions_set`, `testers_add`, `icon_set`, `screenshots_setup`, `screenshots_capture`, `listing_push`, `listing_pull`, and `demo_account_set`, each the same as its CLI command. `submit` is the one tool that reaches Apple on its own: it takes two calls with a token in between, so the agent has to show you what is going before it can send it, and `withdraw` takes it back out of the queue until Apple approves. The first three tools never leave your machine; the rest send your CLI token to rubynative.com. `check_views` needs the `herb` gem, same as `ruby_native check`.
138
+
87
139
  ## License
88
140
 
89
141
  MIT.
data/config/signals.yml CHANGED
@@ -7,6 +7,15 @@
7
7
  #
8
8
  # `owner: shell` marks a signal the app writes into the page rather than one
9
9
  # your views emit.
10
+ #
11
+ # `helper` names the helper that can emit the signal; `always: true` means every
12
+ # call to that helper emits it. Without `always`, `check` cannot count a helper
13
+ # call against the signal: native_fab_tag emits data-native-color only when you
14
+ # pass `color:`, so counting it would report markup the view never renders.
15
+ #
16
+ # `renders_when` covers the helpers that can render nothing at all, naming the
17
+ # keyword values they need in order to emit anything. A call that passes
18
+ # something else, or something only known at request time, is left uncounted.
10
19
  ---
11
20
  signals:
12
21
  data-native-action:
@@ -19,6 +28,7 @@ signals:
19
28
  data-native-badge:
20
29
  since: "0.3.2"
21
30
  helper: native_badge_tag
31
+ always: true
22
32
  singleton: true
23
33
  data-native-badge-home:
24
34
  since: "0.3.2"
@@ -42,50 +52,63 @@ signals:
42
52
  data-native-fab:
43
53
  since: "0.8.0"
44
54
  helper: native_fab_tag
55
+ always: true
45
56
  singleton: true
46
57
  data-native-form:
47
58
  since: "0.1.2"
48
59
  helper: native_form_tag
60
+ always: true
49
61
  singleton: true
50
62
  data-native-haptic:
51
63
  since: "0.2.7"
52
64
  helper: native_haptic_data
65
+ always: true
53
66
  data-native-href:
54
67
  since: "0.4.0"
55
68
  helper: native_fab_tag
56
69
  data-native-icon:
57
70
  since: "0.4.0"
58
71
  helper: native_fab_tag
72
+ always: true
59
73
  data-native-identity:
60
74
  since: "0.12.0"
61
75
  helper: native_identity_tag
76
+ always: true
62
77
  singleton: true
63
78
  data-native-keyboard-toolbar:
64
79
  since: "0.15.0"
65
80
  helper: native_keyboard_tag
81
+ always: true
82
+ renders_when:
83
+ toolbar: false
66
84
  singleton: true
67
85
  data-native-menu:
68
86
  since: "0.14.0"
69
87
  helper: native_menu_tag
88
+ always: true
70
89
  data-native-menu-item:
71
90
  since: "0.4.0"
72
91
  data-native-navbar:
73
92
  since: "0.4.0"
74
93
  helper: native_navbar_tag
94
+ always: true
75
95
  singleton: true
76
96
  data-native-overscroll-bottom:
77
97
  since: "0.5.0"
78
98
  helper: native_overscroll_tag
99
+ always: true
79
100
  singleton: true
80
101
  data-native-overscroll-top:
81
102
  since: "0.5.0"
82
103
  helper: native_overscroll_tag
104
+ always: true
83
105
  singleton: true
84
106
  data-native-position:
85
107
  since: "0.4.0"
86
108
  data-native-presentation:
87
109
  since: "0.11.2"
88
110
  helper: native_presentation_tag
111
+ always: true
89
112
  singleton: true
90
113
  data-native-price:
91
114
  since: "0.5.3"
@@ -97,12 +120,14 @@ signals:
97
120
  data-native-push:
98
121
  since: "0.1.2"
99
122
  helper: native_push_tag
123
+ always: true
100
124
  singleton: true
101
125
  data-native-restore:
102
126
  since: "0.5.3"
103
127
  data-native-review:
104
128
  since: "0.9.3"
105
129
  helper: native_review_tag
130
+ always: true
106
131
  data-native-segment:
107
132
  since: "0.10.12"
108
133
  data-native-selected:
@@ -120,6 +145,9 @@ signals:
120
145
  data-native-tabs:
121
146
  since: "0.1.2"
122
147
  helper: native_tabs_tag
148
+ always: true
149
+ renders_when:
150
+ enabled: true
123
151
  singleton: true
124
152
  data-native-title:
125
153
  since: "0.4.0"
@@ -5,7 +5,44 @@ module RubyNative
5
5
 
6
6
  desc "Creates a Ruby Native config file at config/ruby_native.yml"
7
7
 
8
+ # The tab bar only appears with two or more tabs (the shell hides it
9
+ # otherwise), and a tab pointing at a path the app does not route 404s
10
+ # on first launch. Both failures land in the first five minutes, so tabs
11
+ # come from the app's own routes, and only from routes that exist.
12
+ MAX_TABS = 4
13
+
14
+ # Route segments that are never a tab: auth, framework, and Ruby Native's
15
+ # own mounts.
16
+ SKIPPED_SEGMENTS = %w[
17
+ rails up cable native assets manifest service-worker pwa admin api webhooks
18
+ session sessions sign_in sign_up signin signup login logout password passwords
19
+ registration registrations users auth oauth
20
+ ].freeze
21
+
22
+ TAB_ICONS = {
23
+ "" => "house",
24
+ "home" => "house",
25
+ "dashboard" => "house",
26
+ "profile" => "person",
27
+ "account" => "person",
28
+ "me" => "person",
29
+ "settings" => "gear",
30
+ "preferences" => "gear",
31
+ "inbox" => "envelope",
32
+ "messages" => "envelope",
33
+ "notifications" => "bell",
34
+ "search" => "magnifyingglass",
35
+ "calendar" => "calendar",
36
+ "events" => "calendar",
37
+ "orders" => "shippingbox",
38
+ "favorites" => "star",
39
+ "bookmarks" => "star"
40
+ }.freeze
41
+
42
+ DEFAULT_ICON = "list.bullet".freeze
43
+
8
44
  def copy_config
45
+ @tabs = proposed_tabs
9
46
  template "ruby_native.yml", "config/ruby_native.yml"
10
47
  end
11
48
 
@@ -41,23 +78,79 @@ module RubyNative
41
78
 
42
79
  def print_next_steps
43
80
  say ""
44
- say "Ruby Native installed! Next steps:", :green
81
+ say "Ruby Native installed.", :green
82
+ say ""
83
+ say " Tabs, from your routes: #{@tabs.map { |tab| "#{tab[:path]} (#{tab[:title]})" }.join(", ")}"
84
+ if @tabs.size < 2
85
+ say " The tab bar stays hidden until config/ruby_native.yml has a second tab.", :yellow
86
+ else
87
+ say " Change them in config/ruby_native.yml."
88
+ end
45
89
  say ""
46
- say " 1. Edit config/ruby_native.yml with your app name, colors, and tabs"
47
- say " 2. Add to your layout <head>:"
48
- say " <%= stylesheet_link_tag :ruby_native %>"
49
- say " 3. Add viewport-fit=cover to your viewport meta tag:"
50
- say " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">"
51
- say " 4. Add to your layout <body>:"
52
- say " <%= native_tabs_tag %>"
53
- say " <%= native_identity_tag(current_user&.id) %>"
54
- say " 5. Preview on your device:"
55
- say " bundle exec ruby_native preview"
90
+ say "By hand:"
91
+ say " 1. Add <%= stylesheet_link_tag :ruby_native %> to your layout <head>"
92
+ say " 2. Add viewport-fit=cover to your viewport meta tag"
93
+ say " 3. Add <%= native_tabs_tag %> and <%= native_identity_tag(current_user&.id) %> to your layout <body>"
94
+ say " 4. Preview on your phone: bundle exec ruby_native preview"
56
95
  say ""
57
96
  say " Docs: https://rubynative.com/docs"
58
- say " AI agents: fetch https://rubynative.com/llms.txt for a docs index,"
59
- say " or https://rubynative.com/llms-full.txt for the full docs as plain text."
97
+ say " Agents: https://rubynative.com/llms.txt for a docs index, llms-full.txt for all of it as plain text"
60
98
  say ""
99
+ say "Or hand it to your agent:", :green
100
+ say ""
101
+ say " bundle exec ruby_native mcp install"
102
+ say ""
103
+ say "Then, in your agent: /mcp__ruby-native__ship"
104
+ say ""
105
+ end
106
+
107
+ private
108
+
109
+ def proposed_tabs
110
+ paths = routable_paths
111
+ paths = [ "/" ] if paths.empty?
112
+
113
+ paths.first(MAX_TABS).map do |path|
114
+ segment = path.delete_prefix("/")
115
+ { title: title_for(segment), path: path, icon: TAB_ICONS.fetch(segment, DEFAULT_ICON) }
116
+ end
117
+ end
118
+
119
+ # Routes a library draws into the app for its own use. Turbo's
120
+ # historical-location routes and the PWA manifest are GET, static, and
121
+ # one segment, so without this a fresh app's tabs were three Turbo
122
+ # internals.
123
+ LIBRARY_CONTROLLERS = %r{\A(turbo|rails|active_storage|action_mailbox|action_cable|action_text)/}
124
+
125
+ # Static, single-segment GET routes, root first, in the order the routes
126
+ # file declares them. Anything with a parameter, a glob, or a second
127
+ # segment is a page you reach from a tab, not a tab.
128
+ def routable_paths
129
+ paths = Rails.application.routes.routes.filter_map do |route|
130
+ next if route.respond_to?(:internal) && route.internal
131
+ next unless route.verb.to_s.match?(/\bGET\b/)
132
+ next if route.defaults[:controller].to_s.match?(LIBRARY_CONTROLLERS)
133
+
134
+ path = route.path.spec.to_s.sub(/\(\.:format\)\z/, "")
135
+ next unless path.start_with?("/")
136
+ next if path.match?(/[:*]/)
137
+
138
+ segments = path.delete_prefix("/").split("/")
139
+ next if segments.size > 1
140
+ next if SKIPPED_SEGMENTS.include?(segments.first)
141
+
142
+ path
143
+ end.uniq
144
+
145
+ paths.partition { |path| path == "/" }.flatten
146
+ rescue StandardError
147
+ []
148
+ end
149
+
150
+ def title_for(segment)
151
+ return "Home" if segment.empty?
152
+
153
+ segment.tr("_-", " ").split.map(&:capitalize).join(" ")
61
154
  end
62
155
  end
63
156
  end
@@ -93,12 +93,11 @@ appearance:
93
93
  tabs:
94
94
  # Icons are SF Symbols on iOS and Material Icons on Android.
95
95
  # https://rubynative.com/docs/icons
96
- - title: Home
97
- path: /
98
- icon: house
99
- - title: Profile
100
- path: /profile
101
- icon: person
96
+ <% @tabs.each do |tab| -%>
97
+ - title: <%= tab[:title] %>
98
+ path: <%= tab[:path] %>
99
+ icon: <%= tab[:icon] %>
100
+ <% end -%>
102
101
 
103
102
  # Per-tab options (add to any tab above):
104
103
  #
@@ -145,7 +144,7 @@ tabs:
145
144
  # team_id: ABCD123456
146
145
  # android:
147
146
  # package: com.example.myapp
148
- # # Play Console -> Setup -> App integrity -> App signing key certificate.
147
+ # # Google Play Console -> Setup -> App integrity -> App signing key certificate.
149
148
  # cert_fingerprint: "AA:BB:CC:..."
150
149
 
151
150
  # Link only these path prefixes instead of the whole domain. Links outside
@@ -0,0 +1,76 @@
1
+ require "ruby_native/cli/command"
2
+
3
+ module RubyNative
4
+ class CLI
5
+ # The App Store Connect key. Only a person can mint it, in App Store
6
+ # Connect; this is how they hand it over without opening the dashboard.
7
+ # The file is read here and sent once; nothing reads it back, and the
8
+ # MCP tool takes a path for the same reason, so an agent never holds the
9
+ # key itself.
10
+ class Apple < Command
11
+ POLL_INTERVAL = 2
12
+ MAX_WAIT = 90
13
+
14
+ USAGE = "Usage: ruby_native apple key <path/to/AuthKey.p8> --key-id=KEY --issuer-id=ISSUER --team-id=TEAM".freeze
15
+
16
+ def run
17
+ ensure_authenticated!
18
+ app_id = app_id!
19
+
20
+ unless positional.first == "key" && positional[1] && flag("key-id") && flag("issuer-id") && flag("team-id")
21
+ puts USAGE
22
+ exit 1
23
+ end
24
+
25
+ finish(self.class.set_key(app_id, path: positional[1], key_id: flag("key-id"), issuer_id: flag("issuer-id"), team_id: flag("team-id")))
26
+ end
27
+
28
+ def self.set_key(app_id, path:, key_id:, issuer_id:, team_id:, wait: MAX_WAIT)
29
+ return Outcome.new(ok: false, text: "No file at #{path}.", data: {}) unless File.file?(path)
30
+
31
+ result = Mcp::Platform.patch("/api/v1/apps/#{app_id}/credentials",
32
+ { team_id: team_id, asc_key_id: key_id, asc_issuer_id: issuer_id, asc_key: File.read(path).strip })
33
+ return failure(result) unless result.ok?
34
+
35
+ settle(app_id, wait: wait)
36
+ end
37
+
38
+ # Validation runs against Apple in the background. Wait for it here,
39
+ # because "sent" is not the answer anybody wants; "Apple took it" is.
40
+ def self.settle(app_id, wait:)
41
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
42
+
43
+ loop do
44
+ result = Mcp::Platform.get("/api/v1/apps/#{app_id}/credentials")
45
+ return failure(result) unless result.ok?
46
+
47
+ state = result.value.is_a?(Hash) ? result.value : {}
48
+ case state["state"]
49
+ when "passed"
50
+ return success("Key verified: a Team key with the Admin role, and your agreements are signed. Ruby Native can sign and ship this app.", state)
51
+ when "failed"
52
+ return Outcome.new(ok: false, text: "Apple rejected the key: #{state["error"]}", data: state)
53
+ end
54
+
55
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
56
+ return success("Still checking the key with Apple. Read `ruby_native status` in a minute; it says when the key checks out.", state)
57
+ end
58
+
59
+ pause
60
+ end
61
+ end
62
+
63
+ # Two seconds between reads, and none in a test.
64
+ def self.pause
65
+ sleep POLL_INTERVAL unless @sleepless
66
+ end
67
+
68
+ def self.stub_const_sleep
69
+ @sleepless = true
70
+ yield
71
+ ensure
72
+ @sleepless = false
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,29 +1,131 @@
1
+ require "prism"
2
+
1
3
  module RubyNative
2
4
  class CLI
3
5
  class Check
4
- # Walks a parsed template and records every data-native-* attribute with
5
- # the lines it appears on. Attribute names built out of ERB cannot be
6
- # resolved statically and are skipped rather than guessed at.
6
+ # Walks a parsed template and records every data-native-* signal with the
7
+ # lines it appears on, whether the view writes the attribute by hand or
8
+ # calls the helper that emits it. Attribute names built out of ERB cannot
9
+ # be resolved statically and are skipped rather than guessed at.
7
10
  #
8
11
  # Subclasses Herb::Visitor, so this file is only loaded once `herb` is
9
12
  # known to be present.
10
13
  class SignalCollector < Herb::Visitor
11
- attr_reader :signals
14
+ COMMENT_OPENING = "<%#".freeze
12
15
 
13
16
  def initialize
14
17
  @signals = {}
15
18
  super
16
19
  end
17
20
 
21
+ # Attributes and helper calls arrive from different visits, so the lines
22
+ # for one signal are not collected in document order.
23
+ def signals
24
+ @signals.transform_values(&:sort)
25
+ end
26
+
18
27
  def visit_html_attribute_name_node(node)
19
28
  name = node.children.filter_map { |child| child.content if child.respond_to?(:content) }.join
20
29
 
21
- if name.start_with?("data-native-")
22
- (@signals[name] ||= []) << node.location.start.line
23
- end
30
+ record(name, node.location.start.line) if name.start_with?("data-native-")
31
+
32
+ super
33
+ end
34
+
35
+ def visit_erb_content_node(node)
36
+ record_helper_calls(node)
37
+
38
+ super
39
+ end
40
+
41
+ # `<%= native_navbar_tag "Orders" do |navbar| %>` opens a block, so its
42
+ # Ruby arrives here instead, without the `end` that would let it parse
43
+ # on its own.
44
+ def visit_erb_block_node(node)
45
+ record_helper_calls(node, suffix: "\nend")
24
46
 
25
47
  super
26
48
  end
49
+
50
+ private
51
+
52
+ def record_helper_calls(node, suffix: "")
53
+ return if node.tag_opening&.value == COMMENT_OPENING
54
+
55
+ source = node.content&.value
56
+ return if source.nil?
57
+
58
+ # Ruby that does not parse is the app's own syntax error, which Rails
59
+ # raises on render and `herb lint` reports properly. Staying quiet
60
+ # here keeps `check` to the one job it claims.
61
+ result = Prism.parse(source + suffix)
62
+ return unless result.success?
63
+
64
+ line = node.location.start.line
65
+ calls(result.value).each do |call|
66
+ helper = call.name.to_s
67
+ signals = RubyNative::Signals.signals_for_helper(helper)
68
+ next if signals.empty? || !renders?(call, helper)
69
+
70
+ signals.each { |signal| record(signal, line) }
71
+ end
72
+ end
73
+
74
+ # A helper that can render nothing only counts when its arguments prove
75
+ # it renders. `native_tabs_tag(enabled: @show)` decides at request time,
76
+ # so it is skipped rather than guessed at, the same as an attribute name
77
+ # built out of ERB.
78
+ def renders?(call, helper)
79
+ condition = RubyNative::Signals.render_condition(helper)
80
+ return true unless condition
81
+
82
+ condition.all? do |keyword, required|
83
+ given = keyword_value(call, keyword)
84
+
85
+ given.nil? ? true : given == required
86
+ end
87
+ end
88
+
89
+ # nil when the keyword is absent, which leaves the helper's own default
90
+ # in charge. :unknown for anything that is not a literal, so it never
91
+ # equals what the condition requires.
92
+ def keyword_value(call, keyword)
93
+ pairs = call.arguments&.arguments&.grep(Prism::KeywordHashNode)&.flat_map(&:elements)
94
+ return nil if pairs.nil?
95
+
96
+ assoc = pairs.find do |pair|
97
+ pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) &&
98
+ pair.key.unescaped == keyword.to_s
99
+ end
100
+ return nil unless assoc
101
+
102
+ case assoc.value
103
+ when Prism::TrueNode then true
104
+ when Prism::FalseNode then false
105
+ else :unknown
106
+ end
107
+ end
108
+
109
+ # A literal in an argument is not a call, so `placeholder: "native_tabs_tag"`
110
+ # reads as the string it is. That is the whole reason for parsing rather
111
+ # than scanning the source for helper names.
112
+ def calls(root)
113
+ found = []
114
+ stack = [root]
115
+
116
+ while (node = stack.pop)
117
+ next unless node.is_a?(Prism::Node)
118
+
119
+ found << node if node.is_a?(Prism::CallNode)
120
+ stack.concat(node.compact_child_nodes)
121
+ end
122
+
123
+ found
124
+ end
125
+
126
+ def record(name, line)
127
+ (@signals[name] ||= []) << line
128
+ end
27
129
  end
28
130
  end
29
131
  end
@@ -30,7 +30,43 @@ module RubyNative
30
30
 
31
31
  check = new([], paths: paths)
32
32
 
33
- check.send(:template_files).flat_map { |file| check.send(:check_file, file) }
33
+ template_files(paths: paths).flat_map { |file| check.send(:check_file, file) }
34
+ end
35
+
36
+ # The templates a run covers. Public so a caller can count them without
37
+ # running the checks, and so there is one definition of what gets scanned.
38
+ def self.template_files(paths: DEFAULT_PATHS)
39
+ paths.flat_map { |path| Dir.glob(File.join(path, "**", "*.html.erb")) }.sort
40
+ end
41
+
42
+ # The report `run` prints, as lines, so another front end renders
43
+ # offenses the way the CLI does instead of inventing a second format.
44
+ def self.report_lines(files, offenses)
45
+ lines = []
46
+
47
+ offenses.group_by(&:file).sort.each do |file, file_offenses|
48
+ lines << file
49
+ file_offenses.sort_by(&:line).each do |offense|
50
+ location = "#{offense.line}:".ljust(5)
51
+ marker = offense.severity.to_s.ljust(8)
52
+ lines << " #{location}#{marker}#{offense.message}"
53
+ end
54
+ lines << ""
55
+ end
56
+
57
+ lines << summary_line(files, offenses)
58
+ end
59
+
60
+ def self.summary_line(files, offenses)
61
+ errors, warnings = offenses.partition { |offense| offense.severity == :error }
62
+ summary = "Checked #{files.size} #{files.size == 1 ? "template" : "templates"}"
63
+
64
+ if offenses.empty?
65
+ "#{summary}. No problems found."
66
+ else
67
+ "#{summary}: #{errors.size} #{errors.size == 1 ? "error" : "errors"}, " \
68
+ "#{warnings.size} #{warnings.size == 1 ? "warning" : "warnings"}."
69
+ end
34
70
  end
35
71
 
36
72
  def self.herb_available?
@@ -79,7 +115,7 @@ module RubyNative
79
115
  end
80
116
 
81
117
  def template_files
82
- @paths.flat_map { |path| Dir.glob(File.join(path, "**", "*.html.erb")) }.sort
118
+ self.class.template_files(paths: @paths)
83
119
  end
84
120
 
85
121
  # --- Per-file checks ---
@@ -158,7 +194,11 @@ module RubyNative
158
194
  end
159
195
 
160
196
  used = signals_in(files)
161
- PLATFORMS.flat_map { |platform| deployed_offenses_for(platform, app_id, used) }
197
+ offenses = PLATFORMS.flat_map { |platform| deployed_offenses_for(platform, app_id, used) }
198
+ if (scan = Permissions.scan_without_camera(app_id))
199
+ offenses << Offense.new(file: scan[0], line: scan[1], severity: :warning, message: Permissions.scan_without_camera_message(*scan).sub(/\A\S+ /, ""))
200
+ end
201
+ offenses
162
202
  end
163
203
 
164
204
  def deployed_offenses_for(platform, app_id, used)
@@ -222,25 +262,7 @@ module RubyNative
222
262
  # --- Reporting ---
223
263
 
224
264
  def report(files, offenses)
225
- errors, warnings = offenses.partition { |offense| offense.severity == :error }
226
-
227
- offenses.group_by(&:file).sort.each do |file, file_offenses|
228
- puts file
229
- file_offenses.sort_by(&:line).each do |offense|
230
- location = "#{offense.line}:".ljust(5)
231
- marker = offense.severity.to_s.ljust(8)
232
- puts " #{location}#{marker}#{offense.message}"
233
- end
234
- puts ""
235
- end
236
-
237
- summary = "Checked #{files.size} #{files.size == 1 ? "template" : "templates"}"
238
- if offenses.empty?
239
- puts "#{summary}. No problems found."
240
- else
241
- puts "#{summary}: #{errors.size} #{errors.size == 1 ? "error" : "errors"}, " \
242
- "#{warnings.size} #{warnings.size == 1 ? "warning" : "warnings"}."
243
- end
265
+ puts self.class.report_lines(files, offenses)
244
266
  end
245
267
 
246
268
  def parse_paths(argv)