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 +7 -0
- data/LICENSE.txt +19 -0
- data/README.md +22 -0
- data/exe/carton +6 -0
- data/lib/carton/tools/bindings.rb +110 -0
- data/lib/carton/tools/checks.rb +360 -0
- data/lib/carton/tools/cli.rb +134 -0
- data/lib/carton/tools/project.rb +186 -0
- data/lib/carton/tools/signatures.rb +220 -0
- data/lib/carton/tools/source_file.rb +649 -0
- data/lib/carton/tools/version.rb +9 -0
- data/lib/carton/tools.rb +4 -0
- metadata +87 -0
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,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
|