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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/action.yml +83 -0
- data/exe/dialecto-rails +6 -0
- data/lib/dialecto-rails.rb +1 -0
- data/lib/dialecto_rails/config.rb +206 -0
- data/lib/dialecto_rails/errors.rb +21 -0
- data/lib/dialecto_rails/in_context/args.rb +37 -0
- data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
- data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
- data/lib/dialecto_rails/in_context/csp.rb +72 -0
- data/lib/dialecto_rails/in_context/git_context.rb +103 -0
- data/lib/dialecto_rails/in_context/key_index.rb +128 -0
- data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
- data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
- data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
- data/lib/dialecto_rails/in_context/live_store.rb +14 -0
- data/lib/dialecto_rails/in_context/marker.rb +106 -0
- data/lib/dialecto_rails/in_context/marking.rb +122 -0
- data/lib/dialecto_rails/in_context/middleware.rb +202 -0
- data/lib/dialecto_rails/in_context/overrides.rb +61 -0
- data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
- data/lib/dialecto_rails/in_context/runtime.rb +52 -0
- data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
- data/lib/dialecto_rails/in_context/store.rb +41 -0
- data/lib/dialecto_rails/scan/catalogs.rb +46 -0
- data/lib/dialecto_rails/scan/cli.rb +339 -0
- data/lib/dialecto_rails/scan/client.rb +117 -0
- data/lib/dialecto_rails/scan/envelope.rb +100 -0
- data/lib/dialecto_rails/scan/git.rb +68 -0
- data/lib/dialecto_rails/scan/worktree.rb +41 -0
- data/lib/dialecto_rails/unmarked.rb +42 -0
- data/lib/dialecto_rails/usages/call_args.rb +78 -0
- data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
- data/lib/dialecto_rails/usages/check.rb +70 -0
- data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
- data/lib/dialecto_rails/usages/keys.rb +206 -0
- data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
- data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
- data/lib/dialecto_rails/usages/parse_check.rb +75 -0
- data/lib/dialecto_rails/usages/read_by_name.json +28 -0
- data/lib/dialecto_rails/usages/record.rb +91 -0
- data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
- data/lib/dialecto_rails/usages/scanner.rb +241 -0
- data/lib/dialecto_rails/usages/summary.rb +191 -0
- data/lib/dialecto_rails/usages/templates.rb +45 -0
- data/lib/dialecto_rails/usages/wrappers.rb +336 -0
- data/lib/dialecto_rails/version.rb +3 -0
- data/lib/dialecto_rails.rb +14 -0
- metadata +119 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
require_relative "../version"
|
|
3
|
+
require_relative "../scan/catalogs"
|
|
4
|
+
require_relative "catalog_keys"
|
|
5
|
+
require_relative "ruby_visitor"
|
|
6
|
+
require_relative "wrappers"
|
|
7
|
+
require_relative "known_wrappers"
|
|
8
|
+
require_relative "templates"
|
|
9
|
+
|
|
10
|
+
module DialectoRails
|
|
11
|
+
module Usages
|
|
12
|
+
# The usage scan (spec-0023 §6): every place the code at one commit reads a translation key, in the shape
|
|
13
|
+
# spec-0022 §6 fixes (the `railsUsages` object). Reads committed blobs through `Scan::Git`, never the
|
|
14
|
+
# working tree.
|
|
15
|
+
class Scanner
|
|
16
|
+
VERSION = "1.0.0".freeze
|
|
17
|
+
MAX_BYTES = 1_000_000
|
|
18
|
+
MAX_NAMED_KEYS = 200
|
|
19
|
+
DEFAULT_EXCLUDES = %w[node_modules vendor tmp log coverage public/assets app/assets/builds].freeze
|
|
20
|
+
EXCLUDED_PREFIXES = %w[public/packs].freeze
|
|
21
|
+
# What a generator copies into a new app is text for another app: it holds ERB inside `.rb` and is never loaded.
|
|
22
|
+
GENERATOR_TEMPLATES = %r{(?:\A|/)lib/generators/(?:.+/)?templates/}
|
|
23
|
+
RUBY_EXTENSIONS = %w[.rb .rake .jbuilder .builder .arb].freeze
|
|
24
|
+
SCRIPT_EXTENSIONS = %w[.js .jsx .ts .tsx .mjs .cjs .coffee].freeze
|
|
25
|
+
I18N_JS_CONFIGS = %w[config/i18n.yml config/i18n-js.yml config/i18n.js].freeze
|
|
26
|
+
JS_STRING = /"((?:[^"\\\n]|\\.){1,200})"|'((?:[^'\\\n]|\\.){1,200})'|`([^`$\\\n]{1,200})`/
|
|
27
|
+
|
|
28
|
+
Source = Struct.new(:path, :lang, :bytes, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
# The catalog keys read at the scanned commit, available after `call`.
|
|
31
|
+
attr_reader :catalog
|
|
32
|
+
|
|
33
|
+
def initialize(git:, sha:, config:)
|
|
34
|
+
@git = git
|
|
35
|
+
@sha = sha
|
|
36
|
+
@config = config
|
|
37
|
+
@unscanned = []
|
|
38
|
+
@catalog = CatalogKeys.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
entries = @git.entries(@sha).reject { |entry| excluded?(entry.path) }
|
|
43
|
+
contents = {}
|
|
44
|
+
read = ->(entry) { contents[entry.path] ||= @git.blob(entry.blob) }
|
|
45
|
+
catalog_entries = entries.select { |entry| catalogs.match?(entry.path) }
|
|
46
|
+
catalog_entries.each { |entry| @catalog.add(read.call(entry)) }
|
|
47
|
+
|
|
48
|
+
sources = []
|
|
49
|
+
entries.each do |entry|
|
|
50
|
+
next if catalogs.match?(entry.path)
|
|
51
|
+
|
|
52
|
+
classify(entry, sources, read)
|
|
53
|
+
end
|
|
54
|
+
contents.clear
|
|
55
|
+
|
|
56
|
+
locked_gems = gems(entries, read)
|
|
57
|
+
declared = KnownWrappers.declared(@config.wrappers, locked_gems.map { |gem| gem["name"] })
|
|
58
|
+
wrappers = Wrappers.new(i18n_functions: @config.i18n_functions, declared: declared)
|
|
59
|
+
programs = {}
|
|
60
|
+
sources.each do |source|
|
|
61
|
+
program = parse(source)
|
|
62
|
+
next unless program
|
|
63
|
+
|
|
64
|
+
programs[source.path] = [source, program]
|
|
65
|
+
guarded(source) { wrappers.scout(source.path, program) unless RubyVisitor::TEST_FILE.match?(source.path) }
|
|
66
|
+
end
|
|
67
|
+
wrappers.resolve!
|
|
68
|
+
|
|
69
|
+
usages = []
|
|
70
|
+
conventions = []
|
|
71
|
+
programs.each do |path, (source, program)|
|
|
72
|
+
guarded(source) do
|
|
73
|
+
result = RubyVisitor.new(file: path, lang: source.lang.to_s, bytes: source.bytes, wrappers: wrappers,
|
|
74
|
+
catalog: @catalog, i18n_functions: @config.i18n_functions).call(program)
|
|
75
|
+
usages.concat(result.records)
|
|
76
|
+
conventions.concat(result.conventions)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
"version" => VERSION,
|
|
82
|
+
"parsers" => parsers,
|
|
83
|
+
"gems" => locked_gems,
|
|
84
|
+
"usages" => order(resolve_overlaps(usages)),
|
|
85
|
+
"unscanned" => @unscanned.sort_by { |item| [item["file"], item["reason"]] },
|
|
86
|
+
"conventions" => conventions.sort_by { |item| [item["file"], item["line"], item["key"].to_s] }
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def catalogs = @catalogs ||= Scan::Catalogs.new(@config.catalog_paths)
|
|
93
|
+
|
|
94
|
+
def custom_excludes = @custom_excludes ||= Scan::Catalogs.new(@config.usages_exclude)
|
|
95
|
+
|
|
96
|
+
def excluded?(path)
|
|
97
|
+
return true if DEFAULT_EXCLUDES.any? { |name| path == name || path.start_with?("#{name}/") || path.include?("/#{name}/") }
|
|
98
|
+
return true if EXCLUDED_PREFIXES.any? { |prefix| path.start_with?(prefix) }
|
|
99
|
+
return true if GENERATOR_TEMPLATES.match?(path)
|
|
100
|
+
|
|
101
|
+
!@config.usages_exclude.empty? && custom_excludes.match?(path)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def classify(entry, sources, read)
|
|
105
|
+
path = entry.path
|
|
106
|
+
extension = File.extname(path).downcase
|
|
107
|
+
if path.end_with?(".slim")
|
|
108
|
+
return unscanned(path, "Slim isn't scanned")
|
|
109
|
+
elsif SCRIPT_EXTENSIONS.include?(extension)
|
|
110
|
+
return script(entry, read)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
lang = template_language(path, extension)
|
|
114
|
+
return unless lang
|
|
115
|
+
|
|
116
|
+
bytes = entry.size > MAX_BYTES ? nil : read.call(entry)
|
|
117
|
+
return unscanned(path, "could not be parsed: file is larger than #{MAX_BYTES} bytes") unless bytes
|
|
118
|
+
|
|
119
|
+
bytes = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
120
|
+
return unscanned(path, "could not be parsed: the file is not valid UTF-8") unless bytes.valid_encoding?
|
|
121
|
+
|
|
122
|
+
sources << Source.new(path: path, lang: lang, bytes: bytes)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def template_language(path, extension)
|
|
126
|
+
return :ruby if RUBY_EXTENSIONS.include?(extension) || %w[Rakefile Gemfile].include?(File.basename(path))
|
|
127
|
+
return :erb if extension == ".erb"
|
|
128
|
+
|
|
129
|
+
:haml if extension == ".haml"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def script(entry, read)
|
|
133
|
+
return if entry.size > MAX_BYTES
|
|
134
|
+
|
|
135
|
+
if i18n_js_configured?
|
|
136
|
+
return unscanned(entry.path, "JavaScript that reads the locale files isn't scanned")
|
|
137
|
+
end
|
|
138
|
+
return if @catalog.empty?
|
|
139
|
+
|
|
140
|
+
text = read.call(entry).dup.force_encoding(Encoding::UTF_8).scrub("")
|
|
141
|
+
named = text.scan(JS_STRING).map { |groups| groups.compact.first.to_s }.select { |name| @catalog.include?(name) }.uniq.sort
|
|
142
|
+
return if named.empty?
|
|
143
|
+
|
|
144
|
+
# The keys the file names as strings stay locked server-side; the file itself is a place to check.
|
|
145
|
+
# A list past the cap would silently unlock the rest, so it is sent as "all": the file locks every key.
|
|
146
|
+
keys = named.size > MAX_NAMED_KEYS ? "all" : named
|
|
147
|
+
unscanned(entry.path, "JavaScript that names a catalog key isn't scanned", keys: keys)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def i18n_js_configured?
|
|
151
|
+
return @i18n_js unless @i18n_js.nil?
|
|
152
|
+
|
|
153
|
+
paths = @git.entries(@sha).map(&:path)
|
|
154
|
+
@i18n_js = I18N_JS_CONFIGS.any? { |name| paths.include?(name) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def unscanned(path, reason, keys: nil)
|
|
158
|
+
item = {"file" => path, "reason" => reason}
|
|
159
|
+
item["keys"] = keys if keys
|
|
160
|
+
@unscanned << item
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parse(source)
|
|
164
|
+
ruby = case source.lang
|
|
165
|
+
when :ruby then source.bytes
|
|
166
|
+
when :erb then Templates.erb(source.bytes)
|
|
167
|
+
when :haml then Templates.haml(source.bytes)
|
|
168
|
+
end
|
|
169
|
+
return haml_unavailable(source) if ruby.nil?
|
|
170
|
+
|
|
171
|
+
result = Prism.parse(ruby, version: "latest")
|
|
172
|
+
errors = source.lang == :ruby ? result.errors : result.errors.reject { |error| TEMPLATE_TOLERATED.include?(error.message) }
|
|
173
|
+
unless errors.empty?
|
|
174
|
+
unscanned(source.path, parse_reason(errors.first.message))
|
|
175
|
+
return nil
|
|
176
|
+
end
|
|
177
|
+
result.value
|
|
178
|
+
rescue Templates::Unreadable => e
|
|
179
|
+
unscanned(source.path, parse_reason(e.message)) # our own message: a line number, never source text
|
|
180
|
+
nil
|
|
181
|
+
rescue => e
|
|
182
|
+
# A parser's exception message may quote the source, so only its class leaves the machine.
|
|
183
|
+
unscanned(source.path, parse_reason("#{e.class.name} while reading the template"))
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
REASON_LIMIT = 200
|
|
188
|
+
|
|
189
|
+
# `could not be parsed: <message>`, the message cut to 200 bytes on a character boundary.
|
|
190
|
+
def parse_reason(message)
|
|
191
|
+
text = message.to_s.lines.first.to_s.strip.byteslice(0, REASON_LIMIT).to_s.dup.force_encoding(Encoding::UTF_8)
|
|
192
|
+
text = text.byteslice(0, text.bytesize - 1).force_encoding(Encoding::UTF_8) until text.empty? || text.valid_encoding?
|
|
193
|
+
"could not be parsed: #{text}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# A template's Ruby isn't inside a method: a layout's `yield` is fine there.
|
|
197
|
+
TEMPLATE_TOLERATED = ["Invalid yield", "Invalid return in class/module body"].freeze
|
|
198
|
+
|
|
199
|
+
# A bug in the scan on one file must not take the others down; the file is reported, not hidden.
|
|
200
|
+
def guarded(source)
|
|
201
|
+
yield
|
|
202
|
+
rescue => e
|
|
203
|
+
unscanned(source.path, parse_reason("internal error (#{e.class.name})"))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def haml_unavailable(source)
|
|
207
|
+
unscanned(source.path, "HAML isn't scanned")
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def gems(entries, read)
|
|
212
|
+
lock = entries.find { |entry| entry.path == "Gemfile.lock" }
|
|
213
|
+
return [] unless lock
|
|
214
|
+
|
|
215
|
+
specs = read.call(lock).dup.force_encoding(Encoding::UTF_8).scrub("").scan(/^ {4}(\S+) \(([^)\s]+)\)\s*$/)
|
|
216
|
+
specs.map { |name, version| {"name" => name, "version" => version} }.uniq.sort_by { |gem| [gem["name"], gem["version"]] }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def parsers
|
|
220
|
+
{"dialecto-rails" => VERSION_STRING, "prism" => Prism::VERSION, "herb" => Templates.herb_version,
|
|
221
|
+
"haml" => Templates.haml_version}.compact
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
VERSION_STRING = DialectoRails::VERSION
|
|
225
|
+
|
|
226
|
+
# A `literal` that lies inside a call record's anchor is already part of that use.
|
|
227
|
+
def resolve_overlaps(usages)
|
|
228
|
+
usages.group_by { |usage| usage["file"] }.flat_map do |_file, records|
|
|
229
|
+
claims = records.reject { |record| record["kind"] == "literal" }.map { |record| claim(record) }
|
|
230
|
+
records.reject do |record|
|
|
231
|
+
record["kind"] == "literal" && claims.any? { |from, to| record["offset"] >= from && record["offset"] < to }
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def claim(record) = [record["offset"], record["offset"] + record["content"].bytesize]
|
|
237
|
+
|
|
238
|
+
def order(usages) = usages.sort_by { |usage| [usage["file"].b, usage["offset"]] }
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module DialectoRails
|
|
4
|
+
module Usages
|
|
5
|
+
# The developer's view of a usage scan (spec-0023 §6.7): uses by kind, every catalog key sorted by how a
|
|
6
|
+
# rename could treat it, and what wasn't read. It follows the server's classification (dlocal
|
|
7
|
+
# `Dlocal.Localize.KeyRename`, spec-0022 §5.6), which decides; a parity test holds the two together.
|
|
8
|
+
#
|
|
9
|
+
# alone read only by calls that name it
|
|
10
|
+
# family a shared template or a group lookup reaches it: renamed only with its group
|
|
11
|
+
# locked Rails or a gem reads it by name, a JavaScript file names it as a string, or a call whose
|
|
12
|
+
# key is partly known could reach it. A file the scan can't read and a lookup of a wholly
|
|
13
|
+
# unknown key lock nothing: they are places to check, listed at the end.
|
|
14
|
+
# unreached nothing reads it
|
|
15
|
+
class Summary
|
|
16
|
+
# What Rails and the gems read by name; the server owns this file and the gem ships a copy.
|
|
17
|
+
READ_BY_NAME = JSON.parse(File.read(File.join(__dir__, "read_by_name.json"))).freeze
|
|
18
|
+
|
|
19
|
+
Row = Struct.new(:key, :klass, :because, :prefix, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
def initialize(envelope, catalog_keys)
|
|
22
|
+
@envelope = envelope
|
|
23
|
+
@keys = catalog_keys.sort
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def kinds = @envelope["usages"].group_by { |usage| usage["kind"] }.transform_values(&:length).sort.to_h
|
|
27
|
+
|
|
28
|
+
def rows
|
|
29
|
+
@rows ||= begin
|
|
30
|
+
index
|
|
31
|
+
@keys.map { |key| classify(key) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def counts = rows.group_by(&:klass).transform_values(&:length)
|
|
36
|
+
|
|
37
|
+
def render
|
|
38
|
+
lines = ["Uses: #{kinds.map { |kind, count| "#{count} #{kind}" }.join(", ").then { |text| text.empty? ? "none" : text }}"]
|
|
39
|
+
counts.then { |by| lines << "Catalog keys: " + %w[alone family locked unreached].map { |name| "#{by[name.to_sym] || 0} #{name}" }.join(", ") }
|
|
40
|
+
rows.select { |row| row.klass == :locked }.each { |row| lines << " #{row.key} can't be renamed: #{row.because}" }
|
|
41
|
+
unreached = rows.select { |row| row.klass == :unreached }
|
|
42
|
+
lines << "Nothing reads #{unreached.length} keys (first: #{unreached.first(3).map(&:key).join(", ")})." unless unreached.empty?
|
|
43
|
+
@envelope["unscanned"].each { |item| lines << "Not scanned: #{item["file"]} (#{item["reason"]})" }
|
|
44
|
+
unknown = @envelope["usages"].select { |usage| usage["kind"] == "pattern" && wholly_unknown?(usage) }
|
|
45
|
+
unknown.each { |usage| lines << "Unknown key: #{usage["file"]}:#{usage["start_line"]} (#{reason_text(usage["reason"])})" }
|
|
46
|
+
places = @envelope["unscanned"].length + unknown.length
|
|
47
|
+
lines << "#{places} places Dialecto can't read; you check them before a rename." if places.positive?
|
|
48
|
+
lines.join("\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def index
|
|
54
|
+
@index ||= begin
|
|
55
|
+
usages = @envelope["usages"]
|
|
56
|
+
static = {}
|
|
57
|
+
set_by = Hash.new { |hash, key| hash[key] = [] }
|
|
58
|
+
templates = {}
|
|
59
|
+
patterns = []
|
|
60
|
+
implicit = Hash.new { |hash, key| hash[key] = [] }
|
|
61
|
+
usages.each do |usage|
|
|
62
|
+
case usage["kind"]
|
|
63
|
+
when "static" then static[usage["key"]] = true
|
|
64
|
+
when "template" then templates[usage["id"]] = usage
|
|
65
|
+
when "set" then usage["keys"].each { |key| set_by[key] << usage }
|
|
66
|
+
when "pattern" then patterns << [usage, tokens(usage["parts"])] unless wholly_unknown?(usage)
|
|
67
|
+
end
|
|
68
|
+
Array(usage["defaults"]).each do |default|
|
|
69
|
+
if default.key?("span") then static[default["key"]] = true
|
|
70
|
+
else implicit[default["key"]] << usage
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
{ static: static, set_by: set_by, templates: templates, patterns: patterns, implicit: implicit,
|
|
75
|
+
rules: read_by_name_rules }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A lookup of a key nothing is known about (`t(key)`): a place to check, not a lock on every key.
|
|
80
|
+
def wholly_unknown?(usage) = usage["parts"].none? { |part| part.key?("lit") }
|
|
81
|
+
|
|
82
|
+
# Rails' namespaces, the prefixes of the scan's gems and the scan's own conventions.
|
|
83
|
+
def read_by_name_rules
|
|
84
|
+
namespaces = READ_BY_NAME["namespaces"].map { |entry| { prefix: entry["prefix"], reader: entry["reader"] } }
|
|
85
|
+
gems = Array(@envelope["gems"]).flat_map do |gem|
|
|
86
|
+
Array(READ_BY_NAME["gems"][gem["name"]]).map { |prefix| { prefix: prefix, reader: "the #{gem["name"]} gem reads this key by its name" } }
|
|
87
|
+
end
|
|
88
|
+
conventions = Array(@envelope["conventions"]).map do |entry|
|
|
89
|
+
{ prefix: entry["prefix"], key: entry["key"], reader: "the app reads this key by its name" }
|
|
90
|
+
end
|
|
91
|
+
namespaces + gems.uniq + conventions
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def classify(key)
|
|
95
|
+
because = lock_reason(key)
|
|
96
|
+
return Row.new(key: key, klass: :locked, because: because) if because
|
|
97
|
+
|
|
98
|
+
groups = group_starts(key)
|
|
99
|
+
if index[:set_by].key?(key) || !groups.empty?
|
|
100
|
+
family(key, groups)
|
|
101
|
+
elsif index[:static][key] then Row.new(key: key, klass: :alone)
|
|
102
|
+
else Row.new(key: key, klass: :unreached)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def lock_reason(key)
|
|
107
|
+
# A file the scan can't read is a place to check, not a lock; a key the file names as a string stays locked.
|
|
108
|
+
unscanned = @envelope["unscanned"].find { |item| item["keys"] == "all" || Array(item["keys"]).include?(key) }
|
|
109
|
+
return "#{unscanned["file"]} names it as a string (#{unscanned["reason"]})" if unscanned
|
|
110
|
+
|
|
111
|
+
rule = index[:rules].find { |entry| entry[:prefix] ? key.start_with?(entry[:prefix]) : entry[:key] == key }
|
|
112
|
+
return rule[:reader] if rule
|
|
113
|
+
|
|
114
|
+
implicit = index[:implicit][key].first
|
|
115
|
+
return "#{implicit["file"]}:#{implicit["start_line"]} reads it as a fallback Rails adds" if implicit
|
|
116
|
+
|
|
117
|
+
hit = index[:patterns].find { |_usage, tokens| matches?(tokens, key) }
|
|
118
|
+
return "#{hit.first["file"]}:#{hit.first["start_line"]} #{reason_text(hit.first["reason"])}" if hit
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def family(key, groups)
|
|
122
|
+
sets = index[:set_by][key]
|
|
123
|
+
starts = sets.map { |set| start_of(set) } + groups
|
|
124
|
+
prefix = starts.any?(&:nil?) ? "" : shared_prefix(starts)
|
|
125
|
+
if prefix.empty?
|
|
126
|
+
because = sets.map { |set| "#{set["file"]}:#{set["start_line"]} builds it from a template with no fixed dotted start" }.first
|
|
127
|
+
Row.new(key: key, klass: :locked, because: because)
|
|
128
|
+
else
|
|
129
|
+
Row.new(key: key, klass: :family, because: prefix, prefix: prefix)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def start_of(set)
|
|
134
|
+
origin = set["via"] && index[:templates][set["via"].first]
|
|
135
|
+
parts = set["via"] ? origin && origin["parts"] : set["parts"]
|
|
136
|
+
first = parts&.first
|
|
137
|
+
first && first["lit"]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The longest dotted prefix every start shares ("" when they share no dot).
|
|
141
|
+
def shared_prefix(starts)
|
|
142
|
+
common = starts.reduce do |left, right|
|
|
143
|
+
left[0, (0...[left.length, right.length].min).take_while { |at| left[at] == right[at] }.length]
|
|
144
|
+
end
|
|
145
|
+
segments = common.split(".", -1)
|
|
146
|
+
segments.length < 2 ? "" : segments[0...-1].join(".") + "."
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# `users.` for `users.index.title` when a call names the group `users`, which is no catalog key.
|
|
150
|
+
def group_starts(key)
|
|
151
|
+
@catalog ||= @keys.to_h { |catalog_key| [catalog_key, true] }
|
|
152
|
+
positions = (0...key.length).select { |at| key[at] == "." && at.positive? }
|
|
153
|
+
positions.filter_map do |at|
|
|
154
|
+
group = key[0, at]
|
|
155
|
+
"#{group}." if index[:static][group] && !@catalog[group]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A pattern is a glob: literals and `*`. A wildcard matches any text, empty included.
|
|
160
|
+
def tokens(parts)
|
|
161
|
+
parts.each_with_object([]) do |part, acc|
|
|
162
|
+
if part["wild"] then acc << :wild unless acc.last == :wild
|
|
163
|
+
elsif !part["lit"].to_s.empty?
|
|
164
|
+
acc.last.is_a?(String) ? acc.last << part["lit"] : acc << part["lit"].dup
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def matches?(tokens, key)
|
|
170
|
+
regexp = Regexp.new("\\A" + tokens.map { |token| token == :wild ? ".*" : Regexp.escape(token) }.join + "\\z", Regexp::MULTILINE)
|
|
171
|
+
regexp.match?(key)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def reason_text(reason)
|
|
175
|
+
{
|
|
176
|
+
"dynamic_scope" => "reads a key under a scope that isn't a literal",
|
|
177
|
+
"lazy_action_unknown" => "reads a lazy key outside a known action",
|
|
178
|
+
"lazy_outside_view" => "reads a lazy key outside a view",
|
|
179
|
+
"custom_separator" => "uses a custom separator",
|
|
180
|
+
"splat" => "passes options the scan can't read",
|
|
181
|
+
"argument" => "passes the key through a method the scan can't follow",
|
|
182
|
+
"concatenated_literal" => "builds a key from adjacent strings",
|
|
183
|
+
"interpolation" => "builds a key by interpolation",
|
|
184
|
+
"variable" => "reads a key from a variable",
|
|
185
|
+
"constant" => "reads a key from a constant",
|
|
186
|
+
"method_call" => "reads a key from a method call"
|
|
187
|
+
}.fetch(reason, "reads a key the scan can't resolve")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module DialectoRails
|
|
2
|
+
module Usages
|
|
3
|
+
# Template languages reduced to the Ruby inside them, with every byte kept where it was, so an offset in
|
|
4
|
+
# the reduced text is an offset in the template.
|
|
5
|
+
module Templates
|
|
6
|
+
# A template construct whose Ruby can't be put back in place; the message holds a line number, never source.
|
|
7
|
+
Unreadable = Class.new(StandardError)
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def erb(bytes)
|
|
12
|
+
require "herb"
|
|
13
|
+
Herb.extract_ruby(bytes)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# HAML is read when the app's bundle has the `haml` gem; nil otherwise.
|
|
17
|
+
def haml(bytes)
|
|
18
|
+
return nil unless haml_loadable?
|
|
19
|
+
|
|
20
|
+
require_relative "haml_extractor"
|
|
21
|
+
HamlExtractor.extract(bytes)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def haml_loadable?
|
|
25
|
+
return @haml_loadable unless @haml_loadable.nil?
|
|
26
|
+
|
|
27
|
+
@haml_loadable = begin
|
|
28
|
+
require "haml"
|
|
29
|
+
true
|
|
30
|
+
rescue LoadError
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def herb_version
|
|
36
|
+
require "herb"
|
|
37
|
+
Herb::VERSION
|
|
38
|
+
rescue LoadError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def haml_version = haml_loadable? ? Haml::VERSION : nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|