carton-tools 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a9b5a58923b272d6d481087138eb847495d2503c428c3007ea31a924831ed6d9
4
+ data.tar.gz: f24945c590b134e414688a81276b674daef32fdb9f36771c4f8c9317ee7d6840
5
+ SHA512:
6
+ metadata.gz: feb37915b5d2f255568c3200f6ef947202a90345ab7bf2705db14dc50de4461cf63ac8a62d79dc652a3edd6c9d4aff2b9feb9328366e54a376478f208a030e32
7
+ data.tar.gz: b93e8b603de790feed455047fa6edc20aa0d5d009b21f706f85c84c8bc42d9f4ab626bcd307c23bfbed607c212a2893fb3832043bb5204e89b1182fde87607ff
data/LICENSE.txt ADDED
@@ -0,0 +1,19 @@
1
+ The MIT License (MIT)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # carton-tools
2
+
3
+ The `carton` command for [Carton](../README.md). It reads code with Prism and never runs it, so it needs no `RUBY_BOX=1`.
4
+
5
+ - `carton check main.rb` reports import and export problems: missing imports, cycles, `require` calls that reach into a Carton, missing keys of an exported Hash, and gems a gem Carton uses without declaring them.
6
+ - `carton rbs main.rb` writes `sig/generated/carton.rbs`, so [Steep](https://github.com/soutaro/steep) knows what each import returns.
7
+
8
+ Add it next to `carton`, in the group your checks run in:
9
+
10
+ ```ruby
11
+ # Gemfile
12
+ gem 'carton'
13
+ gem 'carton-tools', group: :development
14
+ ```
15
+
16
+ ```sh
17
+ bundle exec carton check main.rb
18
+ ```
19
+
20
+ carton-tools is released with carton and depends on exactly the same version. See [Types and checks](../docs/usage.md#types-and-checks) for what each command does.
21
+
22
+ Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
data/exe/carton ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/carton/tools'
5
+
6
+ exit Carton::Tools::CLI.start(ARGV)
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'project'
4
+
5
+ module Carton
6
+ module Tools
7
+ # The names in a Project bound to imports, `CONST = import ...` or
8
+ # `local = import ...`, and every use of each. A constant binding is seen
9
+ # by every file loaded in the same Box (the app's files, or one Carton's);
10
+ # a local binding only in its own file, and only when nothing reassigns it.
11
+ class Bindings
12
+ # A name bound to an imported Carton, with each Use of it.
13
+ Binding = Data.define(:name, :unit, :file, :line, :uses)
14
+
15
+ # One Reference to a binding, and the file it is in.
16
+ Use = Data.define(:file, :reference)
17
+
18
+ attr_reader :all
19
+
20
+ def initialize(project)
21
+ @project = project
22
+ @by_key = {}
23
+ @all = []
24
+ bind_imports
25
+ collect_uses
26
+ end
27
+
28
+ # Imports matched directly against a pattern, `import('math') => { add: }`,
29
+ # as [call, unit] pairs.
30
+ def patterns
31
+ @project.edges.filter_map do |edge|
32
+ [edge.call, edge.unit] if edge.unit && edge.call.pattern
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def bind_imports
39
+ clashing = {}
40
+ @project.edges.each do |edge|
41
+ call = edge.call
42
+ next unless edge.unit
43
+
44
+ key = binding_key(call)
45
+ next unless key
46
+
47
+ existing = @by_key[key]
48
+ clashing[key] = true if existing && existing.unit != edge.unit
49
+ next if existing
50
+
51
+ @by_key[key] = Binding.new(
52
+ name: call.constant || call.local,
53
+ unit: edge.unit,
54
+ file: call.file,
55
+ line: call.line,
56
+ uses: [],
57
+ )
58
+ end
59
+ clashing.each_key { |key| @by_key.delete(key) }
60
+ @all = @by_key.values
61
+ end
62
+
63
+ def binding_key(call)
64
+ if call.constant
65
+ [:constant, box_of(call.file), call.constant]
66
+ elsif call.local &&
67
+ @project.files[call.file].local_writes[call.local] == 1
68
+ [:local, call.file, call.local]
69
+ end
70
+ end
71
+
72
+ def collect_uses
73
+ @project.files.each_value do |file|
74
+ file.references.each do |reference|
75
+ binding = lookup(file.path, reference)
76
+ binding&.uses&.push(Use.new(file: file.path, reference:))
77
+ end
78
+ end
79
+ end
80
+
81
+ # A constant resolves lexically: in `module App`, `MathTools` may be
82
+ # `App::MathTools` or `::MathTools`.
83
+ def lookup(path, reference)
84
+ return @by_key[[:local, path, reference.name]] if reference.local
85
+
86
+ box = box_of(path)
87
+ candidates(reference).each do |name|
88
+ binding = @by_key[[:constant, box, name]]
89
+ return binding if binding
90
+ end
91
+ nil
92
+ end
93
+
94
+ def candidates(reference)
95
+ name = reference.name
96
+ return [name.delete_prefix('::')] if name.start_with?('::')
97
+
98
+ parts = reference.scope.to_s.split('::')
99
+ [
100
+ *parts.size.downto(1).map { |n| [*parts.first(n), name].join('::') },
101
+ name,
102
+ ]
103
+ end
104
+
105
+ # Files loaded in the same Box share constants: the app's, or one
106
+ # Carton's.
107
+ def box_of(path) = @project.owner(path)&.entrypoint
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'bindings'
4
+ require 'carton/namespace_methods'
5
+ require 'carton/namespace'
6
+ require_relative 'project'
7
+
8
+ module Carton
9
+ module Tools
10
+ # Problems `carton check` finds in a Project without running it. An error
11
+ # is certain to fail or misbehave when the code runs. A warning is
12
+ # something a dynamic program could still get right.
13
+ class Checks
14
+ Problem =
15
+ Data.define(:level, :file, :line, :message) do
16
+ def error? = level == :error
17
+ end
18
+
19
+ def initialize(project)
20
+ @project = project
21
+ @bindings = Bindings.new(project)
22
+ end
23
+
24
+ def problems
25
+ [
26
+ *parse_errors,
27
+ *import_problems,
28
+ *cycles,
29
+ *requires_into_cartons,
30
+ *undeclared_gems,
31
+ *export_problems,
32
+ *bare_collisions,
33
+ *missing_exports,
34
+ *private_constant_references,
35
+ ].sort_by { |problem| [problem.file, problem.line, problem.message] }
36
+ end
37
+
38
+ private
39
+
40
+ def parse_errors
41
+ @project.files.each_value.flat_map do |file|
42
+ file.errors.map { |line, message| error(file.path, line, message) }
43
+ end
44
+ end
45
+
46
+ # Missing paths and files inside a Carton raise at runtime. A name not on
47
+ # the load path may come from a gem activated at runtime, and a path
48
+ # built at runtime cannot be followed. `# carton:dynamic` on an import
49
+ # says either is intended, such as plugin discovery.
50
+ def import_problems
51
+ @project.edges.filter_map do |edge|
52
+ call = edge.call
53
+ if edge.error
54
+ error(call.file, call.line, edge.error)
55
+ elsif @project.files.fetch(call.file).dynamic_marker?(call.line)
56
+ next
57
+ elsif edge.unfound
58
+ warning(
59
+ call.file,
60
+ call.line,
61
+ "cannot find #{call.path} on the load path: pass -I DIR, " \
62
+ 'or run under bundle exec',
63
+ )
64
+ elsif call.path.nil?
65
+ warning(
66
+ call.file,
67
+ call.line,
68
+ "#{call.kind} path is not a literal, so it is not checked",
69
+ )
70
+ end
71
+ end
72
+ end
73
+
74
+ # An import cycle raises `Carton::ImportError` when every import in it
75
+ # runs while its file loads. An import inside a method or block may run
76
+ # after the other Carton has loaded.
77
+ def cycles
78
+ graph = Hash.new { |hash, key| hash[key] = [] }
79
+ @project.edges.each do |edge|
80
+ from = @project.owner(edge.call.file)
81
+ graph[from.entrypoint] << edge if from && edge.unit
82
+ end
83
+ find_cycles(graph).map do |cycle|
84
+ first = cycle.first.call
85
+ names = [cycle.last.unit, *cycle.map(&:unit)].map(&:name)
86
+ message = "import cycle: #{names.join(' -> ')}"
87
+ if cycle.any? { |edge| edge.call.lazy }
88
+ warning(
89
+ first.file,
90
+ first.line,
91
+ "#{message} (an import runs lazily, so it may load)",
92
+ )
93
+ else
94
+ error(first.file, first.line, message)
95
+ end
96
+ end
97
+ end
98
+
99
+ # Each elementary cycle once, starting from its smallest Entrypoint. Of
100
+ # parallel imports between the same Cartons, the eager one counts.
101
+ def find_cycles(graph)
102
+ cycles = []
103
+ graph.each_key.to_a.each do |start|
104
+ walk(graph, start, start, [], cycles)
105
+ end
106
+ cycles
107
+ .sort_by { |cycle| cycle.count { |edge| edge.call.lazy } }
108
+ .uniq { |cycle| cycle.map { |edge| edge.unit.entrypoint } }
109
+ end
110
+
111
+ def walk(graph, start, node, path, cycles)
112
+ graph[node].each do |edge|
113
+ target = edge.unit.entrypoint
114
+ if target == start
115
+ cycles << [*path, edge]
116
+ elsif target > start && path.none? { |e| e.unit.entrypoint == target }
117
+ walk(graph, start, target, [*path, edge], cycles)
118
+ end
119
+ end
120
+ end
121
+
122
+ # A `require` of an imported Carton's file from outside it loads a second
123
+ # copy into the requiring Box, which the runtime cannot detect.
124
+ def requires_into_cartons
125
+ @project.requires.filter_map do |call|
126
+ path = @project.locate(call) or next
127
+ unit = @project.unit_containing(path) or next
128
+ next if unit == @project.owner(call.file)
129
+
130
+ error(
131
+ call.file,
132
+ call.line,
133
+ "#{call.kind} '#{call.path}' loads a second copy of the #{unit.name} " \
134
+ "Carton's #{path == unit.entrypoint ? 'Entrypoint' : 'file'}: import it instead",
135
+ )
136
+ end
137
+ end
138
+
139
+ # A gem Carton's gemspec is its manifest: every other gem it imports or
140
+ # requires must be a runtime dependency there. Under a bundled app it
141
+ # still loads today, since the Carton sees the app's whole lockfile, but
142
+ # it fails wherever the gem is installed without that dependency. Default
143
+ # gems and Carton itself load in every Box.
144
+ def undeclared_gems
145
+ @project.units.each_value.flat_map do |unit|
146
+ spec = @project.gem_for(unit.entrypoint) or next []
147
+ declared = [spec.name, *spec.runtime_dependencies.map(&:name)]
148
+ calls = unit.files.flat_map { |path| loads(path) }
149
+ calls.filter_map do |call|
150
+ path = @project.target(call) or next
151
+ used = @project.gem_for(path) or next
152
+ next if used.default_gem? || used.name == 'carton'
153
+ next if declared.include?(used.name)
154
+
155
+ error(
156
+ call.file,
157
+ call.line,
158
+ "#{call.kind} '#{call.path}' loads the #{used.name} gem, which " \
159
+ "#{spec.name}'s gemspec does not declare: add " \
160
+ "spec.add_dependency '#{used.name}'",
161
+ )
162
+ end
163
+ end
164
+ end
165
+
166
+ def loads(path)
167
+ file = @project.files.fetch(path)
168
+ file.imports + file.requires
169
+ end
170
+
171
+ def export_problems
172
+ @project.files.each_value.flat_map do |file|
173
+ next [] if file.exports.empty?
174
+
175
+ if Resolver.entrypoint_for(file.path) == file.path
176
+ declaration_problems(file)
177
+ else
178
+ file.exports.map do |export|
179
+ warning(
180
+ file.path,
181
+ export.line,
182
+ 'export outside an Entrypoint does nothing: ' \
183
+ "this file is inside the Carton #{display(Resolver.entrypoint_for(file.path))}",
184
+ )
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ # The same rules `Carton::ExportError` enforces at runtime, for calls
191
+ # that can both run: calls in different branches of one `if` or `case`
192
+ # never do.
193
+ def declaration_problems(file)
194
+ file.exports.each_with_index.flat_map do |export, index|
195
+ problems = []
196
+ if export.symbol
197
+ problems << error(
198
+ file.path,
199
+ export.line,
200
+ 'export takes the value to export, not a name: ' \
201
+ 'export a module, or keywords for a Hash',
202
+ )
203
+ end
204
+ if file.exports.first(index).any? { |e| !e.exclusive?(export) }
205
+ problems << error(
206
+ file.path,
207
+ export.line,
208
+ 'export may be called only once per Entrypoint',
209
+ )
210
+ end
211
+ problems
212
+ end
213
+ end
214
+
215
+ # A Bare Carton's top-level methods become `Carton::Namespace` methods,
216
+ # so the same names collide.
217
+ def bare_collisions
218
+ @project.units.each_value.flat_map do |unit|
219
+ next [] unless @project.files.fetch(unit.entrypoint).exports.empty?
220
+
221
+ unit.files.flat_map do |path|
222
+ @project
223
+ .files
224
+ .fetch(path)
225
+ .methods
226
+ .each_value
227
+ .filter_map do |method|
228
+ next unless Namespace.reserved?(method.name)
229
+
230
+ error(path, method.line, collision(method.name))
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ def collision(name)
237
+ "a top-level method named #{name} would hide " \
238
+ "Carton::Namespace##{name}: rename it, or export a module"
239
+ end
240
+
241
+ # A `fetch` of a key a Carton's exported Hash does not have, which
242
+ # raises KeyError. `[]`, `key?`, and `in` patterns return nil or false
243
+ # instead. Only keyword and Hash literal exports are checked, since
244
+ # other exports' members are not declared in one place.
245
+ def missing_exports
246
+ from_bindings =
247
+ @bindings.all.flat_map do |binding|
248
+ names = hash_keys(binding.unit) or next []
249
+ binding.uses.filter_map do |use|
250
+ reference = use.reference
251
+ next unless raises?(reference)
252
+ next if names.include?(reference.member)
253
+
254
+ error(
255
+ use.file,
256
+ reference.line,
257
+ missing_message(
258
+ "#{reference.name}.fetch(:#{reference.member})",
259
+ reference.member,
260
+ binding.unit,
261
+ names,
262
+ ),
263
+ )
264
+ end
265
+ end
266
+ from_bindings + missing_pattern_keys
267
+ end
268
+
269
+ def missing_pattern_keys
270
+ @bindings.patterns.flat_map do |call, unit|
271
+ names = hash_keys(unit)
272
+ next [] unless names && call.pattern.kind == :fetch
273
+
274
+ (call.pattern.keys - ['**'] - names).map do |key|
275
+ error(
276
+ call.file,
277
+ call.line,
278
+ missing_message(
279
+ "#{call.kind}('#{call.path}') => { #{key}: }",
280
+ key,
281
+ unit,
282
+ names,
283
+ ),
284
+ )
285
+ end
286
+ end
287
+ end
288
+
289
+ # A `fetch` or `=>` pattern raises unless an enclosing `rescue` catches
290
+ # what it raises.
291
+ def raises?(reference)
292
+ return false unless reference.kind == :fetch
293
+
294
+ raised = %w[
295
+ KeyError
296
+ IndexError
297
+ NoMatchingPatternKeyError
298
+ NoMatchingPatternError
299
+ ]
300
+ !rescued?(reference, raised)
301
+ end
302
+
303
+ def rescued?(reference, raised)
304
+ caught = reference.rescued.map { |name| name.delete_prefix('::') }
305
+ caught.intersect?([*raised, 'StandardError', 'Exception'])
306
+ end
307
+
308
+ def missing_message(code, name, unit, names)
309
+ "#{code}: the #{unit.name} Carton's export has no key #{name} " \
310
+ "(it has #{names.join(', ')})"
311
+ end
312
+
313
+ # The keys of the Hash a Carton exports, or nil when it exports
314
+ # something else or its keys cannot be read statically.
315
+ def hash_keys(unit)
316
+ exports = @project.files.fetch(unit.entrypoint).exports
317
+ exports.first.keys&.keys if exports.size == 1
318
+ end
319
+
320
+ # `Billing::Tax`, where the billing Carton exports `Billing` and
321
+ # `Billing` makes `Tax` private, raises NameError.
322
+ def private_constant_references
323
+ @bindings.all.flat_map do |binding|
324
+ default = default_export(binding.unit) or next []
325
+ privates =
326
+ binding.unit.files.flat_map do |path|
327
+ @project.files.fetch(path).private_constants
328
+ end
329
+ binding.uses.filter_map do |use|
330
+ reference = use.reference
331
+ next unless reference.kind == :const
332
+ next unless privates.include?("#{default}::#{reference.member}")
333
+ next if rescued?(reference, %w[NameError])
334
+
335
+ error(
336
+ use.file,
337
+ reference.line,
338
+ "#{reference.name}::#{reference.member} is a private constant " \
339
+ "of the #{binding.unit.name} Carton",
340
+ )
341
+ end
342
+ end
343
+ end
344
+
345
+ # The constant a Carton exports, or nil.
346
+ def default_export(unit)
347
+ exports = @project.files.fetch(unit.entrypoint).exports
348
+ exports.first.value if exports.size == 1
349
+ end
350
+
351
+ def display(path) = Project.relative(path)
352
+
353
+ def error(file, line, message) = Problem.new(:error, file, line, message)
354
+
355
+ def warning(file, line, message)
356
+ Problem.new(:warning, file, line, message)
357
+ end
358
+ end
359
+ end
360
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'optparse'
5
+ require_relative 'checks'
6
+ require_relative 'signatures'
7
+
8
+ module Carton
9
+ module Tools
10
+ # `carton rbs` and `carton check`. Both read code with Prism and never run
11
+ # it, so neither needs `RUBY_BOX=1`.
12
+ class CLI
13
+ USAGE = <<~TEXT
14
+ Usage: carton COMMAND [options] PATH...
15
+
16
+ Commands:
17
+ rbs Write RBS for what each import returns
18
+ check Report import and export problems without running code
19
+
20
+ PATH is a Ruby file or a directory of them, such as main.rb or app.
21
+ Imports are followed into every Carton they reach.
22
+ TEXT
23
+
24
+ RBS_OUTPUT = 'sig/generated/carton.rbs'
25
+
26
+ def self.start(argv, out: $stdout, err: $stderr)
27
+ new(out:, err:).run(argv)
28
+ end
29
+
30
+ def initialize(out:, err:)
31
+ @out = out
32
+ @err = err
33
+ end
34
+
35
+ # Returns the exit status: 0 on success, 1 when `check` finds errors,
36
+ # and 2 for a usage error.
37
+ def run(argv)
38
+ command, *args = argv
39
+ case command
40
+ when 'rbs'
41
+ rbs(args)
42
+ when 'check'
43
+ check(args)
44
+ when '-h', '--help'
45
+ @out.puts USAGE
46
+ 0
47
+ else
48
+ @err.puts "carton: unknown command #{command}\n\n" if command
49
+ @err.puts USAGE
50
+ 2
51
+ end
52
+ rescue OptionParser::ParseError, Errno::ENOENT => e
53
+ @err.puts "carton: #{e.message}"
54
+ 2
55
+ end
56
+
57
+ private
58
+
59
+ def rbs(args)
60
+ output = RBS_OUTPUT
61
+ project =
62
+ project(args, 'rbs') do |o|
63
+ o.on(
64
+ '-o',
65
+ '--output FILE',
66
+ "Where to write the RBS (default #{RBS_OUTPUT}, - for stdout)",
67
+ ) { |file| output = file }
68
+ end
69
+ return 2 unless project
70
+
71
+ rbs = Signatures.new(project).to_s
72
+ if output == '-'
73
+ @out.print rbs
74
+ else
75
+ FileUtils.mkdir_p(File.dirname(output))
76
+ File.write(output, rbs)
77
+ @out.puts "Wrote #{output}"
78
+ end
79
+ 0
80
+ end
81
+
82
+ def check(args)
83
+ strict = false
84
+ project =
85
+ project(args, 'check') do |o|
86
+ o.on('--strict', 'Fail on warnings too, not only errors') do
87
+ strict = true
88
+ end
89
+ end
90
+ return 2 unless project
91
+
92
+ problems = Checks.new(project).problems
93
+ pwd = File.realpath(Dir.pwd)
94
+ problems.each do |problem|
95
+ file = Project.relative(problem.file)
96
+ message = problem.message.gsub("#{pwd}/", '').gsub(pwd, '.')
97
+ @out.puts "#{file}:#{problem.line}: #{problem.level}: #{message}"
98
+ end
99
+ errors = problems.count(&:error?)
100
+ @out.puts "#{project.files.size} files, #{project.units.size} Cartons, " \
101
+ "#{errors} errors, #{problems.size - errors} warnings"
102
+ failures = strict ? problems.size : errors
103
+ failures.zero? ? 0 : 1
104
+ end
105
+
106
+ # Parse options shared by every command, then scan PATHs. Named imports
107
+ # resolve against `-I` directories, then this process's `$LOAD_PATH`,
108
+ # which includes the bundle's gems under `bundle exec`, and files belong
109
+ # to the gems RubyGems knows: the bundle's, or every installed gem.
110
+ def project(args, command)
111
+ includes = []
112
+ parser =
113
+ OptionParser.new do |o|
114
+ o.banner = "Usage: carton #{command} [options] PATH..."
115
+ o.on(
116
+ '-I',
117
+ '--include DIR',
118
+ 'Add DIR to the load path for named imports',
119
+ ) { |dir| includes << File.expand_path(dir) }
120
+ yield o if block_given?
121
+ end
122
+ paths = parser.parse(args)
123
+ if paths.empty?
124
+ @err.puts parser
125
+ return
126
+ end
127
+
128
+ Project.new(paths, load_path: includes + $LOAD_PATH)
129
+ end
130
+ end
131
+
132
+ private_constant :Checks, :Project, :Signatures, :SourceFile
133
+ end
134
+ end