laneyard 0.4.1 → 0.6.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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>laneyard</title>
7
- <script type="module" crossorigin src="/assets/index-pzPa9EZr.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-B8lAQPEM.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-DsjxZtsO.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "laneyard",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "laneyard": "dist/src/main.js"
package/ruby/scan.rb ADDED
@@ -0,0 +1,176 @@
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
+ # Every `key: "literal"` inside a call, wherever the call sits.
80
+ #
81
+ # Descends through everything: a call inside an `if`, inside a `def`, inside a
82
+ # `platform` block, is still a call this file makes. `calls_within` in
83
+ # introspect.rb resolves helper methods to attribute actions to a *lane*; there
84
+ # is no such need here, because a literal path is a problem wherever it is
85
+ # written and belongs to the file rather than to any one lane.
86
+ def literals_in(node, out = [])
87
+ return out if node.nil?
88
+
89
+ node.compact_child_nodes.each do |child|
90
+ if child.is_a?(Prism::CallNode)
91
+ # Both node types, because `supply(json_key: "…")` parses as a
92
+ # KeywordHashNode and `supply({json_key: "…"})` as a HashNode. They are
93
+ # the same argument written two ways, and looking for only the first
94
+ # missed the braced form entirely — silently, since a scan that finds
95
+ # nothing is indistinguishable from a file with nothing to find.
96
+ hashes = (child.arguments&.arguments || []).select do |a|
97
+ a.is_a?(Prism::KeywordHashNode) || a.is_a?(Prism::HashNode)
98
+ end
99
+ hashes.flat_map(&:elements).each do |el|
100
+ next unless el.is_a?(Prism::AssocNode)
101
+ next unless el.key.is_a?(Prism::SymbolNode)
102
+ next unless el.value.is_a?(Prism::StringNode)
103
+ # A heredoc's location covers its *marker* — `<<~P` is four bytes —
104
+ # while its value lives on the lines below. Reporting that range would
105
+ # have the caller splice `ENV.fetch("...")` over the marker and leave
106
+ # the body stranded after the call: a Fastfile that no longer parses,
107
+ # written silently into someone's repository. Dropping heredocs costs
108
+ # nothing real, because a credential path is never written as one while
109
+ # `changelog:` and `message:` routinely are. Only Ruby can tell a
110
+ # heredoc from a quoted string, so the decision has to be made here.
111
+ next if el.value.opening&.start_with?("<<")
112
+
113
+ # `start_offset` and `length` are byte offsets, and must stay that way:
114
+ # the caller splices them into a Buffer. Prism also offers
115
+ # `start_character_offset`, and swapping one in would still pass any
116
+ # test whose fixture is pure ASCII while putting one accent above the
117
+ # literal enough to land a patch mid-string in a build file.
118
+ out << {
119
+ action: child.name.to_s,
120
+ arg: el.key.unescaped,
121
+ value: el.value.unescaped,
122
+ value_start: el.value.location.start_offset,
123
+ value_length: el.value.location.length,
124
+ pair_start: el.location.start_offset,
125
+ pair_length: el.location.length,
126
+ line: el.location.start_line
127
+ }
128
+ end
129
+ end
130
+ literals_in(child, out)
131
+ end
132
+ out
133
+ end
134
+
135
+ # A Fastfile that does not parse, told apart from everything else that can go
136
+ # wrong in the block below, so each gets a sentence that is true of it.
137
+ ParseFailure = Class.new(StandardError)
138
+
139
+ # Prism's first complaint, and the line it points at.
140
+ #
141
+ # "Fastfile could not be parsed" on its own leaves someone opening the file and
142
+ # hunting; a flow that is about to offer to *edit* that file owes them better
143
+ # than that. The first error is the one worth showing — the ones behind it are
144
+ # usually the same mistake seen again from further down.
145
+ def parse_failure(result)
146
+ first = result.errors.first
147
+ return "Fastfile could not be parsed" unless first
148
+
149
+ "Fastfile could not be parsed, line #{first.location.start_line}: #{first.message}"
150
+ end
151
+
152
+ begin
153
+ source = File.read(fastfile_path)
154
+ result = Prism.parse(source)
155
+ # A plain `raise`, not `fail_with`, and deliberately so: `fail_with` ends in
156
+ # `respond`'s `exit 0`, which raises SystemExit — a subclass of Exception, so
157
+ # calling it from inside this very `begin` would be caught by the `rescue`
158
+ # below and answered a second time, corrupting the output with two JSON
159
+ # blobs. Raising here and answering from the `rescue` clause (which is not
160
+ # itself guarded) is the same shape `introspect.rb` uses for this.
161
+ raise ParseFailure, parse_failure(result) unless result.success?
162
+ # Serialised here rather than at the `emit` below, so that a literal Prism
163
+ # read fine but JSON cannot represent — anything that is not valid UTF-8 —
164
+ # becomes an ordinary `{ "ok": false }` instead of a trace. Only the write
165
+ # stays outside the guard, for the SystemExit reason above.
166
+ payload = JSON.generate({ ok: true, literals: literals_in(result.value) })
167
+ rescue ParseFailure => e
168
+ # Already a whole sentence, and prefixing it produced the visible doubling
169
+ # this replaced: "Could not read the Fastfile: Fastfile could not be parsed",
170
+ # whose first half was also false — the read had succeeded.
171
+ fail_with(e.message)
172
+ rescue Exception => e # rubocop:disable Lint/RescueException
173
+ fail_with("Could not scan the Fastfile: #{e.message}")
174
+ end
175
+
176
+ emit(payload)