dialecto-rails 0.1.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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +196 -0
  4. data/action.yml +83 -0
  5. data/exe/dialecto-rails +6 -0
  6. data/lib/dialecto-rails.rb +1 -0
  7. data/lib/dialecto_rails/config.rb +206 -0
  8. data/lib/dialecto_rails/errors.rb +21 -0
  9. data/lib/dialecto_rails/in_context/args.rb +37 -0
  10. data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
  11. data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
  12. data/lib/dialecto_rails/in_context/csp.rb +72 -0
  13. data/lib/dialecto_rails/in_context/git_context.rb +103 -0
  14. data/lib/dialecto_rails/in_context/key_index.rb +128 -0
  15. data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
  16. data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
  17. data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
  18. data/lib/dialecto_rails/in_context/live_store.rb +14 -0
  19. data/lib/dialecto_rails/in_context/marker.rb +106 -0
  20. data/lib/dialecto_rails/in_context/marking.rb +122 -0
  21. data/lib/dialecto_rails/in_context/middleware.rb +202 -0
  22. data/lib/dialecto_rails/in_context/overrides.rb +61 -0
  23. data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
  24. data/lib/dialecto_rails/in_context/runtime.rb +52 -0
  25. data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
  26. data/lib/dialecto_rails/in_context/store.rb +41 -0
  27. data/lib/dialecto_rails/scan/catalogs.rb +46 -0
  28. data/lib/dialecto_rails/scan/cli.rb +339 -0
  29. data/lib/dialecto_rails/scan/client.rb +117 -0
  30. data/lib/dialecto_rails/scan/envelope.rb +100 -0
  31. data/lib/dialecto_rails/scan/git.rb +68 -0
  32. data/lib/dialecto_rails/scan/worktree.rb +41 -0
  33. data/lib/dialecto_rails/unmarked.rb +42 -0
  34. data/lib/dialecto_rails/usages/call_args.rb +78 -0
  35. data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
  36. data/lib/dialecto_rails/usages/check.rb +70 -0
  37. data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
  38. data/lib/dialecto_rails/usages/keys.rb +206 -0
  39. data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
  40. data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
  41. data/lib/dialecto_rails/usages/parse_check.rb +75 -0
  42. data/lib/dialecto_rails/usages/read_by_name.json +28 -0
  43. data/lib/dialecto_rails/usages/record.rb +91 -0
  44. data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
  45. data/lib/dialecto_rails/usages/scanner.rb +241 -0
  46. data/lib/dialecto_rails/usages/summary.rb +191 -0
  47. data/lib/dialecto_rails/usages/templates.rb +45 -0
  48. data/lib/dialecto_rails/usages/wrappers.rb +336 -0
  49. data/lib/dialecto_rails/version.rb +3 -0
  50. data/lib/dialecto_rails.rb +14 -0
  51. metadata +119 -0
@@ -0,0 +1,339 @@
1
+ require "json"
2
+ require "optparse"
3
+ require_relative "../errors"
4
+ require_relative "../config"
5
+ require_relative "git"
6
+ require_relative "worktree"
7
+ require_relative "catalogs"
8
+ require_relative "envelope"
9
+ require_relative "client"
10
+
11
+ module DialectoRails
12
+ module Scan
13
+ # `dialecto-rails scan | usages | check | doctor | version`.
14
+ # `run` returns the exit code: 0 ok, 1 findings, 2 can't start, 3 refused, 4 retryable.
15
+ class Cli
16
+ COMMANDS = %w[scan doctor version help].freeze
17
+ TOKEN_SHAPE = /\Adlk_[A-Za-z0-9_-]{43}\z/
18
+
19
+ def self.run(argv, env: ENV, out: $stdout, err: $stderr, root: Dir.pwd, client: nil, input: $stdin)
20
+ new(argv, env: env, out: out, err: err, root: root, client: client, input: input).run
21
+ end
22
+
23
+ def initialize(argv, env:, out:, err:, root:, client: nil, input: $stdin)
24
+ @argv = argv.dup
25
+ @env = env
26
+ @out = out
27
+ @err = err
28
+ @in = input
29
+ @root = root
30
+ @client_factory = client || ->(config) { Client.new(config, log: ->(line) { @err.puts(line) }) }
31
+ @flags = {}
32
+ end
33
+
34
+ def run
35
+ command = @argv.shift || "help"
36
+ case command
37
+ when "scan" then scan
38
+ when "doctor" then doctor
39
+ when "usages" then usages
40
+ when "check" then check
41
+ when "parse-check" then parse_check
42
+ when "version", "--version" then @out.puts("dialecto-rails #{VERSION}") || 0
43
+ when "help", "--help", "-h" then help
44
+ else
45
+ @err.puts("Unknown command #{command.inspect}.")
46
+ help(@err)
47
+ 2
48
+ end
49
+ rescue Error => e
50
+ @err.puts(e.message)
51
+ @err.puts(e.backtrace.first(8)) if @flags[:debug]
52
+ e.exit_code
53
+ end
54
+
55
+ private
56
+
57
+ def help(io = @out)
58
+ io.puts <<~HELP
59
+ dialecto-rails #{VERSION}
60
+
61
+ dialecto-rails scan [--branch NAME] [--sha SHA] [--usages] [--require-usages] [--worktree] [--dry-run] [--debug]
62
+ dialecto-rails usages [--summary] [--sha SHA] [--worktree] [--out FILE]
63
+ dialecto-rails check [--sha SHA] [--worktree]
64
+ dialecto-rails parse-check [--root DIR] (relative paths on standard input)
65
+ dialecto-rails doctor
66
+ dialecto-rails version
67
+
68
+ Settings come from the environment: DIALECTO_SCAN_TOKEN, DIALECTO_PROJECT (the project's number),
69
+ DIALECTO_URL, DIALECTO_CATALOG_PATHS, DIALECTO_USAGES, DIALECTO_DEFAULT_BRANCH.
70
+ HELP
71
+ 0
72
+ end
73
+
74
+ def parse_flags
75
+ OptionParser.new do |opts|
76
+ opts.on("--branch NAME") { |value| @flags[:branch] = value }
77
+ opts.on("--sha SHA") { |value| @flags[:sha] = value }
78
+ opts.on("--usages") { @flags[:usages] = true }
79
+ opts.on("--require-usages") { @flags[:require_usages] = true }
80
+ opts.on("--worktree") { @flags[:worktree] = true }
81
+ opts.on("--root DIR") { |value| @flags[:root] = value }
82
+ opts.on("--summary") { @flags[:summary] = true }
83
+ opts.on("--out FILE") { |value| @flags[:out] = value }
84
+ opts.on("--dry-run") { @flags[:dry_run] = true }
85
+ opts.on("--debug") { @flags[:debug] = true }
86
+ end.parse!(@argv)
87
+ rescue OptionParser::ParseError => e
88
+ raise ConfigError, e.message
89
+ end
90
+
91
+ def config
92
+ @config ||= Config.new({usages: (true if @flags[:usages])}.compact, @env)
93
+ end
94
+
95
+ ## scan
96
+
97
+ def scan
98
+ parse_flags
99
+ require_settings!
100
+ git = Git.new(@root)
101
+ raise ConfigError, "#{@root} isn't inside a git repository." unless git.repository?
102
+
103
+ sha = @flags[:sha] || git.head_sha
104
+ branch = @flags[:branch] || current_branch(git)
105
+ client = @client_factory.call(config)
106
+
107
+ unless @flags[:dry_run] || scanned_branch?(client, git, branch)
108
+ @out.puts("Dialecto doesn't scan the branch #{branch}; nothing sent.")
109
+ write_outputs("outcome" => "skipped")
110
+ return 0
111
+ end
112
+
113
+ # `--worktree` reads the folder as it is, dirty files included: a testing escape hatch, never what a PR is made from.
114
+ reader = @flags[:worktree] ? Worktree.new(@root) : git
115
+ @err.puts("Reading the working tree, not commit #{sha[0, 8]}: a pull request from this scan would carry these bytes.") if @flags[:worktree]
116
+ usages = usage_scan(reader, sha)
117
+ envelope = Envelope.build(git: reader, catalogs: Catalogs.new(config.catalog_paths), sha: sha, branch: branch,
118
+ usages: usages)
119
+
120
+ if @flags[:dry_run]
121
+ counts = envelope.counts
122
+ @out.puts("Dry run: #{counts[:files]} catalog files at #{sha[0, 8]} on #{branch}" \
123
+ "#{counts[:records] ? ", #{counts[:records]} usage records" : ""}; nothing sent.")
124
+ return 0
125
+ end
126
+
127
+ report(client.post_scan(envelope.body))
128
+ write_outputs("files" => envelope.counts[:files], "records" => envelope.counts[:records])
129
+ 0
130
+ end
131
+
132
+ # GitHub Actions step outputs, when the runner offers the file. Values come partly from the
133
+ # server's answer, so only a plain word is written: a newline would add outputs of its own.
134
+ def write_outputs(values)
135
+ path = @env["GITHUB_OUTPUT"].to_s
136
+ return if path.empty?
137
+
138
+ File.open(path, "a") do |file|
139
+ values.each { |name, value| file.puts("#{name}=#{value}") if value.to_s.match?(/\A[\w.-]+\z/) }
140
+ end
141
+ end
142
+
143
+ def require_settings!
144
+ raise ConfigError, "Set DIALECTO_SCAN_TOKEN to the project's scan token (Settings → Scan token)." unless config.scan_token
145
+ unless config.project&.match?(/\A\d+\z/)
146
+ raise ConfigError, "Set DIALECTO_PROJECT to the project's number, the <number> in its Dialecto " \
147
+ "URL /repos/<number>#{config.project ? " (got #{config.project.inspect})" : ""}."
148
+ end
149
+ problem = config.url_problem
150
+ raise ConfigError, problem if problem
151
+ end
152
+
153
+ def current_branch(git)
154
+ head_ref = @env["GITHUB_HEAD_REF"].to_s
155
+ return head_ref unless head_ref.empty?
156
+
157
+ ref = @env["GITHUB_REF_NAME"].to_s
158
+ return ref if @env["GITHUB_REF_TYPE"] == "branch" && !ref.empty?
159
+
160
+ git.branch || raise(ConfigError, "HEAD is detached: pass --branch NAME.")
161
+ end
162
+
163
+ # The default branch is always scanned; others when the scan mode is `all`, they are tracked, or they
164
+ # match the project's regex (the rule of the generated workflow and `Branches.scans_branch?`).
165
+ def scanned_branch?(client, git, branch)
166
+ default = config.default_branch || git.default_branch
167
+ return true if branch == default
168
+
169
+ settings = client.scan_config
170
+ return true if settings["scan_mode"] == "all"
171
+ return true if Array(settings["tracked_branches"]).include?(branch)
172
+
173
+ regex = settings["branch_regex"].to_s
174
+ return true if !regex.empty? && Regexp.new(regex).match?(branch)
175
+
176
+ # Without a known default branch a refusal here would skip the branch that matters most.
177
+ default.nil?
178
+ rescue RegexpError
179
+ false
180
+ end
181
+
182
+ ## usages and check
183
+
184
+ # Runs the usage scan for these two commands, which need no token and send nothing.
185
+ # `--worktree` reads the folder as it is, which needs no git repository.
186
+ def local_scan
187
+ parse_flags
188
+ reader, sha = scan_source
189
+
190
+ begin
191
+ require_relative "../usages/scanner"
192
+ rescue LoadError => e
193
+ raise ConfigError, "The usage scan needs the prism gem (#{e.message.lines.first.to_s.strip}); gem install prism."
194
+ end
195
+ scanner = DialectoRails::Usages::Scanner.new(git: reader, sha: sha, config: config)
196
+ [scanner.call, scanner.catalog]
197
+ end
198
+
199
+ def scan_source
200
+ return [Worktree.new(@root), @flags[:sha] || "worktree"] if @flags[:worktree]
201
+
202
+ git = Git.new(@root)
203
+ raise ConfigError, "#{@root} isn't inside a git repository." unless git.repository?
204
+
205
+ [git, @flags[:sha] || git.head_sha]
206
+ end
207
+
208
+ ## parse-check
209
+
210
+ # Reads relative paths, one per line, from standard input and checks that each still parses with the gem's
211
+ # own readers (`--root DIR`, default the current folder). For the rename check; it sends nothing.
212
+ def parse_check
213
+ parse_flags
214
+ begin
215
+ require_relative "../usages/parse_check"
216
+ rescue LoadError => e
217
+ raise ConfigError, "The parse check needs the prism gem (#{e.message.lines.first.to_s.strip}); gem install prism."
218
+ end
219
+ root = @flags[:root] || @root
220
+ files = @in.each_line.map(&:chomp).reject(&:empty?).map { |path| [path, File.binread(File.join(root, path))] }
221
+ result = DialectoRails::Usages::ParseCheck.call(files)
222
+ result[:failures].each { |failure| @out.puts("FAIL #{failure.path}:#{failure.line} #{failure.message}") }
223
+ @out.puts("checked #{result[:checked]}, skipped #{result[:skipped]}, failed #{result[:failures].length}")
224
+ result[:failures].empty? ? 0 : 1
225
+ end
226
+
227
+ def usages
228
+ require_relative "../usages/summary"
229
+ envelope, catalog = local_scan
230
+ @out.puts(DialectoRails::Usages::Summary.new(envelope, catalog.keys.to_a).render)
231
+ File.write(@flags[:out], JSON.pretty_generate(envelope)) if @flags[:out]
232
+ 0
233
+ end
234
+
235
+ def check
236
+ require_relative "../usages/check"
237
+ envelope, catalog = local_scan
238
+ check = DialectoRails::Usages::Check.new(envelope, catalog)
239
+ check.findings.each do |finding|
240
+ @out.puts("#{finding.warning ? "warning" : "error"} #{finding.file}:#{finding.line} #{finding.code}: #{finding.message}")
241
+ end
242
+ @out.puts("No findings.") if check.findings.empty?
243
+ check.failed? ? 1 : 0
244
+ end
245
+
246
+ def usage_scan(git, sha)
247
+ return nil unless config.usages?
248
+
249
+ require_relative "../usages/scanner"
250
+ DialectoRails::Usages::Scanner.new(git: git, sha: sha, config: config).call
251
+ rescue LoadError
252
+ usages_unavailable("the usage scan isn't available in this build")
253
+ rescue Error
254
+ raise
255
+ rescue => e
256
+ usages_unavailable("the usage scan failed (#{e.class}: #{e.message})")
257
+ end
258
+
259
+ def usages_unavailable(reason)
260
+ raise ConfigError, "#{reason}; usages are required (--require-usages)." if @flags[:require_usages] || config.require_usages?
261
+
262
+ @err.puts("Sending the catalogs without call sites: #{reason}. Renames stay unavailable until a scan carries them.")
263
+ nil
264
+ end
265
+
266
+ def report(answer)
267
+ outcome = answer["outcome"]
268
+ write_outputs("scan-id" => answer["scan_id"], "outcome" => outcome)
269
+ if outcome == "idempotent"
270
+ @out.puts("Already scanned (scan #{answer["scan_id"]}); nothing changed.")
271
+ else
272
+ counts = answer["counts"] || {}
273
+ @out.puts("Scan #{answer["scan_id"]} ingested: " \
274
+ "#{counts.map { |name, value| "#{value} #{name}" }.join(", ")}.")
275
+ end
276
+ Array(answer["skipped"]).each do |file|
277
+ @out.puts(" skipped #{file["path"]}: #{file["message"] || file["reason"]}")
278
+ end
279
+ end
280
+
281
+ ## doctor
282
+
283
+ def doctor
284
+ parse_flags
285
+ git = Git.new(@root)
286
+ checks = []
287
+ checks << ["Ruby #{RUBY_VERSION}", RUBY_VERSION >= "3.2", "Dialecto's Rails SDK needs Ruby 3.2 or newer."]
288
+ checks << ["git repository", git.repository?, "Run it inside the project's git checkout."]
289
+ remote = git.repository? ? git.remote_url : nil
290
+ checks << ["origin remote #{remote || "(none)"}", !remote.to_s.empty?, "Add an origin remote: `git remote add origin <url>`."]
291
+ checks << ["catalog files at HEAD", catalogs_present?(git), "No file matches #{config.catalog_paths.join(", ")}: set DIALECTO_CATALOG_PATHS."]
292
+ token = config.scan_token
293
+ checks << ["scan token set", !token.nil?, "Set DIALECTO_SCAN_TOKEN (Settings → Scan token)."]
294
+ checks << ["scan token shape", token.nil? || token.match?(TOKEN_SHAPE), "The token should look like dlk_ followed by 43 characters; copy it exactly as Dialecto showed it."]
295
+ checks << ["project #{config.project || "(unset)"}", !!config.project&.match?(/\A\d+\z/), "Set DIALECTO_PROJECT to the project's number."]
296
+ checks << ["url #{config.url}", config.url_problem.nil?, config.url_problem.to_s]
297
+ checks << parsers_check
298
+ checks << server_check(token) if token && config.project&.match?(/\A\d+\z/) && config.url_problem.nil?
299
+
300
+ checks.each { |label, ok, fix| @out.puts("#{ok ? "ok " : "FAIL"} #{label}#{ok ? "" : "\n #{fix}"}") }
301
+ worker_warning
302
+ checks.all? { |_label, ok, _fix| ok } ? 0 : 2
303
+ end
304
+
305
+ # The in-context editor keeps its drafts and markers in memory, one copy per worker process.
306
+ def worker_warning
307
+ workers = @env["WEB_CONCURRENCY"].to_i
308
+ return unless workers > 1
309
+
310
+ @out.puts("warn WEB_CONCURRENCY=#{workers}\n The in-context editor holds its state per process; with several workers a draft may " \
311
+ "vanish on the next request. Run the development server with one worker (the Rails default).")
312
+ end
313
+
314
+ def catalogs_present?(git)
315
+ return false unless git.repository?
316
+
317
+ catalogs = Catalogs.new(config.catalog_paths)
318
+ git.entries(git.head_sha).any? { |entry| catalogs.match?(entry.path) }
319
+ rescue Error
320
+ false
321
+ end
322
+
323
+ def parsers_check
324
+ require "prism"
325
+ ["prism #{Prism::VERSION}", true, ""]
326
+ rescue LoadError
327
+ ["prism", false, "Install it: gem install prism."]
328
+ end
329
+
330
+ def server_check(_token)
331
+ client = @client_factory.call(config)
332
+ client.scan_config
333
+ ["Dialecto accepts the token", true, ""]
334
+ rescue RefusedError, RetryableError => e
335
+ ["Dialecto accepts the token", false, e.message]
336
+ end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,117 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "openssl"
4
+ require "uri"
5
+
6
+ module DialectoRails
7
+ module Scan
8
+ # Talks to a Dialecto server under the project's scan token (docs/ingestion-api.md). Expected failures
9
+ # are `RefusedError` (fix the pipeline) or `RetryableError` (wait and rerun); the token never appears
10
+ # in a message.
11
+ class Client
12
+ Response = Struct.new(:status, :body, keyword_init: true)
13
+
14
+ RETRY_DELAYS = [1, 4, 16].freeze
15
+ MAX_RETRY_AFTER = 60
16
+ NETWORK_ERRORS = [
17
+ Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
18
+ Errno::EPIPE, SocketError, EOFError, OpenSSL::SSL::SSLError
19
+ ].freeze
20
+
21
+ def initialize(config, sleeper: Kernel.method(:sleep), jitter: -> { rand * 0.25 }, log: nil)
22
+ @config = config
23
+ @sleeper = sleeper
24
+ @jitter = jitter
25
+ @log = log
26
+ end
27
+
28
+ # `{scan_mode, branch_regex, tracked_branches}`.
29
+ def scan_config
30
+ JSON.parse(request(Net::HTTP::Get, "scan-config", nil).body)
31
+ rescue JSON::ParserError
32
+ raise RetryableError, "Dialecto's scan-config answer isn't JSON; try again in a moment."
33
+ end
34
+
35
+ # The server's answer to a scan: `{"scan_id", "outcome", "counts"?, "skipped"}`, with the HTTP status.
36
+ def post_scan(body)
37
+ response = request(Net::HTTP::Post, "scans", body)
38
+ parsed = begin
39
+ JSON.parse(response.body)
40
+ rescue JSON::ParserError
41
+ {}
42
+ end
43
+ parsed.merge("status" => response.status)
44
+ end
45
+
46
+ private
47
+
48
+ def request(klass, tail, body)
49
+ uri = URI.parse("#{@config.url}/api/repos/#{@config.project}/#{tail}")
50
+ attempts = 0
51
+ loop do
52
+ result = attempt(klass, uri, body)
53
+ return result if result.is_a?(Response)
54
+
55
+ attempts += 1
56
+ delay = RETRY_DELAYS[attempts - 1]
57
+ raise RetryableError, result if delay.nil?
58
+
59
+ wait = [delay, @retry_after.to_i].max + @jitter.call
60
+ @retry_after = nil
61
+ @log&.call("Dialecto answered #{result}; retrying in #{wait.round(1)}s (#{attempts}/#{RETRY_DELAYS.length})")
62
+ @sleeper.call(wait)
63
+ end
64
+ end
65
+
66
+ # A Response when the server gave a final answer; otherwise a String saying why it can be retried.
67
+ def attempt(klass, uri, body)
68
+ http = Net::HTTP.new(uri.host, uri.port)
69
+ http.use_ssl = uri.scheme == "https"
70
+ http.open_timeout = 10
71
+ http.read_timeout = 60
72
+ req = klass.new(uri.request_uri)
73
+ req["Authorization"] = "Bearer #{@config.scan_token}"
74
+ req["Accept"] = "application/json"
75
+ req["User-Agent"] = "dialecto-rails/#{VERSION}"
76
+ if body
77
+ req["Content-Type"] = "application/json"
78
+ req.body = body
79
+ end
80
+
81
+ res = http.start { |connection| connection.request(req) }
82
+ classify(res)
83
+ rescue *NETWORK_ERRORS => e
84
+ "a network error (#{e.class.name.split("::").last})"
85
+ end
86
+
87
+ def classify(res)
88
+ status = res.code.to_i
89
+ return Response.new(status: status, body: res.body.to_s) if status.between?(200, 299)
90
+
91
+ # A redirect (the apex domain answers 301) is not an ingest; following it would resend the token.
92
+ if status.between?(300, 399)
93
+ raise RefusedError, "HTTP #{status} to #{res["location"] || "(no Location)"}: check DIALECTO_URL"
94
+ end
95
+
96
+ if status == 429 || status >= 500
97
+ retry_after = res["retry-after"].to_i
98
+ @retry_after = [retry_after, MAX_RETRY_AFTER].min if retry_after.positive?
99
+ return "HTTP #{status}"
100
+ end
101
+
102
+ raise RefusedError, refusal(status, res.body.to_s)
103
+ end
104
+
105
+ def refusal(status, raw)
106
+ data = begin
107
+ JSON.parse(raw)
108
+ rescue JSON::ParserError
109
+ {}
110
+ end
111
+ message = data["message"] || data["error"] || "HTTP #{status}"
112
+ code = data["error"] && data["message"] ? " (#{data["error"]})" : ""
113
+ "#{message}#{code}"
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,100 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module DialectoRails
5
+ module Scan
6
+ # The scan request body (docs/ingestion-api.md): the committed catalog files verbatim, the commit
7
+ # and branch they were read at, and a checksum that makes a re-run a no-op.
8
+ class Envelope
9
+ MB = 1024 * 1024
10
+ MAX_FILES = 1_000
11
+ MAX_FILE_BYTES = 5 * MB
12
+ MAX_CATALOG_BYTES = 10 * MB
13
+ MAX_BODY_BYTES = 20 * MB
14
+
15
+ attr_reader :payload, :files
16
+
17
+ # `usages` is the usage scan's envelope (a Hash) or nil.
18
+ def self.build(git:, catalogs:, sha:, branch:, usages: nil)
19
+ new(git, catalogs, sha, branch, usages).tap(&:validate!)
20
+ end
21
+
22
+ def initialize(git, catalogs, sha, branch, usages)
23
+ @git = git
24
+ @sha = sha
25
+ @branch = branch
26
+ @usages = usages
27
+ @files = git.entries(sha).select { |entry| catalogs.match?(entry.path) }.sort_by(&:path)
28
+ end
29
+
30
+ def validate!
31
+ raise ConfigError, no_files_message if files.empty?
32
+ check_limits!
33
+ @payload = build_payload
34
+ body = JSON.generate(@payload)
35
+ return if body.bytesize <= MAX_BODY_BYTES
36
+
37
+ raise ConfigError, "The scan is #{format_mb(body.bytesize)} MB; Dialecto takes #{MAX_BODY_BYTES / MB} MB. " \
38
+ "Narrow DIALECTO_CATALOG_PATHS, or send fewer usage records."
39
+ end
40
+
41
+ def body = JSON.generate(payload)
42
+
43
+ def counts = {files: files.length, records: @usages ? @usages["usages"].length : nil}
44
+
45
+ private
46
+
47
+ def build_payload
48
+ templates = files.map do |entry|
49
+ content = @git.blob(entry.blob).force_encoding(Encoding::UTF_8)
50
+ unless content.valid_encoding?
51
+ raise ConfigError, "#{entry.path} isn't valid UTF-8, so it can't be sent as text. Dialecto " \
52
+ "reads UTF-8 catalogs: re-save the file as UTF-8 or leave it out of DIALECTO_CATALOG_PATHS."
53
+ end
54
+ {path: entry.path, content: content}
55
+ end
56
+
57
+ payload = {gitSha: @sha, gitBranch: @branch, checksum: checksum, templates: templates}
58
+ payload[:railsUsages] = @usages if @usages
59
+ payload
60
+ end
61
+
62
+ def check_limits!
63
+ if files.length > MAX_FILES
64
+ raise ConfigError, "#{delimit(files.length)} catalog files match; Dialecto takes #{delimit(MAX_FILES)}. " \
65
+ "Narrow DIALECTO_CATALOG_PATHS (for example to the source locale and the languages you translate)."
66
+ end
67
+
68
+ big = files.find { |entry| entry.size > MAX_FILE_BYTES }
69
+ if big
70
+ raise ConfigError, "#{big.path} is #{format_mb(big.size)} MB; Dialecto takes #{MAX_FILE_BYTES / MB} MB a file."
71
+ end
72
+
73
+ total = files.sum(&:size)
74
+ return if total <= MAX_CATALOG_BYTES
75
+
76
+ raise ConfigError, "The catalogs are #{format_mb(total)} MB; Dialecto takes #{MAX_CATALOG_BYTES / MB} MB. " \
77
+ "Narrow DIALECTO_CATALOG_PATHS."
78
+ end
79
+
80
+ # The commit, the branch and each sent file's blob id, plus the usage records, so a code-only change
81
+ # with unchanged catalogs is a new scan, a re-run is a no-op and a revert is a new scan.
82
+ def checksum
83
+ digest = Digest::SHA256.new
84
+ digest << "dialecto-rails/1\n#{@sha}\n#{@branch}\n"
85
+ files.each { |entry| digest << "#{@git.respond_to?(:blob_id) ? @git.blob_id(entry.blob) : entry.blob} #{entry.path}\n" }
86
+ digest << "usages:#{Digest::SHA256.hexdigest(JSON.generate(@usages))}\n" if @usages
87
+ digest.hexdigest
88
+ end
89
+
90
+ def no_files_message
91
+ "No file at #{@sha[0, 8]} matches the catalog paths. Dialecto reads the files your project's " \
92
+ "settings name; set DIALECTO_CATALOG_PATHS to match them (default config/locales/**/*.yml)."
93
+ end
94
+
95
+ def format_mb(bytes) = format("%.1f", bytes.to_f / MB)
96
+
97
+ def delimit(number) = number.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,68 @@
1
+ require "open3"
2
+
3
+ module DialectoRails
4
+ module Scan
5
+ # Reads a repository through `git`, with an argument array (no shell). The scanner reads the committed
6
+ # state at a sha, never the working tree, because that is what pull requests are made against.
7
+ class Git
8
+ Entry = Struct.new(:path, :blob, :size, keyword_init: true)
9
+
10
+ TIMEOUT = 30
11
+
12
+ def initialize(root = Dir.pwd)
13
+ @root = root
14
+ end
15
+
16
+ def repository?
17
+ run("rev-parse", "--is-inside-work-tree", chomp: true) == "true"
18
+ rescue ConfigError
19
+ false
20
+ end
21
+
22
+ def head_sha = run("rev-parse", "HEAD", chomp: true)
23
+
24
+ # The current branch name, or nil on a detached HEAD.
25
+ def branch
26
+ name = run("rev-parse", "--abbrev-ref", "HEAD", chomp: true)
27
+ name == "HEAD" ? nil : name
28
+ end
29
+
30
+ # The remote's default branch, or nil when `origin/HEAD` isn't set.
31
+ def default_branch
32
+ ref = run("symbolic-ref", "--short", "refs/remotes/origin/HEAD", chomp: true, allow_failure: true)
33
+ ref&.sub(%r{\Aorigin/}, "")
34
+ end
35
+
36
+ def remote_url = run("remote", "get-url", "origin", chomp: true, allow_failure: true)
37
+
38
+ # Every blob under `sha`: path, blob id and size. Symlinks and submodules are left out.
39
+ def entries(sha)
40
+ raw = run("ls-tree", "-r", "-z", "-l", sha)
41
+ raw.split("\0").filter_map do |line|
42
+ meta, path = line.split("\t", 2)
43
+ mode, type, blob, size = meta.split(" ")
44
+ next unless type == "blob" && mode != "120000" && path
45
+
46
+ Entry.new(path: path.dup.force_encoding(Encoding::UTF_8), blob: blob, size: size.to_i)
47
+ end
48
+ end
49
+
50
+ def blob(id) = run("cat-file", "blob", id)
51
+
52
+ private
53
+
54
+ def run(*args, chomp: false, allow_failure: false)
55
+ out, err, status = Open3.capture3("git", "-C", @root, *args, binmode: true)
56
+ unless status.success?
57
+ return nil if allow_failure
58
+
59
+ raise ConfigError, "git #{args.first} failed: #{err.to_s.strip.lines.first.to_s.strip}"
60
+ end
61
+
62
+ chomp ? out.strip : out
63
+ rescue Errno::ENOENT
64
+ raise ConfigError, "git isn't installed or isn't on the PATH"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,41 @@
1
+ require "digest"
2
+ require "find"
3
+ require_relative "git"
4
+
5
+ module DialectoRails
6
+ module Scan
7
+ # The `Git` reading interface over a plain folder: for `scan --worktree` (a testing escape hatch that sends
8
+ # what is on disk, not what is committed), and for sparse, blobless clones (the corpus), where `git
9
+ # cat-file` would fetch every missing blob from the network. Blob ids are git's, so the scan checksum still
10
+ # follows the bytes.
11
+ class Worktree
12
+ Entry = Git::Entry
13
+ SKIPPED = %w[.git node_modules].freeze
14
+
15
+ def initialize(root)
16
+ @root = root
17
+ @paths = {}
18
+ end
19
+
20
+ def entries(_sha = nil)
21
+ found = []
22
+ Find.find(@root) do |path|
23
+ if SKIPPED.include?(File.basename(path)) then Find.prune
24
+ elsif File.file?(path) && !File.symlink?(path)
25
+ size = File.size(path)
26
+ found << Entry.new(path: path.delete_prefix("#{@root}/"), blob: path, size: size)
27
+ end
28
+ end
29
+ found.sort_by(&:path)
30
+ end
31
+
32
+ # The id is the file's path here; `blob_id` gives git's id for the bytes, for the checksum.
33
+ def blob(id) = File.binread(id)
34
+
35
+ def blob_id(id)
36
+ bytes = File.binread(id)
37
+ Digest::SHA1.hexdigest("blob #{bytes.bytesize}\0".b + bytes)
38
+ end
39
+ end
40
+ end
41
+ end