proscenium 0.26.1 → 0.27.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -3
  3. data/lib/proscenium/app_package.rb +8 -4
  4. data/lib/proscenium/builder/request.rb +72 -0
  5. data/lib/proscenium/builder.rb +10 -74
  6. data/lib/proscenium/cli/bun_linker.rb +2 -2
  7. data/lib/proscenium/cli/collisions.rb +7 -9
  8. data/lib/proscenium/cli/error.rb +58 -265
  9. data/lib/proscenium/cli/gem_archive.rb +4 -2
  10. data/lib/proscenium/cli/gem_check.rb +13 -11
  11. data/lib/proscenium/cli/gitignore.rb +2 -1
  12. data/lib/proscenium/cli/inspect.rb +12 -11
  13. data/lib/proscenium/cli/install.rb +35 -46
  14. data/lib/proscenium/cli/manager.rb +6 -6
  15. data/lib/proscenium/cli/platform_variants.rb +8 -8
  16. data/lib/proscenium/cli/pnpm_approvals.rb +3 -3
  17. data/lib/proscenium/cli/pnpm_workspace.rb +15 -14
  18. data/lib/proscenium/cli/project_lock.rb +3 -3
  19. data/lib/proscenium/cli/registration.rb +5 -5
  20. data/lib/proscenium/cli/reporter.rb +2 -2
  21. data/lib/proscenium/cli/verify.rb +9 -50
  22. data/lib/proscenium/cli.rb +5 -4
  23. data/lib/proscenium/contexts/map.rb +183 -0
  24. data/lib/proscenium/contexts/mapping_generation.rb +68 -0
  25. data/lib/proscenium/contexts/messages.rb +276 -0
  26. data/lib/proscenium/{cli/contexts.rb → contexts/plan.rb} +12 -12
  27. data/lib/proscenium/contexts/projection.rb +77 -0
  28. data/lib/proscenium/{cli → contexts}/rules.rb +1 -1
  29. data/lib/proscenium/contexts/stale.rb +174 -0
  30. data/lib/proscenium/contexts.rb +16 -0
  31. data/lib/proscenium/css_module/path.rb +1 -0
  32. data/lib/proscenium/css_module/transformer.rb +15 -0
  33. data/lib/proscenium/css_module.rb +3 -2
  34. data/lib/proscenium/helper.rb +6 -1
  35. data/lib/proscenium/importer.rb +13 -1
  36. data/lib/proscenium/middleware/base.rb +0 -4
  37. data/lib/proscenium/middleware.rb +0 -2
  38. data/lib/proscenium/railtie.rb +4 -4
  39. data/lib/proscenium/resolver.rb +3 -1
  40. data/lib/proscenium/side_load.rb +6 -2
  41. data/lib/proscenium/source_path.rb +2 -0
  42. data/lib/proscenium/utils.rb +2 -1
  43. data/lib/proscenium/version.rb +1 -1
  44. data/lib/proscenium.rb +5 -5
  45. metadata +10 -7
  46. data/lib/proscenium/context_map.rb +0 -181
  47. data/lib/proscenium/dependency_context.rb +0 -74
  48. data/lib/proscenium/mapping_generation.rb +0 -64
  49. data/lib/proscenium/stale_contexts.rb +0 -118
@@ -1,181 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'bundler'
4
- require 'json'
5
- require_relative 'app_package'
6
- require_relative 'bundled_gems'
7
- require_relative 'yaml_tree'
8
-
9
- module Proscenium
10
- # What the engine passes Go about gem dependency contexts (#154): which gems resolve their bare
11
- # imports from `.proscenium/packages/<gem>/`, and which of the app's own packages keep their link
12
- # paths. Built from committed files and Bundler only. Like BundledGems, it loads nothing the
13
- # `proscenium` CLI may not.
14
- module ContextMap
15
- CONTEXTS = File.join('.proscenium', 'packages')
16
- REGISTRATION = '.proscenium/packages/*'
17
-
18
- module_function
19
-
20
- # Where the `proscenium` CLI keeps contexts, its lock and its marker: the bundle's root, which
21
- # need not be Rails.root.
22
- def project_root = Bundler.root.to_s
23
-
24
- # The config keys Go reads, for the app at `root`. Read once per mapping generation: a value
25
- # read during an older one is kept under that one's key, never handed to the next.
26
- def config(root, generation = 0)
27
- @config ||= {}
28
- @config[[root, generation]] ||= { DependencyContexts: contexts(root),
29
- AppLocalPackages: AppPackage.local_packages(root) }.freeze
30
- end
31
-
32
- def reset! = @config = {}
33
-
34
- # Each registering file, with the lockfile and the name of the manager that reads it.
35
- REGISTRARS = { 'pnpm-workspace.yaml' => %w[pnpm-lock.yaml pnpm],
36
- 'package.json' => %w[bun.lock bun] }.freeze
37
-
38
- # Whether the app at `root` adopted gem dependency contexts: pnpm's workspace file registers
39
- # them, or package.json does in a Bun app. Each counts only where its manager installs: when
40
- # packageManager names no other manager, and with that manager's lockfile or no other
41
- # manager's, as an app missing its lockfile is an incomplete checkout, which must not silently
42
- # switch the contexts off.
43
- def adopted?(root) = !registrar(root).nil?
44
-
45
- # The file whose registration makes the app at `root` adopted, or nil.
46
- def registrar(root)
47
- named = AppPackage.package_manager(root)
48
- REGISTRARS.find do |file, (own, manager)|
49
- # bun.lockb is Bun's too, until bun.lock replaces it.
50
- others = [*UNSUPPORTED.keys, *REGISTRARS.values.map(&:first)] - [own] +
51
- (manager == 'bun' ? [] : ['bun.lockb'])
52
- registers?(root, file) && [nil, manager].include?(named) &&
53
- (File.exist?(File.join(root, own)) || others.none? { File.exist?(File.join(root, it)) })
54
- end&.first
55
- end
56
-
57
- # Whether `file` lists the contexts: pnpm-workspace.yaml's `packages`, or package.json's
58
- # `workspaces` (an array, or an object's `packages`). Parsed, so a commented-out line does not
59
- # count. For the engine a file that does not parse counts if it names them, so a parse failure
60
- # never silently switches off the context map and the stale check; `strict` (the CLI, which
61
- # must register a file its manager can read) counts only a file that parses.
62
- def registers?(root, file)
63
- path = File.join(root, file)
64
- File.exist?(path) && registered_in?(file, File.read(path))
65
- end
66
-
67
- def registered_in?(file, text, strict: false)
68
- text = utf8(text).delete_prefix(BOM)
69
- list = file.end_with?('.yaml') ? yaml_packages(text) : json_workspaces(text)
70
- list.is_a?(Array) && list.any? { registration?(it) } && !excludes?(list)
71
- rescue Psych::SyntaxError, JSON::ParserError
72
- return false if strict
73
-
74
- file.end_with?('.yaml') ? text.include?(REGISTRATION) : AppPackage.workspaces?(text)
75
- end
76
-
77
- # Whether the list has a `!` pattern that takes a context out: pnpm and Bun apply one whatever
78
- # its order, so the contexts would never be installed. Generous: `*` crosses `/`, and any
79
- # segment of the pattern may be a gem's directory.
80
- def excludes?(list)
81
- Array(list).grep(/\A!/).any? do |entry|
82
- pattern = entry[1..].delete_prefix('./').chomp('/')
83
- # A segment with a wildcard, class or brace, such as `[ah]ue`, may match any gem.
84
- wide = pattern.split('/').map { it.match?(/[*?\[{]/) ? '*' : it }.join('/')
85
- ['gem', *pattern.split('/')].product([pattern, wide]).any? do |name, glob|
86
- File.fnmatch?(glob, "#{CONTEXTS}/#{name}", GLOB)
87
- end
88
- end
89
- end
90
-
91
- GLOB = File::FNM_EXTGLOB | File::FNM_DOTMATCH
92
-
93
- def json_workspaces(text)
94
- parsed = JSON.parse(text)
95
- list = parsed['workspaces'] if parsed.is_a?(Hash)
96
- list.is_a?(Hash) ? list['packages'] : list
97
- end
98
-
99
- # pnpm-workspace.yaml's `packages`: each entry's scalar text, or nil for one that is not a
100
- # scalar; nil when the document is not a mapping or the key is missing or not a list. Read from
101
- # the parse tree and never built into Ruby objects, so no alias can make it slow and no tag or
102
- # date can raise. Raises Psych::SyntaxError.
103
- def yaml_packages(text)
104
- list, anchors = YamlTree.value(text, 'packages')
105
- return unless list.is_a?(Psych::Nodes::Sequence)
106
-
107
- list.children.map do |entry|
108
- entry = YamlTree.node(entry, anchors)
109
- entry.value if entry.is_a?(Psych::Nodes::Scalar)
110
- end
111
- end
112
-
113
- # pnpm and Bun read `./<pattern>` and `<pattern>/` as the pattern.
114
- def registration?(entry)
115
- entry.is_a?(String) && entry.delete_prefix('./').chomp('/') == REGISTRATION
116
- end
117
-
118
- BOM = "\uFEFF"
119
-
120
- # A JSON file as Node, pnpm and Bun read it, a leading byte order mark included.
121
- def read_json(path) = parse_json(File.read(path))
122
- def parse_json(text) = JSON.parse(utf8(text).delete_prefix(BOM))
123
-
124
- # Text as UTF-8, whatever encoding the caller's locale gave it.
125
- def utf8(text) = text.dup.force_encoding(Encoding::UTF_8)
126
-
127
- # Gem name => absolute context directory for each participating gem, when the app at `root`
128
- # adopted dependency contexts; empty before. A gem whose context is missing is mapped all the
129
- # same: its imports then fail by name rather than fall back to the app's packages.
130
- def contexts(root, specs = BundledGems.installed_specs)
131
- return {} unless adopted?(root)
132
-
133
- BundledGems.participating(specs, overrides: BundledGems.overrides(root)).keys.to_h do |gem|
134
- [gem, File.join(root, CONTEXTS, gem)]
135
- end
136
- rescue BundledGems::ConfigError
137
- {} # StaleContexts reports it, so every build is refused with the reason
138
- end
139
-
140
- INSTALLING = File.join('.proscenium', 'installing')
141
- LOCK = File.join('.proscenium', 'lock')
142
- INSTALLING_MESSAGE = '`bundle exec proscenium install` is running, or stopped before it ' \
143
- 'finished. Wait for it, or run it again.'
144
-
145
- # Whether an install is under way in the app at `root`, or stopped before it finished: its
146
- # marker is there, or another process holds the project lock. The engine refuses to build or
147
- # resolve meanwhile (C34). The probe takes a shared lock, so it never stops an install
148
- # starting; the CLI retries a lock it finds taken for a moment. About 16 µs.
149
- def installing?(root)
150
- return true if File.exist?(File.join(root, INSTALLING))
151
-
152
- lock = File.join(root, LOCK)
153
- File.exist?(lock) && File.open(lock, File::RDONLY) { !it.flock(File::LOCK_SH | File::LOCK_NB) }
154
- end
155
-
156
- # Each file that says the app uses an unsupported manager, as Manager.select reads them.
157
- UNSUPPORTED = { 'yarn.lock' => 'Yarn', '.yarnrc.yml' => 'Yarn', 'package-lock.json' => 'npm',
158
- 'npm-shrinkwrap.json' => 'npm' }.freeze
159
-
160
- # The one notice a process logs at boot when gems opt in but the app has not adopted dependency
161
- # contexts, or nil. Those gems' imports keep resolving as they always have, so upgrading
162
- # Proscenium never breaks an app that has not run `proscenium install` (C52).
163
- def adoption_notice(root, specs = BundledGems.installed_specs)
164
- return if adopted?(root)
165
-
166
- gems = BundledGems.participating(specs, overrides: BundledGems.overrides(root)).keys
167
- return if gems.empty?
168
-
169
- opted = "#{gems.join(', ')} opt in to installing their NPM dependencies " \
170
- 'through Proscenium'
171
- manager = UNSUPPORTED.find { |file, _| File.exist?(File.join(root, file)) }&.last
172
- if manager
173
- "#{opted}, but #{manager} is not supported, so the app keeps managing them."
174
- else
175
- "#{opted}. Run `bundle exec proscenium install` to install them."
176
- end
177
- rescue BundledGems::ConfigError => e
178
- e.message
179
- end
180
- end
181
- end
@@ -1,74 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'digest'
4
- require 'json'
5
-
6
- module Proscenium
7
- # A participating gem's dependency context (#154): its package.json through the
8
- # `dependency-context-v1` projection, which keeps installation metadata only and is committed at
9
- # `.proscenium/packages/<gem>/package.json`.
10
- #
11
- # The one owner of the projection and its hash, shared by the CLI and the engine, so it must not
12
- # load ActiveSupport or anything else the CLI may not.
13
- module DependencyContext
14
- PROJECTION = 'dependency-context-v1'
15
-
16
- # The manifest fields a context keeps, in the order it writes them. Everything else (scripts,
17
- # devDependencies, version, entry points, workspaces) is left in the gem's own package.json.
18
- FIELDS = %w[dependencies peerDependencies peerDependenciesMeta optionalDependencies engines os
19
- cpu libc].freeze
20
-
21
- module_function
22
-
23
- def name_for(gem) = "@rubygems/#{gem}"
24
-
25
- def description_for(gem)
26
- "Generated by Proscenium from the #{gem} gem. Do not edit; " \
27
- 'run bundle exec proscenium install.'
28
- end
29
-
30
- # The context for `gem` from its parsed `manifest`. An empty field is left out, as an absent
31
- # one is: the two install the same, so they hash the same.
32
- def project(gem, manifest)
33
- fields = FIELDS.each_with_object({}) do |key, kept|
34
- value = manifest[key]
35
- kept[key] = value unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
36
- end
37
-
38
- { 'name' => name_for(gem), 'private' => true, 'description' => description_for(gem),
39
- **fields,
40
- 'proscenium' => { 'projection' => PROJECTION, 'projectionSha256' => digest(gem, fields) } }
41
- end
42
-
43
- # The bytes committed for a context: fixed key order, two-space indent, a trailing newline, and
44
- # nothing that varies by host or time.
45
- def to_json(context) = "#{JSON.pretty_generate(context)}\n"
46
-
47
- # The projection hash. It hashes a fixed-field encoding of the projected fields, not JSON, so
48
- # it does not move with the json gem's output, key order, whitespace or escaping, and an edit
49
- # to anything the projection drops leaves it alone.
50
- def digest(gem, fields)
51
- Digest::SHA256.hexdigest("#{PROJECTION}\n#{encode(name_for(gem))}#{encode(fields)}")
52
- end
53
-
54
- # A self-delimiting encoding: a type tag, a length where there is one, then the value. Hash
55
- # keys are sorted by their bytes, so key order in the manifest does not matter; array order
56
- # does, as it does to npm.
57
- def encode(value)
58
- case value
59
- when String then "s#{value.bytesize}:#{value.b}"
60
- when true then 't'
61
- when false then 'f'
62
- when nil then 'n'
63
- when Integer then "i#{value};"
64
- when Float then "d#{value};"
65
- when Array then "a#{value.size}:#{value.map { encode(it) }.join}"
66
- when Hash
67
- pairs = value.sort_by { |key, _| key.to_s.b }
68
- "h#{value.size}:#{pairs.map { |key, val| encode(key.to_s) + encode(val) }.join}"
69
- else
70
- raise ArgumentError, "cannot encode #{value.class} in a dependency context"
71
- end
72
- end
73
- end
74
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'bundled_gems'
4
- require_relative 'context_map'
5
- require_relative 'stale_contexts'
6
-
7
- module Proscenium
8
- # Which mapping generation a build uses (#154). A generation is the context map and the
9
- # staleness verdict, read once and kept, so every build sees one consistent view. In production
10
- # a generation lasts the process: restart after a deploy. In development and test, `refresh`
11
- # looks at most once a second for a change to the files the map is built from, and starts a new
12
- # generation when one changed, such as `proscenium install` finishing.
13
- #
14
- # Gemfile.lock is watched, but a gem added or upgraded still needs a restart: Bundler loads the
15
- # bundle once per process.
16
- module MappingGeneration
17
- # The lockfiles too: with contexts adopted, resolved paths carry each package's version.
18
- WATCHED = ['Gemfile.lock', 'package.json', 'pnpm-workspace.yaml', '.npmrc',
19
- 'bunfig.toml', 'pnpm-lock.yaml', 'bun.lock', ContextMap::INSTALLING].freeze
20
- EVERY = 1.0
21
- LOCK = Mutex.new
22
-
23
- module_function
24
-
25
- # The current generation number, starting a new one first if a watched file changed and the
26
- # last look was at least `every` seconds ago. Returns [number, whether it just changed].
27
- def refresh(root, every: EVERY)
28
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
- LOCK.synchronize do
30
- @looked ||= {}
31
- @number ||= 0
32
- at, before = @looked[root]
33
- return [@number, false] if at && now - at < every
34
-
35
- after = signature(root)
36
- @looked[root] = [now, after]
37
- return [@number, false] if before.nil? || before == after
38
-
39
- ContextMap.reset!
40
- StaleContexts.reset!
41
- [@number += 1, true]
42
- end
43
- end
44
-
45
- # The modification time of every file the map is built from, by path, nil for a missing one.
46
- def signature(root)
47
- files = WATCHED.map { File.join(root, it) } +
48
- Dir.glob(File.join(root, ContextMap::CONTEXTS, '*', 'package.json')) +
49
- manifests(root)
50
- files.sort.to_h { [it, File.exist?(it) ? File.mtime(it).to_f : nil] }
51
- end
52
-
53
- # Each participating gem's package.json: editing a path gem's manifest makes its context stale.
54
- def manifests(root)
55
- BundledGems.participating(BundledGems.installed_specs, overrides: BundledGems.overrides(root))
56
- .values.filter_map { BundledGems.manifest_root(it) }
57
- .map { File.join(it, 'package.json') }
58
- rescue BundledGems::ConfigError
59
- []
60
- end
61
-
62
- def reset! = LOCK.synchronize { @looked = {} }
63
- end
64
- end
@@ -1,118 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require_relative 'bundled_gems'
5
- require_relative 'context_map'
6
- require_relative 'dependency_context'
7
- require_relative 'cli/contexts'
8
- require_relative 'cli/error'
9
- require_relative 'cli/verify'
10
-
11
- module Proscenium
12
- # Whether an adopted app's committed gem dependency contexts are what its bundle calls for
13
- # (#154). A stale context resolves a gem's imports against the wrong dependency graph, so the
14
- # engine refuses to build until `proscenium install` brings them up to date. Built from
15
- # committed files and Bundler only; it loads nothing the `proscenium` CLI may not.
16
- module StaleContexts
17
- COMMAND = 'Run `bundle exec proscenium install` and commit .proscenium/packages.'
18
- EDITED = 'its dependency context was edited by hand'
19
- ORPHAN = '%<gem>s: its dependency context is left over, as the gem is no longer in the ' \
20
- 'bundle or no longer opted in'
21
-
22
- module_function
23
-
24
- # One line for each way the committed contexts differ from the bundle; empty when current, or
25
- # before the app adopts. A context is stale when a participating gem has none, when its gem
26
- # left Gemfile.lock or stopped participating, when its projection hash is not the gem's
27
- # current one, or when that hash is not the one its own fields produce (a hand edit). A
28
- # locked gem that is not installed (a group BUNDLE_WITHOUT excludes) keeps its context as
29
- # committed.
30
- def problems(root, specs = BundledGems.installed_specs, locked = BundledGems.locked_names)
31
- registrar = ContextMap.registrar(root)
32
- return [] unless registrar
33
-
34
- # Only the registration in use: a Bun app does not read a pnpm-workspace.yaml left over.
35
- unparsable = unparsable_workspace(root) if registrar == 'pnpm-workspace.yaml'
36
- return [unparsable] if unparsable
37
-
38
- participating = BundledGems.participating(specs, overrides: BundledGems.overrides(root))
39
- contexts = CLI::Contexts.new(root, participating)
40
- committed = CLI::Contexts.committed(root)
41
- found = contexts.problems.map { |code, args| CLI::Error.new(code, **args).message }
42
- contexts.contexts.each_value do |context|
43
- problem = compare(context, committed[context.gem])
44
- found << "#{context.gem}: #{problem}" if problem
45
- end
46
- excluded = (locked || []) - specs.map(&:name)
47
- orphans = committed.keys - participating.keys - excluded
48
- found + orphans.map { format(ORPHAN, gem: it) } +
49
- split_peers(root, contexts.contexts.keys & committed.keys)
50
- rescue BundledGems::ConfigError => e
51
- [e.message]
52
- end
53
-
54
- # An app counts as adopted when a pnpm-workspace.yaml it cannot parse names the contexts, so
55
- # the engine never silently switches them off; it refuses until the file parses. Not
56
- # package.json: Bun reads JSON that Ruby refuses, such as a trailing comma.
57
- def unparsable_workspace(root)
58
- path = File.join(root, 'pnpm-workspace.yaml')
59
- return unless File.exist?(path)
60
-
61
- ContextMap.yaml_packages(ContextMap.utf8(File.read(path)).delete_prefix(ContextMap::BOM))
62
- nil
63
- rescue Psych::SyntaxError => e
64
- "pnpm-workspace.yaml: it is not valid YAML (#{e.problem} at line #{e.line})"
65
- end
66
-
67
- # A peer meant to be shared that the gem and the app reach as two copies, as an app-only
68
- # `pnpm update react` can leave it: install checks this too, and the engine repeats it for each
69
- # generation.
70
- def split_peers(root, gems)
71
- pnpm = ContextMap.registers?(root, 'pnpm-workspace.yaml')
72
- CLI::Verify.new(root, Struct.new(:name).new(pnpm ? 'pnpm' : 'bun'), gems).check_peers
73
- []
74
- rescue CLI::Error => e
75
- [e.message]
76
- end
77
-
78
- # The committed context is projected again from the fields it holds, not taken on trust: a
79
- # hand edit that kept the reported hash would build against a graph no install produced.
80
- def compare(context, path)
81
- return "its dependency context hasn't been written yet" unless path && File.exist?(path)
82
-
83
- committed = ContextMap.read_json(path)
84
- unless committed.is_a?(Hash) && committed['proscenium'].is_a?(Hash)
85
- return 'its dependency context is not valid JSON'
86
- end
87
- if committed['proscenium']['projection'] != DependencyContext::PROJECTION
88
- return 'its dependency context was written by a different version of Proscenium'
89
- end
90
-
91
- # Parsed, so reformatting is not an edit; whole, so its name and the other generated fields
92
- # count as well as its hash.
93
- edited = DependencyContext.project(context.gem, committed) != committed
94
- return EDITED if edited
95
- return if committed == JSON.parse(context.json)
96
-
97
- 'its NPM dependencies changed since its dependency context was written'
98
- rescue JSON::ParserError
99
- 'its dependency context is not valid JSON'
100
- end
101
-
102
- # The build error for the app at `root`, or nil when its contexts are current. Read once per
103
- # mapping generation, as ContextMap.config is.
104
- def message(root, generation = 0)
105
- @message ||= {}
106
- key = [root, generation]
107
- return @message[key] if @message.key?(key)
108
-
109
- found = problems(root)
110
- @message[key] = if found.any?
111
- "Gem dependency contexts are out of date:\n" \
112
- "#{found.map { " - #{it}" }.join("\n")}\n#{COMMAND}"
113
- end
114
- end
115
-
116
- def reset! = @message = {}
117
- end
118
- end