carton-tools 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9b5a58923b272d6d481087138eb847495d2503c428c3007ea31a924831ed6d9
4
- data.tar.gz: f24945c590b134e414688a81276b674daef32fdb9f36771c4f8c9317ee7d6840
3
+ metadata.gz: 3ae3c3331c46059824d9cfee8857d5db202c219b1559af6d9d8ef9f5c97ebc37
4
+ data.tar.gz: 3ea2079bd6ce6e8796bbe0d6285dfcccd910c89f47392eccabf5d31d13e3b053
5
5
  SHA512:
6
- metadata.gz: feb37915b5d2f255568c3200f6ef947202a90345ab7bf2705db14dc50de4461cf63ac8a62d79dc652a3edd6c9d4aff2b9feb9328366e54a376478f208a030e32
7
- data.tar.gz: b93e8b603de790feed455047fa6edc20aa0d5d009b21f706f85c84c8bc42d9f4ab626bcd307c23bfbed607c212a2893fb3832043bb5204e89b1182fde87607ff
6
+ metadata.gz: 2015b0648a2f087ea87fa9a8aa930afad24b36aa3ff5d4f1058ef6bde8d55fc95eee356d7476365177ed5ffa453f35043398d91a19b6a6789aa7d1331b3804a3
7
+ data.tar.gz: ad3864182e38083ed977bc231c9d8db3882c7e8289138f57036d06f62a5975880231a30ae2621965e5aabe7d9bc158e0eeed0549d0ceb2a61e5be43bf39d7a64
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # carton-tools
2
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`.
3
+ The `carton` command for [Carton](../README.md) apps with explicit imports and exports. It reads code with Prism and never runs it, so it needs no `RUBY_BOX=1`.
4
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.
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
6
  - `carton rbs main.rb` writes `sig/generated/carton.rbs`, so [Steep](https://github.com/soutaro/steep) knows what each import returns.
7
7
 
8
8
  Add it next to `carton`, in the group your checks run in:
@@ -17,6 +17,6 @@ gem 'carton-tools', group: :development
17
17
  bundle exec carton check main.rb
18
18
  ```
19
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.
20
+ carton-tools is released with carton and depends on exactly the same version. See [Types and checks](https://carton.dtcristo.com/guides/types-and-checks/) for what each command does.
21
21
 
22
22
  Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
@@ -10,7 +10,7 @@ module Carton
10
10
  # a local binding only in its own file, and only when nothing reassigns it.
11
11
  class Bindings
12
12
  # A name bound to an imported Carton, with each Use of it.
13
- Binding = Data.define(:name, :unit, :file, :line, :uses)
13
+ Binding = Data.define(:name, :local, :carton, :file, :line, :uses)
14
14
 
15
15
  # One Reference to a binding, and the file it is in.
16
16
  Use = Data.define(:file, :reference)
@@ -21,15 +21,32 @@ module Carton
21
21
  @project = project
22
22
  @by_key = {}
23
23
  @all = []
24
+ @defined = Hash.new { |hash, box| hash[box] = {} }
25
+ @project.files.each_value do |file|
26
+ (file.definitions.keys + file.constants.keys).each do |name|
27
+ @defined[box_of(file.path)][name] = true
28
+ end
29
+ end
24
30
  bind_imports
25
31
  collect_uses
26
32
  end
27
33
 
28
34
  # Imports matched directly against a pattern, `import('math') => { add: }`,
29
- # as [call, unit] pairs.
35
+ # as [call, carton] pairs.
30
36
  def patterns
31
37
  @project.edges.filter_map do |edge|
32
- [edge.call, edge.unit] if edge.unit && edge.call.pattern
38
+ [edge.call, edge.carton] if edge.carton && edge.call.pattern
39
+ end
40
+ end
41
+
42
+ # Definitions that open an imported value or a constant beneath it,
43
+ # as [path, definition, binding] triples. A definition is a use, too.
44
+ def reopenings
45
+ @project.files.each_value.flat_map do |file|
46
+ file.definition_sites.filter_map do |site|
47
+ binding = reopened_binding(file.path, site)
48
+ [file.path, site, binding] if binding
49
+ end
33
50
  end
34
51
  end
35
52
 
@@ -39,18 +56,19 @@ module Carton
39
56
  clashing = {}
40
57
  @project.edges.each do |edge|
41
58
  call = edge.call
42
- next unless edge.unit
59
+ next unless edge.carton
43
60
 
44
61
  key = binding_key(call)
45
62
  next unless key
46
63
 
47
64
  existing = @by_key[key]
48
- clashing[key] = true if existing && existing.unit != edge.unit
65
+ clashing[key] = true if existing && existing.carton != edge.carton
49
66
  next if existing
50
67
 
51
68
  @by_key[key] = Binding.new(
52
69
  name: call.constant || call.local,
53
- unit: edge.unit,
70
+ local: !call.local.nil?,
71
+ carton: edge.carton,
54
72
  file: call.file,
55
73
  line: call.line,
56
74
  uses: [],
@@ -83,25 +101,47 @@ module Carton
83
101
  def lookup(path, reference)
84
102
  return @by_key[[:local, path, reference.name]] if reference.local
85
103
 
104
+ constant(path, reference.name, reference.scope)
105
+ end
106
+
107
+ def constant(path, name, scope)
86
108
  box = box_of(path)
87
- candidates(reference).each do |name|
88
- binding = @by_key[[:constant, box, name]]
109
+ candidates(name, scope).each do |candidate|
110
+ binding = @by_key[[:constant, box, candidate]]
89
111
  return binding if binding
112
+ return nil if @defined[box][candidate]
90
113
  end
91
114
  nil
92
115
  end
93
116
 
94
- def candidates(reference)
95
- name = reference.name
117
+ def candidates(name, scope)
96
118
  return [name.delete_prefix('::')] if name.start_with?('::')
97
119
 
98
- parts = reference.scope.to_s.split('::')
120
+ parts = scope.to_s.split('::')
99
121
  [
100
122
  *parts.size.downto(1).map { |n| [*parts.first(n), name].join('::') },
101
123
  name,
102
124
  ]
103
125
  end
104
126
 
127
+ def reopened_binding(path, site)
128
+ # `module Local; class Billing` defines Local::Billing, even if an
129
+ # outer scope imported Billing. Qualified parents resolve lexically.
130
+ unless site.name.include?('::')
131
+ name = [site.scope, site.name].compact.join('::')
132
+ return @by_key[[:constant, box_of(path), name]]
133
+ end
134
+
135
+ parts = site.name.split('::')
136
+ parts
137
+ .size
138
+ .downto(1) do |size|
139
+ binding = constant(path, parts.first(size).join('::'), site.scope)
140
+ return binding if binding
141
+ end
142
+ nil
143
+ end
144
+
105
145
  # Files loaded in the same Box share constants: the app's, or one
106
146
  # Carton's.
107
147
  def box_of(path) = @project.owner(path)&.entrypoint
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'bindings'
4
- require 'carton/namespace_methods'
5
- require 'carton/namespace'
6
4
  require_relative 'project'
7
5
 
8
6
  module Carton
@@ -19,6 +17,7 @@ module Carton
19
17
  def initialize(project)
20
18
  @project = project
21
19
  @bindings = Bindings.new(project)
20
+ @reopenings = @bindings.reopenings
22
21
  end
23
22
 
24
23
  def problems
@@ -29,9 +28,11 @@ module Carton
29
28
  *requires_into_cartons,
30
29
  *undeclared_gems,
31
30
  *export_problems,
32
- *bare_collisions,
31
+ *missing_export_declarations,
33
32
  *missing_exports,
34
33
  *private_constant_references,
34
+ *unused_imports,
35
+ *reopened_classes,
35
36
  ].sort_by { |problem| [problem.file, problem.line, problem.message] }
36
37
  end
37
38
 
@@ -78,11 +79,11 @@ module Carton
78
79
  graph = Hash.new { |hash, key| hash[key] = [] }
79
80
  @project.edges.each do |edge|
80
81
  from = @project.owner(edge.call.file)
81
- graph[from.entrypoint] << edge if from && edge.unit
82
+ graph[from.entrypoint] << edge if from && edge.carton
82
83
  end
83
84
  find_cycles(graph).map do |cycle|
84
85
  first = cycle.first.call
85
- names = [cycle.last.unit, *cycle.map(&:unit)].map(&:name)
86
+ names = [cycle.last.carton, *cycle.map(&:carton)].map(&:name)
86
87
  message = "import cycle: #{names.join(' -> ')}"
87
88
  if cycle.any? { |edge| edge.call.lazy }
88
89
  warning(
@@ -105,15 +106,16 @@ module Carton
105
106
  end
106
107
  cycles
107
108
  .sort_by { |cycle| cycle.count { |edge| edge.call.lazy } }
108
- .uniq { |cycle| cycle.map { |edge| edge.unit.entrypoint } }
109
+ .uniq { |cycle| cycle.map { |edge| edge.carton.entrypoint } }
109
110
  end
110
111
 
111
112
  def walk(graph, start, node, path, cycles)
112
113
  graph[node].each do |edge|
113
- target = edge.unit.entrypoint
114
+ target = edge.carton.entrypoint
114
115
  if target == start
115
116
  cycles << [*path, edge]
116
- elsif target > start && path.none? { |e| e.unit.entrypoint == target }
117
+ elsif target > start &&
118
+ path.none? { |e| e.carton.entrypoint == target }
117
119
  walk(graph, start, target, [*path, edge], cycles)
118
120
  end
119
121
  end
@@ -124,28 +126,28 @@ module Carton
124
126
  def requires_into_cartons
125
127
  @project.requires.filter_map do |call|
126
128
  path = @project.locate(call) or next
127
- unit = @project.unit_containing(path) or next
128
- next if unit == @project.owner(call.file)
129
+ carton = @project.carton_containing(path) or next
130
+ next if carton == @project.owner(call.file)
129
131
 
130
132
  error(
131
133
  call.file,
132
134
  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
+ "#{call.kind} '#{call.path}' loads a second copy of the #{carton.name} " \
136
+ "Carton's #{path == carton.entrypoint ? 'Entrypoint' : 'file'}: import it instead",
135
137
  )
136
138
  end
137
139
  end
138
140
 
139
- # A gem Carton's gemspec is its manifest: every other gem it imports or
141
+ # A Gem Carton's gemspec is its manifest: every other gem it imports or
140
142
  # requires must be a runtime dependency there. Under a bundled app it
141
143
  # still loads today, since the Carton sees the app's whole lockfile, but
142
144
  # it fails wherever the gem is installed without that dependency. Default
143
145
  # gems and Carton itself load in every Box.
144
146
  def undeclared_gems
145
- @project.units.each_value.flat_map do |unit|
146
- spec = @project.gem_for(unit.entrypoint) or next []
147
+ @project.cartons.each_value.flat_map do |carton|
148
+ spec = @project.gem_for(carton.entrypoint) or next []
147
149
  declared = [spec.name, *spec.runtime_dependencies.map(&:name)]
148
- calls = unit.files.flat_map { |path| loads(path) }
150
+ calls = carton.files.flat_map { |path| loads(path) }
149
151
  calls.filter_map do |call|
150
152
  path = @project.target(call) or next
151
153
  used = @project.gem_for(path) or next
@@ -212,30 +214,16 @@ module Carton
212
214
  end
213
215
  end
214
216
 
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
217
+ # Only imported Entrypoints must export. The presence of a call is
218
+ # checked without proving that every path through the file reaches it.
219
+ def missing_export_declarations
220
+ @project.cartons.each_value.filter_map do |carton|
221
+ unless @project.files.fetch(carton.entrypoint).export_lines.empty?
222
+ next
232
223
  end
233
- end
234
- end
235
224
 
236
- def collision(name)
237
- "a top-level method named #{name} would hide " \
238
- "Carton::Namespace##{name}: rename it, or export a module"
225
+ error(carton.entrypoint, 1, 'an imported Entrypoint must call export')
226
+ end
239
227
  end
240
228
 
241
229
  # A `fetch` of a key a Carton's exported Hash does not have, which
@@ -245,7 +233,7 @@ module Carton
245
233
  def missing_exports
246
234
  from_bindings =
247
235
  @bindings.all.flat_map do |binding|
248
- names = hash_keys(binding.unit) or next []
236
+ names = hash_keys(binding.carton) or next []
249
237
  binding.uses.filter_map do |use|
250
238
  reference = use.reference
251
239
  next unless raises?(reference)
@@ -257,7 +245,7 @@ module Carton
257
245
  missing_message(
258
246
  "#{reference.name}.fetch(:#{reference.member})",
259
247
  reference.member,
260
- binding.unit,
248
+ binding.carton,
261
249
  names,
262
250
  ),
263
251
  )
@@ -267,8 +255,8 @@ module Carton
267
255
  end
268
256
 
269
257
  def missing_pattern_keys
270
- @bindings.patterns.flat_map do |call, unit|
271
- names = hash_keys(unit)
258
+ @bindings.patterns.flat_map do |call, carton|
259
+ names = hash_keys(carton)
272
260
  next [] unless names && call.pattern.kind == :fetch
273
261
 
274
262
  (call.pattern.keys - ['**'] - names).map do |key|
@@ -278,7 +266,7 @@ module Carton
278
266
  missing_message(
279
267
  "#{call.kind}('#{call.path}') => { #{key}: }",
280
268
  key,
281
- unit,
269
+ carton,
282
270
  names,
283
271
  ),
284
272
  )
@@ -305,15 +293,15 @@ module Carton
305
293
  caught.intersect?([*raised, 'StandardError', 'Exception'])
306
294
  end
307
295
 
308
- def missing_message(code, name, unit, names)
309
- "#{code}: the #{unit.name} Carton's export has no key #{name} " \
296
+ def missing_message(code, name, carton, names)
297
+ "#{code}: the #{carton.name} Carton's export has no key #{name} " \
310
298
  "(it has #{names.join(', ')})"
311
299
  end
312
300
 
313
301
  # The keys of the Hash a Carton exports, or nil when it exports
314
302
  # something else or its keys cannot be read statically.
315
- def hash_keys(unit)
316
- exports = @project.files.fetch(unit.entrypoint).exports
303
+ def hash_keys(carton)
304
+ exports = @project.files.fetch(carton.entrypoint).exports
317
305
  exports.first.keys&.keys if exports.size == 1
318
306
  end
319
307
 
@@ -321,33 +309,79 @@ module Carton
321
309
  # `Billing` makes `Tax` private, raises NameError.
322
310
  def private_constant_references
323
311
  @bindings.all.flat_map do |binding|
324
- default = default_export(binding.unit) or next []
312
+ constant = exported_constant(binding.carton) or next []
325
313
  privates =
326
- binding.unit.files.flat_map do |path|
314
+ binding.carton.files.flat_map do |path|
327
315
  @project.files.fetch(path).private_constants
328
316
  end
329
317
  binding.uses.filter_map do |use|
330
318
  reference = use.reference
331
319
  next unless reference.kind == :const
332
- next unless privates.include?("#{default}::#{reference.member}")
320
+ next unless privates.include?("#{constant}::#{reference.member}")
333
321
  next if rescued?(reference, %w[NameError])
334
322
 
335
323
  error(
336
324
  use.file,
337
325
  reference.line,
338
326
  "#{reference.name}::#{reference.member} is a private constant " \
339
- "of the #{binding.unit.name} Carton",
327
+ "of the #{binding.carton.name} Carton",
340
328
  )
341
329
  end
342
330
  end
343
331
  end
344
332
 
345
333
  # The constant a Carton exports, or nil.
346
- def default_export(unit)
347
- exports = @project.files.fetch(unit.entrypoint).exports
334
+ def exported_constant(carton)
335
+ exports = @project.files.fetch(carton.entrypoint).exports
348
336
  exports.first.value if exports.size == 1
349
337
  end
350
338
 
339
+ # A binding with no reads may be dead code. Constants on a Carton's
340
+ # Public Surface are also used implicitly by its Importers.
341
+ def unused_imports
342
+ @bindings.all.filter_map do |binding|
343
+ next unless binding.uses.empty?
344
+ next if public_binding?(binding)
345
+ next if @reopenings.any? { |_, _, used| used.equal?(binding) }
346
+
347
+ warning(
348
+ binding.file,
349
+ binding.line,
350
+ "#{binding.name} is bound to an import of the #{binding.carton.name} " \
351
+ 'Carton but never used',
352
+ )
353
+ end
354
+ end
355
+
356
+ def public_binding?(binding)
357
+ return false if binding.local
358
+
359
+ carton = @project.owner(binding.file) or return false
360
+ exports = @project.files.fetch(carton.entrypoint).exports
361
+
362
+ exports.any? do |export|
363
+ name = export.value&.delete_prefix('::')
364
+ name &&
365
+ (binding.name == name || binding.name.start_with?("#{name}::"))
366
+ end
367
+ end
368
+
369
+ # Classes and modules returned by Imports are shared objects. Opening
370
+ # one, or defining a constant beneath it, mutates that shared object.
371
+ def reopened_classes
372
+ @reopenings.filter_map do |path, site, binding|
373
+ next if binding.carton == @project.owner(path)
374
+
375
+ warning(
376
+ path,
377
+ site.line,
378
+ "#{site.kind} #{site.name} opens #{binding.name} from the " \
379
+ "#{binding.carton.name} Carton, changing a shared class or module: " \
380
+ 'subclass it or use a refinement instead',
381
+ )
382
+ end
383
+ end
384
+
351
385
  def display(path) = Project.relative(path)
352
386
 
353
387
  def error(file, line, message) = Problem.new(:error, file, line, message)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'json'
4
5
  require 'optparse'
5
6
  require_relative 'checks'
6
7
  require_relative 'signatures'
@@ -81,28 +82,64 @@ module Carton
81
82
 
82
83
  def check(args)
83
84
  strict = false
85
+ format = 'text'
84
86
  project =
85
87
  project(args, 'check') do |o|
86
88
  o.on('--strict', 'Fail on warnings too, not only errors') do
87
89
  strict = true
88
90
  end
91
+ o.on(
92
+ '--format FORMAT',
93
+ %w[text json],
94
+ 'text (default) or json',
95
+ ) { |value| format = value }
89
96
  end
90
97
  return 2 unless project
91
98
 
92
99
  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
100
  errors = problems.count(&:error?)
100
- @out.puts "#{project.files.size} files, #{project.units.size} Cartons, " \
101
- "#{errors} errors, #{problems.size - errors} warnings"
101
+ summary = {
102
+ files: project.files.size,
103
+ cartons: project.cartons.size,
104
+ errors:,
105
+ warnings: problems.size - errors,
106
+ }
107
+ if format == 'json'
108
+ print_json(problems, summary)
109
+ else
110
+ print_text(problems, summary)
111
+ end
102
112
  failures = strict ? problems.size : errors
103
113
  failures.zero? ? 0 : 1
104
114
  end
105
115
 
116
+ def print_text(problems, summary)
117
+ problems.each do |problem|
118
+ @out.puts "#{Project.relative(problem.file)}:#{problem.line}: " \
119
+ "#{problem.level}: #{relative_message(problem.message)}"
120
+ end
121
+ @out.puts "#{summary[:files]} files, #{summary[:cartons]} Cartons, " \
122
+ "#{summary[:errors]} errors, #{summary[:warnings]} warnings"
123
+ end
124
+
125
+ def print_json(problems, summary)
126
+ list =
127
+ problems.map do |problem|
128
+ {
129
+ level: problem.level,
130
+ file: Project.relative(problem.file),
131
+ line: problem.line,
132
+ message: relative_message(problem.message),
133
+ }
134
+ end
135
+ @out.puts JSON.pretty_generate(problems: list, **summary)
136
+ end
137
+
138
+ def relative_message(message)
139
+ pwd = File.realpath(Dir.pwd)
140
+ message.gsub("#{pwd}/", '').gsub(pwd, '.')
141
+ end
142
+
106
143
  # Parse options shared by every command, then scan PATHs. Named imports
107
144
  # resolve against `-I` directories, then this process's `$LOAD_PATH`,
108
145
  # which includes the bundle's gems under `bundle exec`, and files belong
@@ -9,23 +9,23 @@ module Carton
9
9
  # The Cartons an app imports, found by following `import` and
10
10
  # `import_relative` calls from the given files without running them.
11
11
  # Name-based imports resolve against `load_path`, as they would against the
12
- # importing Box's `$LOAD_PATH`. `specs` are the gems that files may belong
12
+ # Importer's `$LOAD_PATH`. `specs` are the gems that files may belong
13
13
  # to: under Bundler, the bundle's.
14
14
  class Project
15
- # A Carton: its Entrypoint, its files (the Entrypoint plus its namespace
16
- # directory), and the `lib/` a gem-shaped directory import adds to its
17
- # load path.
18
- Unit =
15
+ # A Carton: its Entrypoint, its files (the Entrypoint plus its Interior),
16
+ # and the `lib/` an Import of a Gem-shaped Directory adds to its load
17
+ # path.
18
+ Carton =
19
19
  Data.define(:entrypoint, :files, :load_path) do
20
20
  def name = File.basename(entrypoint, '.rb')
21
21
  end
22
22
 
23
- # One import call and what it resolved to: a Unit, an error message, or
24
- # a name not found on the load path, which a gem activated at runtime
23
+ # One import call and what it resolved to: a Carton, an error message,
24
+ # or a name not found on the load path, which a gem activated at runtime
25
25
  # may still provide.
26
- Edge = Data.define(:call, :unit, :error, :unfound)
26
+ Edge = Data.define(:call, :carton, :error, :unfound)
27
27
 
28
- attr_reader :files, :units, :edges
28
+ attr_reader :files, :cartons, :edges
29
29
 
30
30
  # `path` relative to the current directory, for messages and comments.
31
31
  def self.relative(path)
@@ -37,8 +37,8 @@ module Carton
37
37
  @load_path = load_path
38
38
  @specs = specs
39
39
  @files = {}
40
- @units = {}
41
- @unit_by_file = {}
40
+ @cartons = {}
41
+ @carton_by_file = {}
42
42
  ruby_files(paths).each { |path| parse(path) }
43
43
  discover
44
44
  @edges = imports.map { |call| resolve(call) }
@@ -51,7 +51,7 @@ module Carton
51
51
  return locate(call)
52
52
  end
53
53
 
54
- @edge_by_call[call]&.unit&.entrypoint
54
+ @edge_by_call[call]&.carton&.entrypoint
55
55
  end
56
56
 
57
57
  # The gem whose require paths hold `path`, or nil.
@@ -62,8 +62,8 @@ module Carton
62
62
  &.last
63
63
  end
64
64
 
65
- # The Unit `file` belongs to, or nil for the app, which runs in Main.
66
- def owner(file) = @unit_by_file[file]
65
+ # The Carton `file` belongs to, or nil for the app, which runs in Main.
66
+ def owner(file) = @carton_by_file[file]
67
67
 
68
68
  def imports = @files.values.flat_map(&:imports)
69
69
 
@@ -86,9 +86,9 @@ module Carton
86
86
  nil
87
87
  end
88
88
 
89
- # The Unit whose files include `path`, if that Unit is imported.
90
- def unit_containing(path)
91
- @unit_by_file[path] || @units[Resolver.entrypoint_for(path)]
89
+ # The Carton whose files include `path`, if that Carton is imported.
90
+ def carton_containing(path)
91
+ @carton_by_file[path] || @cartons[Resolver.entrypoint_for(path)]
92
92
  end
93
93
 
94
94
  private
@@ -122,26 +122,26 @@ module Carton
122
122
  required =
123
123
  requires.select(&:relative?).filter_map { |call| locate(call) }
124
124
  required = required.grep(/\.rb\z/).reject { |path| @files.key?(path) }
125
- found = imports.filter_map { |call| resolve(call).unit }
126
- fresh = found.reject { |unit| @units.key?(unit.entrypoint) }
125
+ found = imports.filter_map { |call| resolve(call).carton }
126
+ fresh = found.reject { |carton| @cartons.key?(carton.entrypoint) }
127
127
  break if required.empty? && fresh.empty?
128
128
 
129
129
  required.each { |path| parse(path) }
130
- fresh.each { |unit| add_unit(unit) }
130
+ fresh.each { |carton| add_carton(carton) }
131
131
  end
132
132
  end
133
133
 
134
- def add_unit(unit)
135
- @units[unit.entrypoint] = unit
136
- unit.files.each do |file|
137
- @unit_by_file[file] ||= unit
134
+ def add_carton(carton)
135
+ @cartons[carton.entrypoint] = carton
136
+ carton.files.each do |file|
137
+ @carton_by_file[file] ||= carton
138
138
  parse(file)
139
139
  end
140
140
  end
141
141
 
142
142
  def resolve(call)
143
143
  unless call.path
144
- return Edge.new(call:, unit: nil, error: nil, unfound: false)
144
+ return Edge.new(call:, carton: nil, error: nil, unfound: false)
145
145
  end
146
146
 
147
147
  base_dir = File.dirname(call.file) if call.relative?
@@ -152,24 +152,24 @@ module Carton
152
152
  base_dir:,
153
153
  )
154
154
  unless File.absolute_path?(target.feature)
155
- return Edge.new(call:, unit: nil, error: nil, unfound: true)
155
+ return Edge.new(call:, carton: nil, error: nil, unfound: true)
156
156
  end
157
157
 
158
- Edge.new(call:, unit: unit_for(target), error: nil, unfound: false)
158
+ Edge.new(call:, carton: carton_for(target), error: nil, unfound: false)
159
159
  rescue ImportError, LoadError => e
160
- Edge.new(call:, unit: nil, error: e.message, unfound: false)
160
+ Edge.new(call:, carton: nil, error: e.message, unfound: false)
161
161
  end
162
162
 
163
- def unit_for(target)
163
+ def carton_for(target)
164
164
  entrypoint = File.realpath(target.feature)
165
- @units.fetch(entrypoint) do
165
+ @cartons.fetch(entrypoint) do
166
166
  dir = entrypoint.delete_suffix('.rb')
167
167
  files =
168
168
  Dir
169
169
  .glob('**/*.rb', base: dir)
170
170
  .sort
171
171
  .map { |file| File.realpath(File.join(dir, file)) }
172
- Unit.new(
172
+ Carton.new(
173
173
  entrypoint:,
174
174
  files: [entrypoint, *files],
175
175
  load_path: target.load_path,
@@ -7,8 +7,6 @@ module Carton
7
7
  # RBS for a Project's imports, so a type checker like Steep knows what each
8
8
  # `import` returns. An exported constant names the Carton's own type,
9
9
  # which the Carton's RBS declares, and a keyword export is a record type.
10
- # Bare Cartons each get a module under `Carton::Namespace`, typed from
11
- # rbs-inline `#:` annotations and literal values, or `untyped`.
12
10
  class Signatures
13
11
  HEADER = <<~RBS
14
12
  # Generated by `carton rbs` from the app's imports. Do not edit.
@@ -16,11 +14,10 @@ module Carton
16
14
 
17
15
  def initialize(project)
18
16
  @project = project
19
- @names = namespace_names
20
17
  end
21
18
 
22
19
  def to_s
23
- [HEADER, kernel, *namespaces, *constants].compact.join("\n")
20
+ [HEADER, kernel, *constants].compact.join("\n")
24
21
  end
25
22
 
26
23
  private
@@ -33,10 +30,10 @@ module Carton
33
30
  overloads =
34
31
  %w[import import_relative].filter_map do |kind|
35
32
  types =
36
- edges_by_path(kind).filter_map do |path, units|
37
- next unless units.uniq.size == 1
33
+ edges_by_path(kind).filter_map do |path, cartons|
34
+ next unless cartons.uniq.size == 1
38
35
 
39
- type = type_of(units.first)
36
+ type = type_of(cartons.first)
40
37
  "(#{path.inspect} path) -> #{type}" unless type == 'untyped'
41
38
  end
42
39
  next if types.empty?
@@ -59,45 +56,12 @@ module Carton
59
56
  def edges_by_path(kind)
60
57
  @project
61
58
  .edges
62
- .select { |edge| edge.unit && edge.call.kind == kind }
59
+ .select { |edge| edge.carton && edge.call.kind == kind }
63
60
  .group_by { |edge| edge.call.path }
64
- .transform_values { |edges| edges.map(&:unit) }
61
+ .transform_values { |edges| edges.map(&:carton) }
65
62
  .sort
66
63
  end
67
64
 
68
- # Each Bare Carton as a module whose singleton methods and constants
69
- # mirror its `Carton::Namespace`.
70
- def namespaces
71
- @names
72
- .sort_by { |_, name| name }
73
- .map do |unit, name|
74
- members = surface(unit).map { |member| " #{member}" }
75
- [
76
- "# #{Project.relative(unit.entrypoint)}",
77
- "module Carton::Namespace::#{name}",
78
- ' extend ::Carton::NamespaceMethods',
79
- *members,
80
- 'end',
81
- '',
82
- ].join("\n")
83
- end
84
- end
85
-
86
- # A Bare Carton's surface is what its files define at top level.
87
- def surface(unit)
88
- files = unit.files.map { |path| file(path) }
89
- methods = files.flat_map { |f| f.methods.keys }.uniq
90
- constants = files.flat_map { |f| f.constants.keys + top_definitions(f) }
91
- [
92
- *methods.map do |name|
93
- "def self.#{name}: #{method_type(unit, name)}"
94
- end,
95
- *constants.uniq.map do |name|
96
- "#{name}: #{constant_type(unit, name)}"
97
- end,
98
- ]
99
- end
100
-
101
65
  # `CONST = import ...` in any file, unless the Carton's own RBS already
102
66
  # declares that name: `Billing = import 'billing'` for `export Billing`.
103
67
  # A constant bound to two different Cartons is skipped.
@@ -105,36 +69,31 @@ module Carton
105
69
  bindings =
106
70
  @project
107
71
  .edges
108
- .select { |edge| edge.unit && edge.call.constant }
72
+ .select { |edge| edge.carton && edge.call.constant }
109
73
  .group_by { |edge| edge.call.constant }
110
- .transform_values { |edges| edges.map(&:unit).uniq }
111
- .select { |_, units| units.size == 1 }
74
+ .transform_values { |edges| edges.map(&:carton).uniq }
75
+ .select { |_, cartons| cartons.size == 1 }
112
76
  lines =
113
- bindings.sort.filter_map do |constant, (unit)|
114
- type = type_of(unit)
77
+ bindings.sort.filter_map do |constant, (carton)|
78
+ type = type_of(carton)
115
79
  next if type == "singleton(::#{constant})"
116
80
 
117
- alias_of(constant, unit, type) || "#{constant}: #{type}"
81
+ alias_of(constant, carton, type) || "#{constant}: #{type}"
118
82
  end
119
83
  return if lines.empty?
120
84
 
121
85
  ['# Constants bound to imports', *lines, ''].join("\n")
122
86
  end
123
87
 
124
- def alias_of(constant, unit, type)
88
+ def alias_of(constant, carton, type)
125
89
  target = type[/\Asingleton\((.+)\)\z/, 1] or return
126
- return "module #{constant} = #{target}" if @names.key?(unit)
127
90
 
128
- kind = definition_kind(unit, target.delete_prefix('::'))
91
+ kind = definition_kind(carton, target.delete_prefix('::'))
129
92
  "#{kind} #{constant} = #{target}" if kind
130
93
  end
131
94
 
132
- def type_of(unit)
133
- if @names.key?(unit)
134
- return "singleton(::Carton::Namespace::#{@names[unit]})"
135
- end
136
-
137
- exports = file(unit.entrypoint).exports
95
+ def type_of(carton)
96
+ exports = file(carton.entrypoint).exports
138
97
  return 'untyped' unless exports.size == 1
139
98
 
140
99
  export = exports.first
@@ -145,76 +104,15 @@ module Carton
145
104
  "{ #{fields.join(', ')} }"
146
105
  end
147
106
 
148
- def exported?(unit) = file(unit.entrypoint).exports.any?
149
-
150
107
  def file(path) = @project.files.fetch(path)
151
108
 
152
- def method_type(unit, name)
153
- unit.files.each do |path|
154
- definition = file(path).methods[name]
155
- return definition.type if definition
156
- end
157
- '(?) -> untyped'
158
- end
159
-
160
- def constant_type(unit, name)
161
- unit.files.each do |path|
162
- type = file(path).constants[name]
163
- return type if type
164
- end
165
- kind = definition_kind(unit, name)
166
- kind ? "singleton(::#{name})" : 'untyped'
167
- end
168
-
169
- def definition_kind(unit, name)
170
- unit.files.each do |path|
109
+ def definition_kind(carton, name)
110
+ carton.files.each do |path|
171
111
  kind = file(path).definitions[name]
172
112
  return kind if kind
173
113
  end
174
114
  nil
175
115
  end
176
-
177
- def top_definitions(entry)
178
- entry.definitions.keys.reject { |name| name.include?('::') }
179
- end
180
-
181
- # A module name under `Carton::Namespace` for each Bare Carton: its
182
- # Entrypoint's name, camelized, with parent directories prepended while
183
- # two Cartons share a name.
184
- def namespace_names
185
- units =
186
- @project
187
- .edges
188
- .filter_map(&:unit)
189
- .uniq
190
- .reject { |unit| exported?(unit) }
191
- depth = units.to_h { |unit| [unit, 1] }
192
- loop do
193
- names = units.to_h { |unit| [unit, camelize(unit, depth[unit])] }
194
- clashes = names.keys.group_by { |unit| names[unit] }.values
195
- clashes = clashes.select { |group| group.size > 1 }.flatten
196
- if clashes.empty? || clashes.none? { |unit| deeper?(unit, depth) }
197
- return names
198
- end
199
-
200
- clashes.each { |unit| depth[unit] += 1 }
201
- end
202
- end
203
-
204
- def deeper?(unit, depth) = depth[unit] < path_parts(unit).size
205
-
206
- def camelize(unit, depth)
207
- words = path_parts(unit).last(depth).join('_').split(/[^A-Za-z0-9]+/)
208
- words.map { |word| word[0].upcase + word[1..] }.join
209
- end
210
-
211
- def path_parts(unit)
212
- unit
213
- .entrypoint
214
- .delete_suffix('.rb')
215
- .split('/')
216
- .reject { |part| part.empty? || part == 'lib' }
217
- end
218
116
  end
219
117
  end
220
118
  end
@@ -38,6 +38,10 @@ module Carton
38
38
  Reference =
39
39
  Data.define(:name, :local, :scope, :kind, :member, :line, :rescued)
40
40
 
41
+ # A class or module definition, with its name as written and the
42
+ # enclosing namespace, before entering its body.
43
+ Definition = Data.define(:name, :scope, :kind, :line)
44
+
41
45
  # A hash pattern matched directly against an import: its Reference
42
46
  # `kind` (:fetch for `=>`, :lookup for `in`) and the keys it reads, with
43
47
  # `'**'` for a rest pattern.
@@ -61,9 +65,6 @@ module Carton
61
65
  end
62
66
  end
63
67
 
64
- # A top-level method and its RBS method type.
65
- Def = Data.define(:name, :type, :line)
66
-
67
68
  IMPORTS = %i[import import_relative].freeze
68
69
  REQUIRES = %i[require require_relative].freeze
69
70
 
@@ -71,10 +72,11 @@ module Carton
71
72
  :imports,
72
73
  :requires,
73
74
  :exports,
74
- :methods,
75
+ :export_lines,
75
76
  :constants,
76
77
  :definitions,
77
78
  :references,
79
+ :definition_sites,
78
80
  :private_constants,
79
81
  :local_writes,
80
82
  :errors
@@ -86,12 +88,15 @@ module Carton
86
88
  @imports = []
87
89
  @requires = []
88
90
  @exports = []
89
- # Top-level methods by name, and RBS types of top-level constants.
90
- @methods = {}
91
+ # Every possible export call, including ones whose execution or type
92
+ # cannot be inferred, so missing-export checks stay conservative.
93
+ @export_lines = []
94
+ # RBS types of top-level constants.
91
95
  @constants = {}
92
96
  # Every class and module defined, by full name: :class or :module.
93
97
  @definitions = {}
94
98
  @references = []
99
+ @definition_sites = []
95
100
  # Full names made private with `private_constant`.
96
101
  @private_constants = []
97
102
  # How many times each local variable is assigned, so a local bound to
@@ -116,12 +121,6 @@ module Carton
116
121
  end
117
122
  end
118
123
 
119
- # The rbs-inline `#: TYPE` annotation on `line`, if any.
120
- def annotation(line)
121
- text = @annotations[line]
122
- text[2..].strip if text&.start_with?('#:')
123
- end
124
-
125
124
  # The RBS type of a literal value node, or `untyped`.
126
125
  def self.literal_type(node)
127
126
  case node
@@ -150,36 +149,6 @@ module Carton
150
149
  end
151
150
  end
152
151
 
153
- # An RBS method type with untyped parameters matching a `def`.
154
- def self.method_type(parameters)
155
- return '() -> untyped' unless parameters
156
-
157
- params = parameters.requireds.map { |node| positional(node) }
158
- parameters.optionals.each { |node| params << "?untyped #{node.name}" }
159
- if parameters.rest.is_a?(Prism::RestParameterNode)
160
- params << "*untyped #{parameters.rest.name}".strip
161
- end
162
- parameters.posts.each { |node| params << positional(node) }
163
- parameters.keywords.each do |node|
164
- optional = node.is_a?(Prism::OptionalKeywordParameterNode)
165
- params << "#{'?' if optional}#{node.name}: untyped"
166
- end
167
- block = parameters.block ? ' ?{ (?) -> untyped }' : ''
168
- case parameters.keyword_rest
169
- when Prism::KeywordRestParameterNode
170
- params << "**untyped #{parameters.keyword_rest.name}".strip
171
- when Prism::ForwardingParameterNode
172
- params.push('*untyped', '**untyped')
173
- block = ' ?{ (?) -> untyped }'
174
- end
175
- "(#{params.join(', ')})#{block} -> untyped"
176
- end
177
-
178
- def self.positional(node)
179
- node.respond_to?(:name) ? "untyped #{node.name}" : 'untyped'
180
- end
181
- private_class_method :positional
182
-
183
152
  # Walks the tree once, tracking the class and module nesting and whether
184
153
  # code runs at load time.
185
154
  class Visitor < Prism::Visitor
@@ -243,6 +212,10 @@ module Carton
243
212
  end
244
213
 
245
214
  def visit_call_node(node)
215
+ if node.name == :export &&
216
+ (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
217
+ @file.export_lines << node.location.start_line
218
+ end
246
219
  record_call(node) if node.receiver.nil?
247
220
  record_use(node) if bindable?(node.receiver)
248
221
  super
@@ -295,16 +268,7 @@ module Carton
295
268
  match(node.value, node.pattern, :lookup) { super }
296
269
  end
297
270
 
298
- def visit_def_node(node)
299
- if top_level? && node.receiver.nil?
300
- line = node.location.start_line
301
- type =
302
- @file.annotation(line - 1) ||
303
- SourceFile.method_type(node.parameters)
304
- @file.methods[node.name.to_s] = Def.new(node.name.to_s, type, line)
305
- end
306
- nest(:@defs) { super }
307
- end
271
+ def visit_def_node(node) = nest(:@defs) { super }
308
272
 
309
273
  def visit_block_node(node) = nest(:@blocks) { super }
310
274
 
@@ -360,9 +324,7 @@ module Carton
360
324
  def visit_constant_write_node(node)
361
325
  name = qualify(node.name.to_s)
362
326
  if top_level?
363
- @file.constants[name] = @file.annotation(
364
- node.location.start_line,
365
- ) || SourceFile.literal_type(node.value)
327
+ @file.constants[name] = SourceFile.literal_type(node.value)
366
328
  end
367
329
  binding(:@constant, name, node.value) { super }
368
330
  end
@@ -584,6 +546,13 @@ module Carton
584
546
  name = constant_name(node.constant_path)
585
547
  return nest(:@defs, 0, &) unless name
586
548
 
549
+ @file.definition_sites << Definition.new(
550
+ name:,
551
+ scope: @namespace.last,
552
+ kind:,
553
+ line: node.location.start_line,
554
+ )
555
+
587
556
  name = qualify(name)
588
557
  @file.definitions[name] ||= kind
589
558
  @namespace.push(name)
@@ -4,6 +4,6 @@ module Carton
4
4
  module Tools
5
5
  # Always the same as Carton::VERSION: carton-tools reads Carton's
6
6
  # internals, so each release depends on exactly its own carton.
7
- VERSION = '0.3.0'
7
+ VERSION = '0.4.0'
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carton-tools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cristofaro
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.3.0
18
+ version: 0.4.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.3.0
25
+ version: 0.4.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: prism
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -37,7 +37,7 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '1.8'
40
- description: The carton command for Carton, the package system Ruby has been missing.
40
+ description: The carton command for Carton apps with explicit imports and exports.
41
41
  carton check reports import and export problems and carton rbs writes RBS for what
42
42
  each import returns, reading code with Prism without running it. Requires Ruby 4.0.7+.
43
43
  email: