proscenium 0.25.3 → 0.26.1

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
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rubygems/package'
5
+ require 'zlib'
6
+ require_relative 'gem_archive'
7
+ require_relative 'rules'
8
+ require_relative '../bundled_gems'
9
+ require_relative '../context_map'
10
+
11
+ module Proscenium
12
+ module CLI
13
+ # `proscenium gem check [path]`: the gem author's check, which needs no bundle. Checks a gem
14
+ # source directory, or a built `.gem`, against the author contract: opted in, the manifest and
15
+ # frontend files in `spec.files`, and the manifest rules. A built `.gem` is only read; a source
16
+ # directory's gemspec is Ruby, which loading it runs, so check an untrusted gem as a `.gem`.
17
+ class GemCheck
18
+ FRONTEND = /\.(?:js|jsx|mjs|ts|tsx|css)\z/
19
+ # Not shipped on purpose: tests, dependencies, and tool configuration at the root, such as
20
+ # eslint.config.js.
21
+ UNSHIPPED = Regexp.union(%r{(?:\A|/)(?:node_modules|test|spec)/}, /\.(?:test|spec)\.[a-z]+\z/,
22
+ %r{\A[^/]+\.config\.[a-z]+\z})
23
+
24
+ def initialize(path, reporter)
25
+ @path = File.expand_path(path || Dir.pwd)
26
+ @reporter = reporter
27
+ end
28
+
29
+ def call
30
+ spec, files, manifest_text, cause = @path.end_with?('.gem') ? read_archive : read_source
31
+ problems = check(spec, files, manifest_text, cause)
32
+ problems.each { |code, args| @reporter.error(Error.new(code, **args), phase: 'gem-check') }
33
+ if problems.empty?
34
+ message = "#{spec.name} #{spec.version} passes every check: Proscenium can install " \
35
+ 'its NPM dependencies.'
36
+ @reporter.info(@reporter.paint(message, :bold, :green),
37
+ event: 'gem-check', gem: spec.name)
38
+ return 0
39
+ end
40
+
41
+ Error::EXIT.fetch(:input)
42
+ end
43
+
44
+ private
45
+
46
+ # `cause` is why the manifest could not be read safely, if it could not (C29).
47
+ def check(spec, files, manifest_text, cause = nil)
48
+ gem = spec.name
49
+ unless spec.metadata['proscenium.dependencies'] == 'true'
50
+ return [['PSM-E-NOT-OPTED-IN', { gem: }]]
51
+ end
52
+
53
+ root = frontend_root(spec)
54
+ return [['PSM-E-FRONTEND-ROOT', { gem:, root: }]] unless root
55
+
56
+ manifest_path = root.empty? ? 'package.json' : File.join(root, 'package.json')
57
+ return [['PSM-E-MANIFEST', { gem:, path: manifest_path, cause: }]] if cause
58
+ unless manifest_text
59
+ return [['PSM-E-MANIFEST',
60
+ { gem:, path: manifest_path, cause: 'it is missing' }]]
61
+ end
62
+
63
+ manifest = parse(manifest_text)
64
+ if manifest.is_a?(String)
65
+ return [['PSM-E-MANIFEST',
66
+ { gem:, path: manifest_path, cause: manifest }]]
67
+ end
68
+
69
+ gyp = root.empty? ? 'binding.gyp' : File.join(root, 'binding.gyp')
70
+ problems = Rules.check(gem, manifest, root: source_root(root), path: manifest_path,
71
+ binding_gyp: files.include?(gyp))
72
+ # A field of the wrong type stops here: the checks below read it as an object.
73
+ return problems if problems.any? { it.first == 'PSM-E-MANIFEST' }
74
+
75
+ unless files.include?(manifest_path)
76
+ problems << ['PSM-E-GEM-FILES',
77
+ { gem:, files: manifest_path }]
78
+ end
79
+ missing = unlisted_frontend(files, root)
80
+ problems << ['PSM-E-GEM-FILES', { gem:, files: summarize(missing) }] if missing.any?
81
+ problems + cross_gem(spec, manifest)
82
+ end
83
+
84
+ def frontend_root(spec) = BundledGems.frontend_root(spec)
85
+
86
+ # A reference to another gem's context needs that gem as a runtime dependency.
87
+ def cross_gem(spec, manifest)
88
+ runtime = spec.runtime_dependencies.map(&:name)
89
+ Rules::DEPENDENCY_FIELDS.flat_map { (manifest[it] || {}).keys }
90
+ .grep(%r{\A@rubygems/})
91
+ .map { it.delete_prefix('@rubygems/') }
92
+ .reject { runtime.include?(it) }
93
+ .map { ['PSM-E-CROSS-GEM', { gem: spec.name, other: it }] }
94
+ end
95
+
96
+ def parse(text)
97
+ manifest = ContextMap.parse_json(text)
98
+ manifest.is_a?(Hash) ? manifest : 'it is not a JSON object'
99
+ rescue JSON::ParserError => e
100
+ "it is not valid JSON (#{e.message.lines.first.strip})"
101
+ end
102
+
103
+ def read_source
104
+ gemspecs = Dir.glob('*.gemspec', base: @path)
105
+ raise Error.new('PSM-E-GEMSPEC', path: @path, found: gemspecs.size) unless gemspecs.one?
106
+
107
+ # Loaded by absolute path: Specification.load caches by the path it is given, so a relative
108
+ # one would return another directory's gem. Inside the directory, because gemspecs often
109
+ # list their files relative to it.
110
+ spec = Dir.chdir(@path) { Gem::Specification.load(File.join(@path, gemspecs.first)) }
111
+ raise Error.new('PSM-E-GEMSPEC', path: @path, found: 0) unless spec
112
+
113
+ root = frontend_root(spec) || ''
114
+ manifest = File.join(@path, root, 'package.json')
115
+ @source = @path
116
+ return [spec, spec.files, nil, nil] unless File.exist?(manifest)
117
+
118
+ cause = Rules.manifest_file_problem(manifest, @path)
119
+ [spec, spec.files, cause ? nil : File.read(manifest), cause]
120
+ end
121
+
122
+ # A built gem, read from its archive (GemArchive).
123
+ def read_archive
124
+ spec, manifest, cause = GemArchive.read(@path)
125
+ [spec, spec.files, manifest, cause]
126
+ end
127
+
128
+ def source_root(root) = @source && File.join(@source, root)
129
+
130
+ # Frontend files on disk under the frontend root but missing from `spec.files`, in the gem's
131
+ # root or a top-level directory it ships something from: a directory it ships nothing from,
132
+ # such as a demo app beside the gem's code, is left out on purpose. A built gem has nothing on
133
+ # disk to compare.
134
+ def unlisted_frontend(files, root)
135
+ return [] unless @source
136
+
137
+ shipped = files.filter_map { it.split('/', 2).first if it.include?('/') }
138
+ Dir.glob('**/*', base: File.join(@source, root)).grep(FRONTEND)
139
+ .map { root.empty? ? it : File.join(root, it) }
140
+ .reject { files.include?(it) || UNSHIPPED.match?(it) }
141
+ .select { !it.include?('/') || shipped.include?(it.split('/', 2).first) }
142
+ end
143
+
144
+ def summarize(paths)
145
+ shown = paths.first(3).join(', ')
146
+ paths.size > 3 ? "#{shown} and #{paths.size - 3} more" : shown
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ module CLI
5
+ # The `.gitignore` lines for Proscenium's own state (#154): everything under .proscenium/
6
+ # ignored but the generated contexts, which are committed.
7
+ module Gitignore
8
+ # Each level down to a context's package.json is re-included: Git never looks inside a
9
+ # directory an earlier rule (`.proscenium/`, `.*`) ignores, and `.proscenium/**` ignores
10
+ # every level below it.
11
+ IGNORES = ['!.proscenium/', '.proscenium/*', '!.proscenium/packages/',
12
+ '!.proscenium/packages/*/', '!.proscenium/packages/*/package.json',
13
+ '.proscenium/packages/*/node_modules/'].freeze
14
+
15
+ module_function
16
+
17
+ # Appends the lines Proscenium owns, and a node_modules rule if the app has none (Bun moves
18
+ # an old tree to node_modules/.old_modules-<hash> when its linker changes).
19
+ def splice(text)
20
+ lines = text.lines(chomp: true)
21
+ # Only the lines together, in order, and with no rule after them that could hide the
22
+ # contexts are enough; otherwise all of them again. Git applies the last matching rule.
23
+ size = IGNORES.size
24
+ at = (0..(lines.size - size)).reverse_each.find { lines[it, size] == IGNORES }
25
+ hidden = at.nil? || lines[(at + size)..].any? { hides_contexts?(it) }
26
+ wanted = hidden ? IGNORES.dup : []
27
+ wanted.unshift('node_modules/') unless lines.any? { it.match?(%r{\A/?node_modules/?\z}) }
28
+ return text if wanted.empty?
29
+
30
+ nl = text.include?("\r\n") ? "\r\n" : "\n"
31
+ separator = text.empty? || text.end_with?("\n") ? '' : nl
32
+ "#{text}#{separator}#{nl unless text.empty?}# Proscenium#{nl}#{wanted.join(nl)}#{nl}"
33
+ end
34
+
35
+ FLAGS = [File::FNM_DOTMATCH, File::FNM_DOTMATCH | File::FNM_PATHNAME].freeze
36
+ COMMITTED = %w[.proscenium .proscenium/packages].freeze
37
+
38
+ # Whether an ignore rule could match a path the contexts are committed under. Generous: `*`
39
+ # crosses `/` here, so at worst the lines are added once more than needed.
40
+ def hides_contexts?(line)
41
+ pattern = line.strip.delete_prefix('/').chomp('/')
42
+ return false if pattern.empty? || pattern.start_with?('#', '!')
43
+
44
+ # Git's `**/` also matches no directory at all. Ruby's does too with FNM_PATHNAME, inside a
45
+ # pattern, but not at its start, so that prefix is also dropped; without FNM_PATHNAME, a
46
+ # trailing `**` (and `*`) crosses `/` as Git's trailing `/**` does.
47
+ # Any segment of the rule may name a gem's directory, such as `.proscenium/packages/hue/**`,
48
+ # and one with a wildcard, class or brace, such as `[ah]ue`, may match any of them.
49
+ names = ['gem', *pattern.split('/')]
50
+ wide = pattern.split('/').map { it.match?(/[*?\[{]/) ? '*' : it }.join('/')
51
+ paths = COMMITTED + names.flat_map do |name|
52
+ ["#{COMMITTED.last}/#{name}", "#{COMMITTED.last}/#{name}/package.json"]
53
+ end
54
+ [pattern, wide].flat_map { [it, it.delete_prefix('**/')] }.uniq.any? do |glob|
55
+ paths.any? do |path|
56
+ FLAGS.any? { File.fnmatch?(glob, path, it) } ||
57
+ File.fnmatch?(glob, File.basename(path), File::FNM_DOTMATCH)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'bundler'
5
+ require_relative '../bundled_gems'
6
+ require_relative 'contexts'
7
+ require_relative 'gitignore'
8
+ require_relative 'manager'
9
+ require_relative 'registration'
10
+ require_relative 'verify'
11
+ require_relative '../stale_contexts'
12
+
13
+ module Proscenium
14
+ module CLI
15
+ # `proscenium inspect [gem]` (#154): read-only. What each participating gem contributes, where
16
+ # it is locked from, whether its committed context is current, and which gems ship a
17
+ # package.json without taking part. With --json, one document.
18
+ class Inspect
19
+ CREDENTIALS = %r{(://)[^/@\s]+@}
20
+ # A query parameter whose name says it holds a secret, which Bundler keeps in a git source.
21
+ SECRET_PARAMS = /([?&][^=&#\s]*(?:token|key|auth|pass|secret|sig)[^=&#\s]*=)[^&#\s]+/i
22
+
23
+ # Each context status, as a person reads it.
24
+ STATUS = {
25
+ 'current' => ['up to date', :green],
26
+ 'stale' => ['out of date: run `bundle exec proscenium install`', :yellow],
27
+ 'edited' => ['edited by hand: run `bundle exec proscenium install` to restore it', :yellow],
28
+ 'missing' => ['not written yet: run `bundle exec proscenium install`', :yellow],
29
+ 'invalid' => ["can't be installed: see the problems below", :red]
30
+ }.freeze
31
+
32
+ def initialize(root, reporter, gem: nil)
33
+ @root = root
34
+ @reporter = reporter
35
+ @only = gem
36
+ end
37
+
38
+ def call
39
+ report = build
40
+ if @reporter.json?
41
+ @reporter.document(report)
42
+ else
43
+ @reporter.result(text(report), event: 'inspect')
44
+ end
45
+ report['problems'].empty? && report['buildRefused'].empty? ? 0 : Error::EXIT.fetch(:drift)
46
+ end
47
+
48
+ private
49
+
50
+ def build
51
+ overrides = BundledGems.overrides(@root)
52
+ specs = BundledGems.installed_specs
53
+ participating = BundledGems.participating(specs, overrides:)
54
+ if @only && !participating.key?(@only)
55
+ raise Error.new('PSM-E-USAGE', detail: "#{@only} doesn't install NPM " \
56
+ 'dependencies through Proscenium')
57
+ end
58
+
59
+ contexts = Contexts.new(@root, participating)
60
+ committed = Contexts.committed(@root)
61
+ gems = participating.keys.select { @only.nil? || it == @only }.map do |name|
62
+ gem_report(name, participating[name], contexts, committed)
63
+ end
64
+ manager, problems = select_manager
65
+ # An app that uses none of this has nothing to set up, whatever its manager.
66
+ if participating.any? || committed.any? || ContextMap.registrar(@root)
67
+ problems += project_problems(manager, contexts, committed)
68
+ else
69
+ problems = []
70
+ end
71
+ problems += contexts.problems.map { |code, args| Error.new(code, **args) }
72
+ { 'schema' => 1, 'manager' => manager_report(manager), 'gems' => gems,
73
+ 'notParticipating' => BundledGems.unparticipating_with_manifest(specs, overrides:),
74
+ # Every reason the engine refuses to build: orphans and split peers as well.
75
+ 'buildRefused' => StaleContexts.problems(@root, specs),
76
+ 'problems' => problems.map { problem_report(it) } }
77
+ rescue BundledGems::ConfigError => e
78
+ raise Error.new('PSM-E-CONFIG', detail: e.message)
79
+ end
80
+
81
+ def gem_report(name, spec, contexts, committed)
82
+ context = contexts.contexts[name]
83
+ current = context && JSON.parse(context.json)
84
+ path = committed[name]
85
+ on_disk = path && File.exist?(path) ? File.read(path) : nil
86
+ {
87
+ 'gem' => name, 'version' => spec.version.to_s, 'source' => redact(source(name)),
88
+ 'context' => ".proscenium/packages/#{name}",
89
+ 'status' => status(context, on_disk, path),
90
+ 'projectionSha256' => current&.dig('proscenium', 'projectionSha256'),
91
+ 'kept' => current ? (current.keys & DependencyContext::FIELDS) : [],
92
+ 'gitAndUrl' => (context&.git_and_url || []).map { redact(it) },
93
+ 'optionalPeers' => optional_peers(current)
94
+ }
95
+ end
96
+
97
+ # `stale` exactly when the engine refuses to build: the projection hash differs. Different
98
+ # bytes with the same hash are a hand edit, which only `install --frozen` refuses.
99
+ def status(context, on_disk, path)
100
+ return 'invalid' unless context
101
+ return 'missing' unless on_disk
102
+ return 'current' if on_disk == context.json
103
+
104
+ reason = StaleContexts.compare(context, path)
105
+ reason.nil? || reason == StaleContexts::EDITED ? 'edited' : 'stale'
106
+ end
107
+
108
+ # Where Gemfile.lock takes the gem from.
109
+ def source(name)
110
+ locked = Bundler.locked_gems&.specs&.find { it.name == name }
111
+ return 'not locked' unless locked
112
+
113
+ locked.source.to_s
114
+ end
115
+
116
+ # Optional peers that are other gems, beside the version Bundler locked: Bundler checks a
117
+ # gemspec dependency's version, but nothing checks an optional peer's range.
118
+ def optional_peers(context)
119
+ return [] unless context
120
+
121
+ (context['peerDependenciesMeta'] || {}).filter_map do |name, meta|
122
+ next unless meta.is_a?(Hash) && meta['optional'] && name.start_with?('@rubygems/')
123
+
124
+ gem = name.delete_prefix('@rubygems/')
125
+ locked = Bundler.locked_gems&.specs&.find { it.name == gem }
126
+ { 'peer' => name, 'range' => context.dig('peerDependencies', name),
127
+ 'locked' => locked&.version&.to_s }
128
+ end
129
+ end
130
+
131
+ # The manager, and what stops install using it, as install would check it: [manager or nil,
132
+ # problems].
133
+ def select_manager
134
+ manager = Manager.select(@root)
135
+ manager.check_version!
136
+ manager.check_project!(frozen: Registration.registered?(@root, manager.name))
137
+ [manager, []]
138
+ rescue Error => e
139
+ [manager, [e]]
140
+ end
141
+
142
+ # What stops the app's gem dependencies working once installed: the registration, the
143
+ # .gitignore lines, the lockfile and the install itself. Before the first install, only that
144
+ # it has not happened.
145
+ def project_problems(manager, contexts, committed)
146
+ return gitignore_problems unless manager
147
+
148
+ file = manager.name == 'bun' ? 'package.json' : 'pnpm-workspace.yaml'
149
+ registered = Registration.registered?(@root, manager.name)
150
+ unregistered = Error.new('PSM-E-NOT-REGISTERED', file:, manager: manager.name)
151
+ return [unregistered] if !registered && committed.empty?
152
+
153
+ found = gitignore_problems
154
+ found << unregistered unless registered
155
+ lockfile = Manager::LOCKFILES.key(manager.name)
156
+ lock = File.join(@root, lockfile)
157
+ return found << Error.new('PSM-E-LOCKFILE-MISSING', lockfile:) unless File.exist?(lock)
158
+ unless File.directory?(File.join(@root, 'node_modules'))
159
+ return found << Error.new('PSM-E-NOT-INSTALLED')
160
+ end
161
+
162
+ found + (registered ? installed_problems(manager, contexts, File.read(lock)) : [])
163
+ end
164
+
165
+ def gitignore_problems
166
+ path = File.join(@root, '.gitignore')
167
+ text = File.exist?(path) ? File.read(path) : ''
168
+ Gitignore.splice(text) == text ? [] : [Error.new('PSM-E-GITIGNORE')]
169
+ end
170
+
171
+ # A gem context the lock takes from a registry, or one the manager did not install. A split
172
+ # peer is in buildRefused, as the engine refuses to build for it.
173
+ def installed_problems(manager, contexts, lock)
174
+ verify = Verify.new(@root, manager, contexts.contexts.keys)
175
+ %i[check_registry check_workspaces].filter_map do |check|
176
+ verify.public_send(check, lock)
177
+ nil
178
+ rescue Error => e
179
+ e
180
+ end
181
+ end
182
+
183
+ def problem_report(error)
184
+ { 'code' => error.code, 'message' => error.message,
185
+ 'fix' => error.fix }
186
+ end
187
+
188
+ def manager_report(manager)
189
+ return nil unless manager
190
+
191
+ linker = manager.name == 'bun' ? manager.bun_linker : pnpm_linker
192
+ { 'name' => manager.name, 'linker' => linker }
193
+ end
194
+
195
+ # pnpm's `node-linker` from .npmrc, `isolated` by default.
196
+ def pnpm_linker
197
+ npmrc = File.join(@root, '.npmrc')
198
+ setting = File.exist?(npmrc) && File.read(npmrc)[/^\s*node-linker\s*=\s*(\S+)/, 1]
199
+ setting || 'isolated'
200
+ end
201
+
202
+ def redact(text)
203
+ text.to_s.gsub(CREDENTIALS, '\1<redacted>@').gsub(SECRET_PARAMS, '\1<redacted>')
204
+ end
205
+
206
+ def text(report)
207
+ lines = []
208
+ if (manager = report['manager'])
209
+ linker = manager['linker'] ? "#{manager['linker']} linker" : 'linker not set'
210
+ lines << "#{bold('Package manager:')} #{manager['name']} (#{linker})" << ''
211
+ end
212
+ lines.concat(gem_lines(report['gems']))
213
+ if report['notParticipating'].any?
214
+ lines << '' << bold('These gems ship a package.json but have not opted in:')
215
+ lines.concat(report['notParticipating'].map { " #{it}" })
216
+ lines << ' To opt one in, add `"proscenium": {"gemOverrides": {"<gem>": ' \
217
+ '{"participate": true}}}` to package.json.'
218
+ end
219
+ report['problems'].each do |problem|
220
+ lines << '' << "#{@reporter.paint('Problem:', :bold, :red)} #{problem['message']}"
221
+ lines << "#{bold('To fix:')} #{problem['fix']}"
222
+ end
223
+ if report['buildRefused'].any?
224
+ lines << '' << @reporter.paint('Rails will refuse to build assets until you fix:', :bold,
225
+ :red)
226
+ lines.concat(report['buildRefused'].map { " - #{it}" })
227
+ end
228
+ if report['problems'].empty? && report['buildRefused'].empty?
229
+ lines << '' << @reporter.paint('No problems found.', :bold, :green)
230
+ end
231
+ lines.join("\n")
232
+ end
233
+
234
+ def gem_lines(gems)
235
+ return ['No gems install NPM dependencies through Proscenium.'] if gems.empty?
236
+
237
+ lines = [bold("Gems whose NPM dependencies Proscenium installs (#{gems.size}):")]
238
+ gems.each do |gem|
239
+ status, colour = STATUS.fetch(gem['status'])
240
+ lines << " #{gem['gem']} #{gem['version']}: #{@reporter.paint(status, colour)}"
241
+ lines << " from: #{gem['source']}"
242
+ lines << " uses: #{gem['kept'].join(', ')}" if gem['kept'].any?
243
+ if gem['gitAndUrl'].any?
244
+ lines << " Git and URL dependencies: #{gem['gitAndUrl'].join(', ')}"
245
+ end
246
+ gem['optionalPeers'].each do |peer|
247
+ locked = peer['locked'] ? "#{peer['locked']} is locked" : 'not locked'
248
+ lines << " optional peer #{peer['peer']} #{peer['range']} (#{locked})"
249
+ end
250
+ end
251
+ lines
252
+ end
253
+
254
+ def bold(text) = @reporter.paint(text, :bold)
255
+ end
256
+ end
257
+ end