proscenium 0.25.3 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +170 -23
  3. data/exe/proscenium +19 -0
  4. data/exe/psm +5 -0
  5. data/lib/proscenium/app_package.rb +90 -0
  6. data/lib/proscenium/builder.rb +221 -20
  7. data/lib/proscenium/bundled_gems.rb +103 -0
  8. data/lib/proscenium/cli/brackets.rb +66 -0
  9. data/lib/proscenium/cli/bun_linker.rb +79 -0
  10. data/lib/proscenium/cli/capabilities.json +29 -0
  11. data/lib/proscenium/cli/collisions.rb +94 -0
  12. data/lib/proscenium/cli/contexts.rb +133 -0
  13. data/lib/proscenium/cli/diff.rb +80 -0
  14. data/lib/proscenium/cli/error.rb +291 -0
  15. data/lib/proscenium/cli/gem_archive.rb +44 -0
  16. data/lib/proscenium/cli/gem_check.rb +150 -0
  17. data/lib/proscenium/cli/gitignore.rb +63 -0
  18. data/lib/proscenium/cli/inspect.rb +257 -0
  19. data/lib/proscenium/cli/install.rb +378 -0
  20. data/lib/proscenium/cli/manager.rb +323 -0
  21. data/lib/proscenium/cli/platform_variants.rb +82 -0
  22. data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
  23. data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
  24. data/lib/proscenium/cli/project_lock.rb +106 -0
  25. data/lib/proscenium/cli/registration.rb +140 -0
  26. data/lib/proscenium/cli/reporter.rb +103 -0
  27. data/lib/proscenium/cli/rules.rb +169 -0
  28. data/lib/proscenium/cli/runner.rb +142 -0
  29. data/lib/proscenium/cli/verify.rb +107 -0
  30. data/lib/proscenium/cli.rb +122 -0
  31. data/lib/proscenium/context_map.rb +181 -0
  32. data/lib/proscenium/css_module/transformer.rb +18 -3
  33. data/lib/proscenium/css_module.rb +5 -6
  34. data/lib/proscenium/dependency_context.rb +74 -0
  35. data/lib/proscenium/error.rb +10 -0
  36. data/lib/proscenium/helper.rb +38 -4
  37. data/lib/proscenium/importer.rb +53 -42
  38. data/lib/proscenium/manifest.rb +18 -23
  39. data/lib/proscenium/mapping_generation.rb +64 -0
  40. data/lib/proscenium/middleware/base.rb +5 -10
  41. data/lib/proscenium/middleware/chunks.rb +5 -0
  42. data/lib/proscenium/monkey.rb +51 -69
  43. data/lib/proscenium/railtie.rb +16 -0
  44. data/lib/proscenium/railties/assets.rake +2 -1
  45. data/lib/proscenium/resolver.rb +43 -11
  46. data/lib/proscenium/runtime/bootstrap.js +26 -6
  47. data/lib/proscenium/runtime/server.rb +57 -36
  48. data/lib/proscenium/side_load.rb +75 -18
  49. data/lib/proscenium/stale_contexts.rb +118 -0
  50. data/lib/proscenium/templates/rescues/build_error.html.erb +9 -7
  51. data/lib/proscenium/utils.rb +55 -0
  52. data/lib/proscenium/version.rb +1 -1
  53. data/lib/proscenium/yaml_tree.rb +48 -0
  54. data/lib/proscenium.rb +7 -4
  55. metadata +42 -13
  56. data/lib/proscenium/ext/proscenium +0 -0
  57. data/lib/proscenium/ext/proscenium.h +0 -113
  58. data/lib/proscenium/react-manager/index.jsx +0 -102
  59. data/lib/proscenium/react-manager/react.js +0 -2
  60. data/lib/proscenium/react_componentable.rb +0 -94
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'ffi'
4
+ require 'pathname'
5
+ require_relative 'error'
4
6
 
5
7
  module Proscenium
6
8
  class Builder
@@ -23,16 +25,81 @@ module Proscenium
23
25
  :messages, :pointer
24
26
  end
25
27
 
28
+ # The compiled Go library. It ships in the platform gems only, gated in the gemspec, so a
29
+ # host that matches no platform gem installs the platform-less gem and finds nothing here.
30
+ #
31
+ # Named explicitly rather than left to the loader: LoadLibrary appends `.dll` to a bare module
32
+ # name, but the documentation does not promise it for a full path, and being wrong here means
33
+ # every call fails at load with an error about a missing file rather than anything useful.
34
+ LIBRARY_NAME = Gem.win_platform? ? 'proscenium.dll' : 'proscenium'
35
+ LIBRARY_PATH = Pathname.new(__dir__).join('ext', LIBRARY_NAME)
36
+
37
+ # Raised in place of letting `ffi_lib` fail on its own. FFI reports a missing library as
38
+ # `Could not open library <path>` followed by the system search paths, which reads like a
39
+ # broken install and says nothing about platforms - so the one thing the reader needs to know,
40
+ # that Proscenium ships no binary for their platform, is the one thing it leaves out.
41
+ class UnsupportedPlatform < Error
42
+ def initialize(path)
43
+ super(<<~MESSAGE)
44
+ Proscenium has no compiled library for this platform (#{Gem::Platform.local}).
45
+
46
+ Proscenium is a Ruby gem wrapped around a Go library, so it ships a separate gem per
47
+ platform. Yours matched none of them, so you have the platform-less gem, which carries
48
+ no library at all. Nothing is corrupted - this platform is simply not built for.
49
+
50
+ Expected the library at: #{path}
51
+
52
+ The supported platforms are listed in the README. If yours should be among them, please
53
+ open an issue at https://github.com/joelmoss/proscenium/issues.
54
+ MESSAGE
55
+ end
56
+ end
57
+
26
58
  module Request
27
59
  extend FFI::Library
28
60
 
29
- ffi_lib Pathname.new(__dir__).join('ext/proscenium').to_s
61
+ raise UnsupportedPlatform, LIBRARY_PATH unless LIBRARY_PATH.exist?
62
+
63
+ ffi_lib LIBRARY_PATH.to_s
64
+
65
+ # Windows unmaps the library at interpreter exit, and the Go runtime does not survive it.
66
+ #
67
+ # ffi's library_free calls dl_close on every platform but macOS, which is FreeLibrary here,
68
+ # and Ruby runs every T_DATA free during VM teardown. Go's c-shared runtime cannot be
69
+ # unloaded: its threads are still running when the mapping goes. On Linux this never bites,
70
+ # because the Go linker marks the library nodelete and dlclose is a no-op; Windows has no
71
+ # equivalent. Measured on windows-latest: three of five plain `require` runs exited 139,
72
+ # while five of five exited 0 when the script ended in `exit!`, which skips the teardown.
73
+ #
74
+ # Pinning the module means the reference count never reaches zero, so FreeLibrary returns
75
+ # without unmapping anything. Nothing here needs the handle, only the pin.
76
+ if Gem.win_platform?
77
+ module Kernel32
78
+ extend FFI::Library
79
+
80
+ ffi_lib 'kernel32'
81
+ attach_function :GetModuleHandleExW, %i[uint32 buffer_in pointer], :int
82
+ end
83
+
84
+ # GET_MODULE_HANDLE_EX_FLAG_PIN. Backslashes, because GetModuleHandle documents that a full
85
+ # path must use them, and LIBRARY_PATH is built from __dir__, which uses "/". Checked,
86
+ # because a pin that quietly failed would bring back an exit crash that only happens some
87
+ # of the time, with nothing to say why.
88
+ name = "#{LIBRARY_PATH.to_s.tr('/', '\\')}\0".encode(Encoding::UTF_16LE)
89
+ if Kernel32.GetModuleHandleExW(0x1, name, FFI::MemoryPointer.new(:pointer)).zero?
90
+ warn "Proscenium could not pin #{LIBRARY_PATH} (Windows error " \
91
+ "#{FFI::LastError.winapi_error}), so Ruby may crash when it exits."
92
+ end
93
+ end
30
94
 
31
95
  enum :environment, [:development, 1, :test, :production]
32
96
 
33
97
  # `blocking: true` releases the GVL for the duration of the call, so a build/resolve
34
98
  # doesn't stall unrelated Ruby threads (eg. other requests in a multi-threaded server).
35
- # Safe to do because the Go side serialises these calls itself with a mutex - see main.go.
99
+ # Safe to do because the Go side shares nothing between calls: each call parses its own
100
+ # config, and the caches that outlive a call (i18n, svg, npm replacements) carry their own
101
+ # locks. See the note in main.go. There is no serialising mutex any more; a new piece of
102
+ # global state on the Go side needs its own.
36
103
 
37
104
  attach_function :build_to_string, [
38
105
  :string, # Path or entry point.
@@ -56,17 +123,54 @@ module Proscenium
56
123
  class BuildError < Error
57
124
  attr_reader :error, :path
58
125
 
126
+ # One esbuild message as text: its text, detail and location on one line, then each note
127
+ # with its own location on a line of its own. A note is where a recovered Go panic carries
128
+ # its stack, and where a failure inside a nested build names the file that imported it -
129
+ # without this they never left the JSON.
130
+ def self.format_message(message)
131
+ msg = message['Text'].to_s.dup
132
+ msg << ' - ' << message['Detail'] if message['Detail'].is_a?(String)
133
+ msg << format_location(message['Location'])
134
+ Array(message['Notes']).each do |note|
135
+ msg << "\n" << note['Text'].to_s << format_location(note['Location'])
136
+ end
137
+
138
+ msg
139
+ end
140
+
141
+ def self.format_location(location)
142
+ location ? " at #{location['File']}:#{location['Line']}:#{location['Column']}" : ''
143
+ end
144
+
145
+ # Go always answers with a JSON object. Anything else - a nil pointer, a bare scalar, text -
146
+ # is a contract violation, and is shown as the message rather than replaced by a parse error
147
+ # that hides it.
148
+ def self.parse_json(json)
149
+ parsed = JSON.parse(json)
150
+ parsed if parsed.is_a?(Hash)
151
+ rescue JSON::ParserError, TypeError
152
+ nil
153
+ end
154
+
59
155
  def initialize(path, error)
60
156
  @path = path
61
- @error = JSON.parse(error, strict: true)
157
+ @error = self.class.parse_json(error) || { 'Text' => error.to_s }
62
158
 
63
- msg = @error['Text']
64
- msg << ' - ' << @error['Detail'] if @error['Detail'].is_a?(String)
65
- if (location = @error['Location'])
66
- msg << " at #{location['File']}:#{location['Line']}:#{location['Column']}"
67
- end
159
+ super("Failed to build #{path} - #{self.class.format_message(@error)}")
160
+ end
161
+ end
162
+
163
+ # Raised by `compile` with every error esbuild reported. It used to return `false` and throw
164
+ # the messages away, so a failed precompile gave no reason at all.
165
+ class CompileError < Error
166
+ attr_reader :messages
68
167
 
69
- super("Failed to build #{path} - #{msg}")
168
+ def initialize(messages)
169
+ @messages = BuildError.parse_json(messages) || { 'Errors' => [{ 'Text' => messages.to_s }] }
170
+
171
+ errors = Array(@messages['Errors']).map { |message| BuildError.format_message(message) }
172
+
173
+ super("Failed to compile assets - #{errors.join("\n")}")
70
174
  end
71
175
  end
72
176
 
@@ -91,7 +195,51 @@ module Proscenium
91
195
  new(root:, **overrides).compile
92
196
  end
93
197
 
94
- # Intended for tests only.
198
+ # Why no build may run now, or nil (#154): an install is under way or stopped before it
199
+ # finished, or the committed gem dependency contexts are stale. With
200
+ # PROSCENIUM_STALE_CONTEXT=warn, for an incident, stale contexts are logged once instead.
201
+ def self.refusal(root = Rails.root)
202
+ project = ContextMap.project_root
203
+ return ContextMap::INSTALLING_MESSAGE if ContextMap.installing?(project)
204
+
205
+ outside = outside_rails_root(project, root) and return outside
206
+
207
+ stale = StaleContexts.message(project, generation) or return
208
+ return stale unless ENV['PROSCENIUM_STALE_CONTEXT'] == 'warn'
209
+
210
+ @warned ||= Set.new
211
+ Rails.logger.warn("[Proscenium] #{stale}") if @warned.add?(stale)
212
+ nil
213
+ end
214
+
215
+ # Why an adopted app's contexts cannot be served from `root`, the root builds run in (the Rails
216
+ # app's), or nil (#154). They live at the bundle's root, beside the Gemfile; one outside `root`
217
+ # has no URL the engine serves, so such a layout is refused plainly, not left to fail
218
+ # mid-page. Only compares paths unless they differ.
219
+ def self.outside_rails_root(project, root = Rails.root)
220
+ return if within?(project, root) || !ContextMap.adopted?(project)
221
+
222
+ "Gem dependency contexts are under #{project}, outside the Rails app at #{root}, " \
223
+ 'which Proscenium cannot serve yet. Keep the Gemfile in the Rails root to use them.'
224
+ end
225
+
226
+ # Whether `path` is `root` or under it, as spelled or, through a link such as a Capistrano
227
+ # `current`, as resolved.
228
+ def self.within?(path, root)
229
+ under?(path, root) || under?(File.realpath(path), File.realpath(root))
230
+ rescue SystemCallError
231
+ false
232
+ end
233
+
234
+ def self.under?(path, root) = "#{path}/".start_with?("#{root}/")
235
+ private_class_method :within?, :under?
236
+
237
+ # The mapping generation builds use now (#154). See Resolver.generation.
238
+ def self.generation = Resolver.generation
239
+
240
+ # Resets nothing any more - Go keeps no config between calls. Kept because it is the one call
241
+ # into Go that needs no Rails app, which is how bin/verify-installed-gem and the packaging test
242
+ # prove the library loads, and benchmarks/bridge.rb times it as the bare cost of an FFI call.
95
243
  def self.reset_config!
96
244
  Request.reset_config
97
245
  end
@@ -102,8 +250,25 @@ module Proscenium
102
250
  # - than the app's own configuration. Keys must match `types.ConfigT`; Go silently ignores
103
251
  # any it does not know.
104
252
  def initialize(root: nil, **overrides)
253
+ @root = root
254
+ @overrides = overrides
255
+ @generation = self.class.generation
256
+ @request_config = configure(@generation)
257
+ end
258
+
259
+ # The config pointer for the current mapping generation: a Builder kept past an install, or
260
+ # made just before one finished, sends Go the context map builds now use, not the one it was
261
+ # made with (#154).
262
+ def request_config
263
+ generation = self.class.generation
264
+ @request_config = configure(@generation = generation) if generation != @generation
265
+ @request_config
266
+ end
267
+ private :request_config
268
+
269
+ def configure(generation)
105
270
  config_hash = {
106
- RootPath: (root || Rails.root).to_s,
271
+ RootPath: (@root || Rails.root).to_s,
107
272
  OutputDir: "public#{Proscenium.config.output_dir}",
108
273
  GemPath: gem_root,
109
274
  Environment: ENVIRONMENTS.fetch(Rails.env.to_sym, 2),
@@ -114,11 +279,13 @@ module Proscenium
114
279
  Aliases: Proscenium.config.aliases,
115
280
  External: Proscenium.config.external,
116
281
  Precompile: Proscenium.config.precompile,
117
- Debug: Proscenium.config.debug
118
- }.merge(overrides)
282
+ Debug: Proscenium.config.debug,
283
+ **ContextMap.config(ContextMap.project_root, generation)
284
+ }.merge(@overrides)
119
285
 
120
- @request_config = self.class.request_config_pointer(config_hash)
286
+ self.class.request_config_pointer(config_hash)
121
287
  end
288
+ private :configure
122
289
 
123
290
  class << self
124
291
  # Building the config JSON and copying it into an FFI::MemoryPointer is the only real cost
@@ -146,8 +313,12 @@ module Proscenium
146
313
  end
147
314
 
148
315
  def build_to_string(path)
316
+ if (reason = self.class.refusal(@root || Rails.root))
317
+ raise BuildError.new(path, reason)
318
+ end
319
+
149
320
  ActiveSupport::Notifications.instrument('build.proscenium', identifier: path) do
150
- raw = Request.build_to_string(path, @request_config)
321
+ raw = Request.build_to_string(path, request_config)
151
322
  result = { success: raw[:success], response: read_and_free(raw[:response]),
152
323
  content_hash: read_and_free(raw[:content_hash]) }
153
324
 
@@ -158,8 +329,12 @@ module Proscenium
158
329
  end
159
330
 
160
331
  def resolve(path)
332
+ if (reason = self.class.refusal(@root || Rails.root))
333
+ raise ResolveError.new(path, reason)
334
+ end
335
+
161
336
  ActiveSupport::Notifications.instrument('resolve.proscenium', identifier: path) do
162
- raw = Request.resolve(path, @request_config)
337
+ raw = Request.resolve(path, request_config)
163
338
  success = raw[:success]
164
339
  url_path = read_and_free(raw[:url_path])
165
340
  abs_path = read_and_free(raw[:abs_path])
@@ -170,9 +345,17 @@ module Proscenium
170
345
  end
171
346
  end
172
347
 
348
+ # Returns true, or raises CompileError with esbuild's messages.
173
349
  def compile
174
- raw = Request.compile(@request_config)
175
- read_and_free(raw[:messages])
350
+ if (reason = self.class.refusal(@root || Rails.root))
351
+ raise CompileError, reason
352
+ end
353
+
354
+ raw = Request.compile(request_config)
355
+ messages = read_and_free(raw[:messages])
356
+
357
+ raise CompileError, messages unless raw[:success]
358
+
176
359
  raw[:success]
177
360
  end
178
361
 
@@ -180,19 +363,37 @@ module Proscenium
180
363
 
181
364
  # The Go side allocates each of these strings with C.CString, which the Go runtime cannot
182
365
  # see or collect - it must be freed from this side once we're done reading it.
366
+ #
367
+ # `read_string` always returns ASCII-8BIT, but everything Go sends is UTF-8: paths, esbuild's
368
+ # output and its messages. Left binary, a non-ASCII character is several "characters" to
369
+ # anything working on the string, and JSON.generate warns (a later json will raise).
183
370
  def read_and_free(ptr)
184
371
  return nil if ptr.null?
185
372
 
186
- ptr.read_string
373
+ ptr.read_string.force_encoding(Encoding::UTF_8)
187
374
  ensure
188
375
  Request.free_cstr(ptr)
189
376
  end
190
377
 
191
378
  # Build the ENV variables as determined by `Proscenium.config.env_vars` and
192
379
  # `Proscenium::DEFAULT_ENV_VARS` to pass to esbuild.
380
+ #
381
+ # Each name becomes the esbuild define `proscenium.env.<NAME>`, so one that is not a JS
382
+ # identifier would fail every build, or with a dot define a nested path. Checked whether or not
383
+ # the variable is set, so a bad name fails the first build everywhere, not only where it is set.
384
+ #
385
+ # Only the ASCII rules are checked, on the raw bytes: Ruby's Unicode tables differ from
386
+ # esbuild's by Ruby version, so non-ASCII characters are left for esbuild to judge, and the
387
+ # bytes cannot raise an encoding error. An empty name passes, and `ENV.slice` drops it.
193
388
  def env_vars
389
+ names = Array(Proscenium.config.env_vars).map(&:to_s)
390
+ if (invalid = names.select { |name| name.b.match?(/\A\d|[^\w$\x80-\xff]/n) }).any?
391
+ raise ArgumentError, 'config.proscenium.env_vars must be JavaScript identifiers, but got ' \
392
+ "#{invalid.map(&:inspect).join(', ')}"
393
+ end
394
+
194
395
  ENV['NODE_ENV'] = ENV.fetch('RAILS_ENV', nil)
195
- ENV.slice(*Proscenium.config.env_vars + Proscenium::DEFAULT_ENV_VARS)
396
+ ENV.slice(*Proscenium::DEFAULT_ENV_VARS, *names)
196
397
  end
197
398
 
198
399
  def gem_root
@@ -1,7 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require_relative 'error'
5
+
3
6
  module Proscenium
7
+ # The one reader of the bundle, for the engine and the `proscenium` CLI alike, so the two never
8
+ # disagree about which gems exist or which take part in JavaScript dependency installation
9
+ # (#154). It must not load ActiveSupport or anything else the CLI may not.
4
10
  module BundledGems
11
+ # The app's package.json, or its `proscenium` key, is unreadable or not in the documented
12
+ # shape.
13
+ class ConfigError < Proscenium::Error; end
14
+
5
15
  module_function
6
16
 
7
17
  def paths
@@ -22,6 +32,99 @@ module Proscenium
22
32
  end
23
33
  end
24
34
 
35
+ # The `@rubygems/` form of an absolute path inside a bundled gem, or nil when it is in none.
36
+ # A plain prefix match: a gem path is text, not a pattern, and may hold `+` or `(`.
37
+ #
38
+ # The longest root wins, which is Go's rule in `GemFromFsPath`, so a file under a gem nested in
39
+ # another gem's tree gets one URL from both sides. `max_by` keeps the first of equal roots, and
40
+ # `paths` is sorted by name, so a tie goes to the first name, as it does in Go.
41
+ def virtual_path(abs_path)
42
+ name, root = paths.select { |_, v| abs_path.start_with? "#{v}/" }.max_by { |_, v| v.length }
43
+ name && "@rubygems/#{name}#{abs_path.delete_prefix(root)}"
44
+ end
45
+
46
+ # Every installed gem but Bundler, sorted by name: the specs of the groups Bundler loaded.
47
+ def installed_specs = Bundler.load.specs.reject { it.name == 'bundler' }.sort_by(&:name)
48
+
49
+ # The names in Gemfile.lock, installed or not, or nil without a lockfile. A gem in a group
50
+ # BUNDLE_WITHOUT excludes, or locked for another platform, is here but not installed.
51
+ def locked_names
52
+ locked = Bundler.locked_gems
53
+ locked && locked.specs.map(&:name).uniq.sort
54
+ end
55
+
56
+ # Locked gems that are not installed: their committed contexts are trusted as they are (the
57
+ # production rule).
58
+ def excluded_names(specs = installed_specs)
59
+ (locked_names || []) - specs.map(&:name)
60
+ end
61
+
62
+ # `proscenium.gemOverrides` from the app's package.json: gem name => true or false, or {}
63
+ # without one.
64
+ def overrides(root)
65
+ path = File.join(root, 'package.json')
66
+ return {} unless File.exist?(path)
67
+
68
+ package = JSON.parse(File.read(path, encoding: 'bom|utf-8'))
69
+ raise ConfigError, 'package.json does not hold a JSON object' unless package.is_a?(Hash)
70
+
71
+ config = package['proscenium'] || {}
72
+ raise ConfigError, '"proscenium" in package.json must be an object' unless config.is_a?(Hash)
73
+
74
+ overrides = config['gemOverrides'] || {}
75
+ unless overrides.is_a?(Hash)
76
+ raise ConfigError, '"proscenium.gemOverrides" in package.json must be an object'
77
+ end
78
+
79
+ overrides.to_h do |gem, settings|
80
+ participate = settings.is_a?(Hash) ? settings['participate'] : nil
81
+ unless [true, false].include?(participate)
82
+ raise ConfigError,
83
+ "\"proscenium.gemOverrides.#{gem}.participate\" in package.json must be true " \
84
+ 'or false'
85
+ end
86
+
87
+ [gem, participate]
88
+ end
89
+ rescue JSON::ParserError => e
90
+ raise ConfigError, "package.json is not valid JSON (#{e.message.lines.first.strip})"
91
+ end
92
+
93
+ # Whether the gem's author opted it in, with gemspec metadata.
94
+ def opted_in?(spec) = spec.metadata['proscenium.dependencies'] == 'true'
95
+
96
+ # The gem's `proscenium.frontend_root`, relative to the gem: '' for its root, nil if it names a
97
+ # place outside the gem.
98
+ def frontend_root(spec)
99
+ relative = spec.metadata['proscenium.frontend_root'].to_s.delete_prefix('./').chomp('/')
100
+ return nil if relative.start_with?('/') || relative.split('/').include?('..')
101
+
102
+ relative
103
+ end
104
+
105
+ # The installed directory holding the gem's package.json, or nil if its frontend root is
106
+ # outside the gem.
107
+ def manifest_root(spec)
108
+ relative = frontend_root(spec)
109
+ return nil unless relative
110
+
111
+ relative.empty? ? spec.full_gem_path : File.join(spec.full_gem_path, relative)
112
+ end
113
+
114
+ # The participating installed specs, by name: opted in by their author or the app, and not
115
+ # opted out by the app.
116
+ def participating(specs = installed_specs, overrides: {})
117
+ specs.select { |spec| overrides.fetch(spec.name) { opted_in?(spec) } }.to_h { [it.name, it] }
118
+ end
119
+
120
+ # Installed gems that ship a root package.json but do not participate, for `inspect`.
121
+ def unparticipating_with_manifest(specs = installed_specs, overrides: {})
122
+ chosen = participating(specs, overrides:)
123
+ specs.reject { chosen.key?(it.name) }
124
+ .select { File.exist?(File.join(it.full_gem_path, 'package.json')) }
125
+ .map(&:name)
126
+ end
127
+
25
128
  def pathname_for(name)
26
129
  (path = paths[name]) ? Pathname(path) : nil
27
130
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ module CLI
5
+ # Quote-aware bracket scanning for the textual splices of pnpm-workspace.yaml flow lists and
6
+ # package.json (#154): a bracket or brace inside a quoted string is text, not structure.
7
+ module Brackets
8
+ module_function
9
+
10
+ # The index of the `]` closing the flow list or JSON array whose `[` ends at `start`, or nil:
11
+ # a bracket inside a quoted string, as a glob such as `packages/[ab]` has, or in a nested
12
+ # list, does not close it.
13
+ def array_end(text, start)
14
+ depth = 0
15
+ structure(text, start) do |char, index|
16
+ if char == '['
17
+ depth += 1
18
+ elsif char == ']'
19
+ return index if depth.zero?
20
+
21
+ depth -= 1
22
+ end
23
+ end
24
+ nil
25
+ end
26
+
27
+ # Where the first match of `key` from `from` at `depth` (1 for a JSON document's own keys)
28
+ # ends, or nil: another object's key of the same name is not it.
29
+ def key_end(text, key, depth, from = 0)
30
+ text.to_enum(:scan, key).map { Regexp.last_match }
31
+ .find { it.begin(0) >= from && depth(text, it.begin(0)) == depth }&.end(0)
32
+ end
33
+
34
+ # How deep `index` sits in `text`'s brackets and braces, outside quoted strings.
35
+ def depth(text, index)
36
+ nesting = { '[' => 1, '{' => 1, ']' => -1, '}' => -1 }
37
+ structure(text).take_while { |_, at| at < index }.sum { |char, _| nesting.fetch(char, 0) }
38
+ end
39
+
40
+ # Each character of `text` from `from` that is outside a quoted string or a YAML comment, with
41
+ # its index.
42
+ def structure(text, from = 0)
43
+ return enum_for(__method__, text, from) unless block_given?
44
+
45
+ quote = nil
46
+ index = from
47
+ while index < text.length
48
+ char = text[index]
49
+ if quote
50
+ index += 1 if char == '\\' && quote == '"'
51
+ quote = nil if char == quote
52
+ elsif ['"', "'"].include?(char)
53
+ quote = char
54
+ elsif char == '#' && (index.zero? || text[index - 1].match?(/[\s\[{,]/))
55
+ # A YAML comment, to the end of its line, after a space or a flow delimiter, as Psych
56
+ # reads it. JSON has no `#` outside a string.
57
+ index = (text.index("\n", index) || text.length) - 1
58
+ else
59
+ yield char, index
60
+ end
61
+ index += 1
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../context_map'
5
+ require_relative 'manager'
6
+
7
+ module Proscenium
8
+ module CLI
9
+ # The Bun linker an app uses today, written into bunfig.toml when the app sets none (#154,
10
+ # C48). Registering contexts adds `workspaces`, and with no linker set that alone can switch
11
+ # Bun from a hoisted node_modules to its isolated store (Stage A), so the first install pins
12
+ # the layout the app already has. Shown in the registration diff before it is written.
13
+ module BunLinker
14
+ # An `[install]` header, its name bare or quoted, and any comment after it.
15
+ HEADER = /^\[[ \t]*#{Manager::INSTALL}[ \t]*\][ \t]*(?:#[^\r\n]*)?(?=\r?$)/
16
+
17
+ module_function
18
+
19
+ # Bun's names for each layout: `node-linker`'s, and `install-strategy`'s, which node-linker
20
+ # overrides (probed on Bun 1.4.2).
21
+ NODE_LINKERS = { 'isolated' => 'isolated', 'pnpm' => 'isolated', 'hoisted' => 'hoisted',
22
+ 'npm' => 'hoisted' }.freeze
23
+ STRATEGIES = { 'linked' => 'isolated', 'hoisted' => 'hoisted', 'nested' => 'hoisted' }.freeze
24
+ NPMRC_LINKER = /^[ \t]*(node-linker|install-strategy)[ \t]*=[ \t]*["']?([^\s"']+)/
25
+
26
+ # The linker the project's .npmrc chooses. Otherwise `isolated` if Bun's isolated store is
27
+ # there, `hoisted` for any other node_modules. With nothing installed yet: what Bun would
28
+ # pick for the app as it stands, which is isolated only for an app that already declares
29
+ # workspaces under a lock of configVersion 1 or later.
30
+ def current(root)
31
+ chosen = configured(root)
32
+ return chosen if chosen
33
+
34
+ modules = File.join(root, 'node_modules')
35
+ return 'isolated' if File.directory?(File.join(modules, '.bun'))
36
+ return 'hoisted' if File.directory?(modules)
37
+
38
+ workspaces?(root) && config_version(root) >= 1 ? 'isolated' : 'hoisted'
39
+ end
40
+
41
+ # bunfig.toml's text with `linker` set in its `[install]` table, adding the table if need be.
42
+ def splice(text, linker)
43
+ line = "linker = \"#{linker}\""
44
+ eol = text.include?("\r\n") ? "\r\n" : "\n"
45
+ if (header = text.match(HEADER))
46
+ text.dup.insert(header.end(0), "#{eol}#{line}")
47
+ else
48
+ separator = text.empty? || text.end_with?("\n") ? '' : eol
49
+ "#{text}#{separator}#{eol unless text.empty?}[install]#{eol}#{line}#{eol}"
50
+ end
51
+ end
52
+
53
+ # The linker the project's own .npmrc sets. Bun also reads the developer's ~/.npmrc,
54
+ # $XDG_CONFIG_HOME/.npmrc and global .bunfig.toml, but this is committed for the whole team,
55
+ # so only the project decides it.
56
+ def configured(root) = npmrc_linker(File.join(root, '.npmrc'))
57
+
58
+ # The linker an .npmrc sets, by node-linker or else install-strategy, or nil.
59
+ def npmrc_linker(path)
60
+ return unless File.exist?(path)
61
+
62
+ settings = File.read(path).scan(NPMRC_LINKER).to_h
63
+ NODE_LINKERS[settings['node-linker']] || STRATEGIES[settings['install-strategy']]
64
+ end
65
+
66
+ def workspaces?(root)
67
+ path = File.join(root, 'package.json')
68
+ File.exist?(path) && ContextMap.read_json(path).key?('workspaces')
69
+ rescue JSON::ParserError
70
+ false
71
+ end
72
+
73
+ def config_version(root)
74
+ path = File.join(root, 'bun.lock')
75
+ File.exist?(path) ? File.read(path)[/"configVersion":\s*(\d+)/, 1].to_i : 0
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,29 @@
1
+ {
2
+ "schema": 1,
3
+ "comment": "The pnpm and Bun lines Proscenium supports (#154). The source of truth: the CLI checks manager versions against it, and CI pins the versions it lists. `ci` is the exact version CI runs; `eol` is when the line's owner stops maintaining it. pnpm 10 is not supported (decided 4 October 2026): it leaves a context on an older copy of a shared peer after the app upgrades it, and 11 and 12 do not. pnpm 11 starts at 11.11.0: earlier 11.x let a package spoofing an approved name in its manifest run its build scripts (GHSA-5wx6-mg75-v57r, fixed in 11.5.3) and a tarball's manifest name write outside node_modules (GHSA-vq4v-j7r6-jq4m, fixed in 11.11.0).",
4
+ "managers": {
5
+ "pnpm": {
6
+ "lines": [
7
+ {
8
+ "range": ">= 11.11.0, < 12",
9
+ "ci": "11.28.4",
10
+ "eol": "2027-04-30"
11
+ },
12
+ {
13
+ "range": ">= 12.0.0, < 13",
14
+ "ci": "12.9.1",
15
+ "eol": null
16
+ }
17
+ ]
18
+ },
19
+ "bun": {
20
+ "lines": [
21
+ {
22
+ "range": ">= 1.4.0, < 1.5",
23
+ "ci": "1.4.2",
24
+ "eol": null
25
+ }
26
+ ]
27
+ }
28
+ }
29
+ }