laneyard 0.5.0 → 0.6.1

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.
package/ruby/scan.rb ADDED
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Laneyard's Fastfile scanner.
5
+ #
6
+ # ruby scan.rb --fastlane-dir fastlane
7
+ #
8
+ # Deliberately ignorant. It reports the keyword arguments given directly to a
9
+ # call whose value is a literal string, with the byte ranges of the value and of
10
+ # the whole `key: value` pair, and has no idea what a credential is. Deciding
11
+ # which of those matters is `src/fastfile/adoption.ts`'s job, next to the one
12
+ # table that already describes each credential kind — a second copy of that
13
+ # table here would be free to disagree with it, in a language that cannot check
14
+ # it.
15
+ #
16
+ # Nested structures are deliberately not descended into. The literal inside
17
+ # `gym(export_options: { provisioningProfiles: { "id" => "./x" } })` has no
18
+ # keyword to report — its key is a bundle id — and attributing it to `gym` would
19
+ # be noise the caller cannot act on. This claim stays narrow on purpose: a
20
+ # confident answer that is wrong is worse than one that admits its edge.
21
+ #
22
+ # It never requires fastlane. `introspect.rb` must, to enumerate lanes; a
23
+ # syntax tree needs only Prism, and paying a fastlane boot for it would make
24
+ # this too slow to run during `laneyard setup`.
25
+ #
26
+ # The output contract is `introspect.rb`'s: { "ok": true, ... } or
27
+ # { "ok": false, "error": "..." }. An error is a valid response.
28
+
29
+ require "json"
30
+
31
+ REAL_STDOUT = $stdout.dup
32
+
33
+ # Writes the one JSON object this process produces, and stops.
34
+ #
35
+ # Serialisation is *not* done here, on purpose: `JSON.generate` raises on a
36
+ # string that is not valid UTF-8, and a Fastfile may well hold one
37
+ # (`supply(json_key: "./key\xFF.json")` is enough). Raising from inside the
38
+ # writer put the failure outside every guard in this file, so the caller got a
39
+ # Ruby trace on stderr and nothing at all on stdout — the one outcome the
40
+ # contract at the top promises never to happen. Callers generate under a guard
41
+ # and hand the finished string here.
42
+ def emit(json)
43
+ REAL_STDOUT.puts json
44
+ REAL_STDOUT.flush
45
+ exit 0
46
+ end
47
+
48
+ def respond(payload)
49
+ emit(JSON.generate(payload))
50
+ end
51
+
52
+ def fail_with(message)
53
+ # Scrubbed, because an error message can quote the bytes that caused it:
54
+ # `JSON::GeneratorError` names the offending literal. An error path that
55
+ # raises while reporting an error leaves the caller with nothing.
56
+ respond({ ok: false, error: message.to_s.dup.force_encoding("UTF-8").scrub("?") })
57
+ end
58
+
59
+ dir_index = ARGV.index("--fastlane-dir")
60
+ fastlane_dir = dir_index ? ARGV[dir_index + 1] : "fastlane"
61
+ fastfile_path = File.join(Dir.pwd, fastlane_dir, "Fastfile")
62
+
63
+ fail_with("Fastfile not found: #{fastfile_path}") unless File.exist?(fastfile_path)
64
+
65
+ # Insurance, not a fix for a known culprit: unlike `introspect.rb`, which faces
66
+ # a fastlane that really does print banners and deprecation notices, nothing
67
+ # here prints — Prism parses silently and the Fastfile is never executed. The
68
+ # redirection stands so that anything which *starts* printing later (a stray
69
+ # `puts`, a warning from some future require) lands on stderr instead of
70
+ # corrupting the one JSON object the real output carries.
71
+ $stdout = $stderr
72
+
73
+ begin
74
+ require "prism"
75
+ rescue LoadError => e
76
+ fail_with("prism is not available in this Ruby (#{e.message})")
77
+ end
78
+
79
+ # The name in `ENV["X"]` or `ENV.fetch("X")`, or nil for anything else.
80
+ #
81
+ # Both spellings read the environment, and both are how a Fastfile that already
82
+ # uses variables writes a credential — the caller rewrites either to the name a
83
+ # signing block exports. Only a literal string argument is resolved: `ENV[key]`
84
+ # with a computed key names nothing this can report.
85
+ def env_lookup_name(node)
86
+ return nil unless node.is_a?(Prism::CallNode)
87
+ receiver = node.receiver
88
+ return nil unless receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :ENV
89
+ return nil unless node.name == :[] || node.name == :fetch
90
+
91
+ first = node.arguments&.arguments&.first
92
+ first.is_a?(Prism::StringNode) ? first.unescaped : nil
93
+ end
94
+
95
+ # Every `key: "literal"` inside a call, wherever the call sits.
96
+ #
97
+ # Descends through everything: a call inside an `if`, inside a `def`, inside a
98
+ # `platform` block, is still a call this file makes. `calls_within` in
99
+ # introspect.rb resolves helper methods to attribute actions to a *lane*; there
100
+ # is no such need here, because a literal path is a problem wherever it is
101
+ # written and belongs to the file rather than to any one lane.
102
+ def literals_in(node, out = [])
103
+ return out if node.nil?
104
+
105
+ node.compact_child_nodes.each do |child|
106
+ if child.is_a?(Prism::CallNode)
107
+ # Both node types, because `supply(json_key: "…")` parses as a
108
+ # KeywordHashNode and `supply({json_key: "…"})` as a HashNode. They are
109
+ # the same argument written two ways, and looking for only the first
110
+ # missed the braced form entirely — silently, since a scan that finds
111
+ # nothing is indistinguishable from a file with nothing to find.
112
+ hashes = (child.arguments&.arguments || []).select do |a|
113
+ a.is_a?(Prism::KeywordHashNode) || a.is_a?(Prism::HashNode)
114
+ end
115
+ hashes.flat_map(&:elements).each do |el|
116
+ next unless el.is_a?(Prism::AssocNode)
117
+ next unless el.key.is_a?(Prism::SymbolNode)
118
+
119
+ # A literal string, or an environment lookup — the two ways a credential
120
+ # is written into a call. `adoption.ts` decides which arguments matter;
121
+ # this stays ignorant of credentials and reports both shapes, tagged so
122
+ # the caller can tell "a path to lift" from "a variable to rename". A
123
+ # value that is neither — a computed expression — has nothing the caller
124
+ # can act on and is skipped.
125
+ value = el.value
126
+ if value.is_a?(Prism::StringNode)
127
+ # A heredoc's location covers its *marker* — `<<~P` is four bytes —
128
+ # while its value lives on the lines below. Reporting that range would
129
+ # have the caller splice `ENV.fetch("...")` over the marker and leave
130
+ # the body stranded after the call: a Fastfile that no longer parses,
131
+ # written silently into someone's repository. Only Ruby can tell a
132
+ # heredoc from a quoted string, so the decision has to be made here.
133
+ next if value.opening&.start_with?("<<")
134
+ kind = "literal"
135
+ reported = value.unescaped
136
+ else
137
+ name = env_lookup_name(value)
138
+ next if name.nil?
139
+ kind = "env"
140
+ reported = name
141
+ end
142
+
143
+ # `start_offset` and `length` are byte offsets, and must stay that way:
144
+ # the caller splices them into a Buffer. Prism also offers
145
+ # `start_character_offset`, and swapping one in would still pass any
146
+ # test whose fixture is pure ASCII while putting one accent above the
147
+ # literal enough to land a patch mid-string in a build file.
148
+ out << {
149
+ action: child.name.to_s,
150
+ arg: el.key.unescaped,
151
+ kind: kind,
152
+ value: reported,
153
+ value_start: value.location.start_offset,
154
+ value_length: value.location.length,
155
+ pair_start: el.location.start_offset,
156
+ pair_length: el.location.length,
157
+ line: el.location.start_line
158
+ }
159
+ end
160
+ end
161
+ literals_in(child, out)
162
+ end
163
+ out
164
+ end
165
+
166
+ # A Fastfile that does not parse, told apart from everything else that can go
167
+ # wrong in the block below, so each gets a sentence that is true of it.
168
+ ParseFailure = Class.new(StandardError)
169
+
170
+ # Prism's first complaint, and the line it points at.
171
+ #
172
+ # "Fastfile could not be parsed" on its own leaves someone opening the file and
173
+ # hunting; a flow that is about to offer to *edit* that file owes them better
174
+ # than that. The first error is the one worth showing — the ones behind it are
175
+ # usually the same mistake seen again from further down.
176
+ def parse_failure(result)
177
+ first = result.errors.first
178
+ return "Fastfile could not be parsed" unless first
179
+
180
+ "Fastfile could not be parsed, line #{first.location.start_line}: #{first.message}"
181
+ end
182
+
183
+ begin
184
+ source = File.read(fastfile_path)
185
+ result = Prism.parse(source)
186
+ # A plain `raise`, not `fail_with`, and deliberately so: `fail_with` ends in
187
+ # `respond`'s `exit 0`, which raises SystemExit — a subclass of Exception, so
188
+ # calling it from inside this very `begin` would be caught by the `rescue`
189
+ # below and answered a second time, corrupting the output with two JSON
190
+ # blobs. Raising here and answering from the `rescue` clause (which is not
191
+ # itself guarded) is the same shape `introspect.rb` uses for this.
192
+ raise ParseFailure, parse_failure(result) unless result.success?
193
+ # Serialised here rather than at the `emit` below, so that a literal Prism
194
+ # read fine but JSON cannot represent — anything that is not valid UTF-8 —
195
+ # becomes an ordinary `{ "ok": false }` instead of a trace. Only the write
196
+ # stays outside the guard, for the SystemExit reason above.
197
+ payload = JSON.generate({ ok: true, literals: literals_in(result.value) })
198
+ rescue ParseFailure => e
199
+ # Already a whole sentence, and prefixing it produced the visible doubling
200
+ # this replaced: "Could not read the Fastfile: Fastfile could not be parsed",
201
+ # whose first half was also false — the read had succeeded.
202
+ fail_with(e.message)
203
+ rescue Exception => e # rubocop:disable Lint/RescueException
204
+ fail_with("Could not scan the Fastfile: #{e.message}")
205
+ end
206
+
207
+ emit(payload)