proscenium 0.26.0-x86_64-linux-gnu → 0.27.0-x86_64-linux-gnu

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 (50) 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/ext/proscenium +0 -0
  35. data/lib/proscenium/helper.rb +6 -1
  36. data/lib/proscenium/importer.rb +13 -1
  37. data/lib/proscenium/middleware/base.rb +0 -4
  38. data/lib/proscenium/middleware.rb +0 -2
  39. data/lib/proscenium/railtie.rb +4 -4
  40. data/lib/proscenium/resolver.rb +3 -1
  41. data/lib/proscenium/side_load.rb +6 -2
  42. data/lib/proscenium/source_path.rb +2 -0
  43. data/lib/proscenium/utils.rb +2 -1
  44. data/lib/proscenium/version.rb +1 -1
  45. data/lib/proscenium.rb +5 -5
  46. metadata +10 -7
  47. data/lib/proscenium/context_map.rb +0 -181
  48. data/lib/proscenium/dependency_context.rb +0 -74
  49. data/lib/proscenium/mapping_generation.rb +0 -64
  50. data/lib/proscenium/stale_contexts.rb +0 -118
@@ -0,0 +1,183 @@
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
+ module Contexts
11
+ # What the engine passes Go about gem dependency contexts (#154): which gems resolve their
12
+ # bare imports from `.proscenium/packages/<gem>/`, and which of the app's own packages keep
13
+ # their link paths. Built from committed files and Bundler only. Like BundledGems, it loads
14
+ # nothing the `proscenium` CLI may not.
15
+ module Map
16
+ CONTEXTS = File.join('.proscenium', 'packages')
17
+ REGISTRATION = '.proscenium/packages/*'
18
+
19
+ module_function
20
+
21
+ # Where the `proscenium` CLI keeps contexts, its lock and its marker: the bundle's root, which
22
+ # need not be Rails.root.
23
+ def project_root = Bundler.root.to_s
24
+
25
+ # The config keys Go reads, for the app at `root`. Read once per mapping generation: a value
26
+ # read during an older one is kept under that one's key, never handed to the next.
27
+ def config(root, generation = 0)
28
+ @config ||= {}
29
+ @config[[root, generation]] ||= { DependencyContexts: contexts(root),
30
+ AppLocalPackages: AppPackage.local_packages(root) }.freeze
31
+ end
32
+
33
+ def reset! = @config = {}
34
+
35
+ # Each registering file, with the lockfile and the name of the manager that reads it.
36
+ REGISTRARS = { 'pnpm-workspace.yaml' => %w[pnpm-lock.yaml pnpm],
37
+ 'package.json' => %w[bun.lock bun] }.freeze
38
+
39
+ # Whether the app at `root` adopted gem dependency contexts: pnpm's workspace file registers
40
+ # them, or package.json does in a Bun app. Each counts only where its manager installs: when
41
+ # packageManager names no other manager, and with that manager's lockfile or no other
42
+ # manager's, as an app missing its lockfile is an incomplete checkout, which must not silently
43
+ # switch the contexts off.
44
+ def adopted?(root) = !registrar(root).nil?
45
+
46
+ # The file whose registration makes the app at `root` adopted, or nil.
47
+ def registrar(root)
48
+ named = AppPackage.package_manager(root)
49
+ REGISTRARS.find do |file, (own, manager)|
50
+ # bun.lockb is Bun's too, until bun.lock replaces it.
51
+ others = [*UNSUPPORTED.keys, *REGISTRARS.values.map(&:first)] - [own] +
52
+ (manager == 'bun' ? [] : ['bun.lockb'])
53
+ registers?(root, file) && [nil, manager].include?(named) &&
54
+ (File.exist?(File.join(root, own)) || others.none? { File.exist?(File.join(root, it)) })
55
+ end&.first
56
+ end
57
+
58
+ # Whether `file` lists the contexts: pnpm-workspace.yaml's `packages`, or package.json's
59
+ # `workspaces` (an array, or an object's `packages`). Parsed, so a commented-out line does not
60
+ # count. For the engine a file that does not parse counts if it names them, so a parse failure
61
+ # never silently switches off the context map and the stale check; `strict` (the CLI, which
62
+ # must register a file its manager can read) counts only a file that parses.
63
+ def registers?(root, file)
64
+ path = File.join(root, file)
65
+ File.exist?(path) && registered_in?(file, File.read(path))
66
+ end
67
+
68
+ def registered_in?(file, text, strict: false)
69
+ text = utf8(text).delete_prefix(BOM)
70
+ list = file.end_with?('.yaml') ? yaml_packages(text) : json_workspaces(text)
71
+ list.is_a?(Array) && list.any? { registration?(it) } && !excludes?(list)
72
+ rescue Psych::SyntaxError, JSON::ParserError
73
+ return false if strict
74
+
75
+ file.end_with?('.yaml') ? text.include?(REGISTRATION) : AppPackage.workspaces?(text)
76
+ end
77
+
78
+ # Whether the list has a `!` pattern that takes a context out: pnpm and Bun apply one whatever
79
+ # its order, so the contexts would never be installed. Generous: `*` crosses `/`, and any
80
+ # segment of the pattern may be a gem's directory.
81
+ def excludes?(list)
82
+ Array(list).grep(/\A!/).any? do |entry|
83
+ pattern = entry[1..].delete_prefix('./').chomp('/')
84
+ # A segment with a wildcard, class or brace, such as `[ah]ue`, may match any gem.
85
+ wide = pattern.split('/').map { it.match?(/[*?\[{]/) ? '*' : it }.join('/')
86
+ ['gem', *pattern.split('/')].product([pattern, wide]).any? do |name, glob|
87
+ File.fnmatch?(glob, "#{CONTEXTS}/#{name}", GLOB)
88
+ end
89
+ end
90
+ end
91
+
92
+ GLOB = File::FNM_EXTGLOB | File::FNM_DOTMATCH
93
+
94
+ def json_workspaces(text)
95
+ parsed = JSON.parse(text)
96
+ list = parsed['workspaces'] if parsed.is_a?(Hash)
97
+ list.is_a?(Hash) ? list['packages'] : list
98
+ end
99
+
100
+ # pnpm-workspace.yaml's `packages`: each entry's scalar text, or nil for one that is not a
101
+ # scalar; nil when the document is not a mapping or the key is missing or not a list. Read
102
+ # from the parse tree and never built into Ruby objects, so no alias can make it slow and no
103
+ # tag or date can raise. Raises Psych::SyntaxError.
104
+ def yaml_packages(text)
105
+ list, anchors = YamlTree.value(text, 'packages')
106
+ return unless list.is_a?(Psych::Nodes::Sequence)
107
+
108
+ list.children.map do |entry|
109
+ entry = YamlTree.node(entry, anchors)
110
+ entry.value if entry.is_a?(Psych::Nodes::Scalar)
111
+ end
112
+ end
113
+
114
+ # pnpm and Bun read `./<pattern>` and `<pattern>/` as the pattern.
115
+ def registration?(entry)
116
+ entry.is_a?(String) && entry.delete_prefix('./').chomp('/') == REGISTRATION
117
+ end
118
+
119
+ BOM = "\uFEFF"
120
+
121
+ # A JSON file as Node, pnpm and Bun read it, a leading byte order mark included.
122
+ def read_json(path) = parse_json(File.read(path))
123
+ def parse_json(text) = JSON.parse(utf8(text).delete_prefix(BOM))
124
+
125
+ # Text as UTF-8, whatever encoding the caller's locale gave it.
126
+ def utf8(text) = text.dup.force_encoding(Encoding::UTF_8)
127
+
128
+ # Gem name => absolute context directory for each participating gem, when the app at `root`
129
+ # adopted dependency contexts; empty before. A gem whose context is missing is mapped all the
130
+ # same: its imports then fail by name rather than fall back to the app's packages.
131
+ def contexts(root, specs = BundledGems.installed_specs)
132
+ return {} unless adopted?(root)
133
+
134
+ BundledGems.participating(specs, overrides: BundledGems.overrides(root)).keys.to_h do |gem|
135
+ [gem, File.join(root, CONTEXTS, gem)]
136
+ end
137
+ rescue BundledGems::ConfigError
138
+ {} # Stale reports it, so every build is refused with the reason
139
+ end
140
+
141
+ INSTALLING = File.join('.proscenium', 'installing')
142
+ LOCK = File.join('.proscenium', 'lock')
143
+ INSTALLING_MESSAGE = '`bundle exec proscenium install` is running, or stopped before it ' \
144
+ 'finished. Wait for it, or run it again.'
145
+
146
+ # Whether an install is under way in the app at `root`, or stopped before it finished: its
147
+ # marker is there, or another process holds the project lock. The engine refuses to build or
148
+ # resolve meanwhile (C34). The probe takes a shared lock, so it never stops an install
149
+ # starting; the CLI retries a lock it finds taken for a moment. About 16 µs.
150
+ def installing?(root)
151
+ return true if File.exist?(File.join(root, INSTALLING))
152
+
153
+ lock = File.join(root, LOCK)
154
+ File.exist?(lock) && File.open(lock, File::RDONLY) { !it.flock(File::LOCK_SH | File::LOCK_NB) }
155
+ end
156
+
157
+ # Each file that says the app uses an unsupported manager, as Manager.select reads them.
158
+ UNSUPPORTED = { 'yarn.lock' => 'Yarn', '.yarnrc.yml' => 'Yarn', 'package-lock.json' => 'npm',
159
+ 'npm-shrinkwrap.json' => 'npm' }.freeze
160
+
161
+ # The one notice a process logs at boot when gems opt in but the app has not adopted
162
+ # dependency contexts, or nil. Those gems' imports keep resolving as they always have, so
163
+ # upgrading Proscenium never breaks an app that has not run `proscenium install` (C52).
164
+ def adoption_notice(root, specs = BundledGems.installed_specs)
165
+ return if adopted?(root)
166
+
167
+ gems = BundledGems.participating(specs, overrides: BundledGems.overrides(root)).keys
168
+ return if gems.empty?
169
+
170
+ opted = "#{gems.join(', ')} opt in to installing their NPM dependencies " \
171
+ 'through Proscenium'
172
+ manager = UNSUPPORTED.find { |file, _| File.exist?(File.join(root, file)) }&.last
173
+ if manager
174
+ "#{opted}, but #{manager} is not supported, so the app keeps managing them."
175
+ else
176
+ "#{opted}. Run `bundle exec proscenium install` to install them."
177
+ end
178
+ rescue BundledGems::ConfigError => e
179
+ e.message
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../bundled_gems'
4
+ require_relative 'map'
5
+ require_relative 'stale'
6
+
7
+ module Proscenium
8
+ module Contexts
9
+ # Which mapping generation a build uses (#154). A generation is the context map and the
10
+ # staleness verdict, read once and kept, so every build sees one consistent view. In production
11
+ # a generation lasts the process: restart after a deploy. In development and test, `refresh`
12
+ # looks at most once a second for a change to the files the map is built from, and starts a new
13
+ # generation when one changed, such as `proscenium install` finishing.
14
+ #
15
+ # Gemfile.lock is watched, but a gem added or upgraded still needs a restart: Bundler loads the
16
+ # bundle once per process.
17
+ module MappingGeneration
18
+ # The lockfiles too: with contexts adopted, resolved paths carry each package's version.
19
+ WATCHED = ['Gemfile.lock', 'package.json', 'pnpm-workspace.yaml', '.npmrc',
20
+ 'bunfig.toml', 'pnpm-lock.yaml', 'bun.lock', Map::INSTALLING].freeze
21
+ EVERY = 1.0
22
+ LOCK = Mutex.new
23
+
24
+ module_function
25
+
26
+ # The current generation number, starting a new one first if a watched file changed and the
27
+ # last look was at least `every` seconds ago. Returns [number, whether it just changed].
28
+ def refresh(root, every: EVERY)
29
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
30
+ LOCK.synchronize do
31
+ @looked ||= {}
32
+ @number ||= 0
33
+ at, before = @looked[root]
34
+ return [@number, false] if at && now - at < every
35
+
36
+ after = signature(root)
37
+ @looked[root] = [now, after]
38
+ return [@number, false] if before.nil? || before == after
39
+
40
+ Map.reset!
41
+ Stale.reset!
42
+ [@number += 1, true]
43
+ end
44
+ end
45
+
46
+ # The modification time of every file the map is built from, by path, nil for a missing one.
47
+ def signature(root)
48
+ files = WATCHED.map { File.join(root, it) } +
49
+ Dir.glob(File.join(root, Map::CONTEXTS, '*', 'package.json')) +
50
+ manifests(root)
51
+ files.sort.to_h { [it, File.exist?(it) ? File.mtime(it).to_f : nil] }
52
+ end
53
+
54
+ # Each participating gem's package.json: editing a path gem's manifest makes its context
55
+ # stale.
56
+ def manifests(root)
57
+ participating = BundledGems.participating(BundledGems.installed_specs,
58
+ overrides: BundledGems.overrides(root))
59
+ participating.values.filter_map { BundledGems.manifest_root(it) }
60
+ .map { File.join(it, 'package.json') }
61
+ rescue BundledGems::ConfigError
62
+ []
63
+ end
64
+
65
+ def reset! = LOCK.synchronize { @looked = {} }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ module Contexts
5
+ # The text of every Proscenium error and warning, by code: the problem, its cause and the fix.
6
+ # Templates take keyword arguments. The model reports a problem as a `[code, args]` pair and
7
+ # renders it here; the CLI's Error adds the exit status (cli/error.rb). Golden-output tests pin
8
+ # the text of each entry (test/cli/golden/).
9
+ class Messages
10
+ # code => [message template, fix template].
11
+ TEXT = {
12
+ 'PSM-E-USAGE' => [
13
+ '%<detail>s',
14
+ 'Run `bundle exec proscenium --help` to see the commands and their options.'
15
+ ],
16
+ 'PSM-E-GEMSPEC' => [
17
+ 'Expected one gemspec in %<path>s, but found %<found>s.',
18
+ "Run `proscenium gem check` from the gem's own directory, or pass it that directory " \
19
+ 'or a built .gem file.'
20
+ ],
21
+ 'PSM-E-NOT-OPTED-IN' => [
22
+ '%<gem>s has not opted in to having Proscenium install its NPM ' \
23
+ 'dependencies.',
24
+ "Add `spec.metadata['proscenium.dependencies'] = 'true'` to its gemspec."
25
+ ],
26
+ 'PSM-E-FRONTEND-ROOT' => [
27
+ "%<gem>s's proscenium.frontend_root (%<root>s) points outside the gem.",
28
+ "Set proscenium.frontend_root to a directory inside the gem, relative to the gem's root."
29
+ ],
30
+ 'PSM-E-MANIFEST' => [
31
+ "%<gem>s's %<path>s can't be used, because %<cause>s.",
32
+ 'Ship a valid package.json there: a JSON object whose dependency fields map package ' \
33
+ 'names to version strings.'
34
+ ],
35
+ 'PSM-E-GEM-FILES' => [
36
+ "%<gem>s's gemspec doesn't list %<files>s in spec.files, so the built gem " \
37
+ "won't include it.",
38
+ 'Add package.json, and every frontend file the gem serves, to spec.files.'
39
+ ],
40
+ 'PSM-E-NAME' => [
41
+ "The gem name %<gem>s can't be used as a JavaScript package name " \
42
+ '(@rubygems/%<gem>s).',
43
+ 'JavaScript package names use only lowercase letters, digits and - . _ ~, are at most ' \
44
+ "214 characters long, and can't end in a dot or be a Windows device name such as con " \
45
+ 'or nul. The gem needs a valid name before Proscenium can install its dependencies.'
46
+ ],
47
+ 'PSM-E-WORKSPACES' => [
48
+ "%<gem>s's package.json declares workspaces, which a gem's package.json " \
49
+ "can't do.",
50
+ "Remove \"workspaces\" from the gem's package.json."
51
+ ],
52
+ 'PSM-E-HOOK' => [
53
+ '%<gem>s needs build steps at install time (%<hooks>s), and Proscenium never ' \
54
+ 'runs them.',
55
+ "Ship the gem's assets already built, and remove those install-time hooks."
56
+ ],
57
+ 'PSM-E-SPEC' => [
58
+ "%<gem>s depends on %<name>s as \"%<spec>s\", which Proscenium doesn't allow.",
59
+ 'Use a version range or tag, an npm:<package> alias, a github:, git+https: or git+ssh: ' \
60
+ 'URL, or an https: tarball URL.'
61
+ ],
62
+ 'PSM-E-CREDENTIAL' => [
63
+ "%<gem>s's dependency %<name>s has a password or token in its URL, which " \
64
+ 'would be committed to your repository.',
65
+ 'Remove it from the URL. Apps log in to private registries in their own .npmrc.'
66
+ ],
67
+ 'PSM-E-ALIAS' => [
68
+ "%<gem>s aliases %<name>s to \"%<spec>s\", which is another gem's " \
69
+ 'dependency context.',
70
+ "Add the other gem to the gemspec's dependencies, and use @rubygems/<gem> directly."
71
+ ],
72
+ 'PSM-E-REACT' => [
73
+ '%<gem>s lists %<packages>s as dependencies, so it would get its own copy and ' \
74
+ 'the page would load two.',
75
+ "List %<packages>s under peerDependencies instead, so the gem uses the app's copy."
76
+ ],
77
+ 'PSM-E-CROSS-GEM' => [
78
+ "%<gem>s's package.json uses @rubygems/%<other>s, but its gemspec doesn't " \
79
+ 'depend on the %<other>s gem.',
80
+ "Add `spec.add_dependency '%<other>s'` to the gemspec, or stop using " \
81
+ '@rubygems/%<other>s.'
82
+ ],
83
+ 'PSM-E-NO-MANAGER' => [
84
+ "Proscenium can't tell which package manager this app uses: there is no " \
85
+ 'packageManager field in package.json, and no pnpm-lock.yaml or bun.lock.',
86
+ 'Run again with --manager pnpm or --manager bun, or add a packageManager field to ' \
87
+ 'package.json.'
88
+ ],
89
+ 'PSM-E-MANAGER-CONFLICT' => [
90
+ 'This app points to more than one package manager: %<signals>s.',
91
+ 'Keep just one: delete the other lockfile, or make packageManager in package.json ' \
92
+ 'match the lockfile. (--manager only decides when nothing else does.)'
93
+ ],
94
+ 'PSM-E-UNSUPPORTED-MANAGER' => [
95
+ 'This app uses %<manager>s, but Proscenium can only install ' \
96
+ "gems' NPM dependencies with pnpm or Bun.",
97
+ "Switch the app to pnpm or Bun, or keep adding gems' NPM dependencies to " \
98
+ 'package.json yourself.'
99
+ ],
100
+ 'PSM-E-BUN-LOCKB' => [
101
+ "This app only has bun.lockb, Bun's older binary lockfile, which " \
102
+ "Proscenium can't read.",
103
+ 'Run `bun install --save-text-lockfile` to write bun.lock, then commit it.'
104
+ ],
105
+ 'PSM-E-MANAGER-MISSING' => [
106
+ "%<manager>s isn't installed, or isn't on your PATH.",
107
+ 'Install %<manager>s the way this project normally does (Corepack, mise, Volta or its ' \
108
+ 'own installer), then try again.'
109
+ ],
110
+ 'PSM-E-MANAGER-VERSION' => [
111
+ "Proscenium doesn't support %<manager>s %<version>s.",
112
+ 'Use a supported version of %<manager>s (%<supported>s). To experiment locally with ' \
113
+ 'another, add --experimental-manager-version.'
114
+ ],
115
+ 'PSM-E-EXPERIMENTAL-FROZEN' => [
116
+ "--experimental-manager-version can't be combined with --frozen.",
117
+ 'Use a supported package manager version wherever --frozen runs, such as CI.'
118
+ ],
119
+ 'PSM-E-BUN-LINKER' => [
120
+ "bunfig.toml doesn't say which linker Bun uses (hoisted or isolated), " \
121
+ 'and adding gem dependency contexts could make Bun switch linkers.',
122
+ 'Add `linker = "hoisted"` (or "isolated", whichever your app uses today) to the ' \
123
+ '`[install]` table in bunfig.toml, creating the table if there is none.'
124
+ ],
125
+ 'PSM-E-NESTED-WORKSPACE' => [
126
+ 'This app is inside the JavaScript workspace at %<enclosing>s, and ' \
127
+ "Proscenium doesn't support that yet.",
128
+ "Keep adding gems' NPM dependencies to that workspace yourself, as you do today."
129
+ ],
130
+ 'PSM-E-REGISTRATION' => [
131
+ "Proscenium couldn't add the gem dependency contexts to %<file>s without " \
132
+ 'rewriting the file.',
133
+ 'Check that %<file>s is valid (package.json must be strict JSON, with no comments or ' \
134
+ 'trailing commas). Then add ".proscenium/packages/*" yourself, to `packages` in ' \
135
+ 'pnpm-workspace.yaml or `workspaces` in package.json, and run ' \
136
+ '`bundle exec proscenium install` again.'
137
+ ],
138
+ 'PSM-E-REGISTRATION-EXCLUDED' => [
139
+ '%<file>s excludes .proscenium/packages/ with a "!" pattern, so your package ' \
140
+ 'manager would never install the gem dependency contexts.',
141
+ 'Remove that pattern from %<file>s, then run `bundle exec proscenium install` again.'
142
+ ],
143
+ 'PSM-E-BUSY' => [
144
+ '%<holder>s is already running in this project.',
145
+ 'Wait for it to finish, then run your command again. If nothing is actually ' \
146
+ 'installing, delete .proscenium/manager, which an earlier run left behind.'
147
+ ],
148
+ 'PSM-E-NATIVE' => [
149
+ '`%<command>s` failed (exit status %<status>s).',
150
+ 'Its output is above. Fix what it reports, then run `bundle exec proscenium install` ' \
151
+ "again. Proscenium hasn't changed your package manager or regenerated your lockfile."
152
+ ],
153
+ 'PSM-E-INTERRUPTED' => [
154
+ '`%<command>s` was stopped before it finished.',
155
+ 'Run `bundle exec proscenium install` again to finish.'
156
+ ],
157
+ 'PSM-E-CROSS-GEM-TARGET' => [
158
+ "%<gem>s's package.json uses @rubygems/%<other>s, but %<other>s hasn't " \
159
+ 'opted in to having Proscenium install its NPM dependencies.',
160
+ 'Opt %<other>s in: its author adds proscenium.dependencies to its gemspec, or you add ' \
161
+ '`"proscenium": {"gemOverrides": {"%<other>s": {"participate": true}}}` to your ' \
162
+ 'package.json.'
163
+ ],
164
+ 'PSM-E-CONFIG' => [
165
+ "Proscenium can't read its settings: %<detail>s.",
166
+ 'Fix package.json. Proscenium reads one setting from it: ' \
167
+ '`"proscenium": {"gemOverrides": {"<gem>": {"participate": true}}}`, with true or ' \
168
+ 'false for each gem.'
169
+ ],
170
+ 'PSM-E-PROBLEMS' => [
171
+ 'The install stopped because of the problems above. Nothing was changed.',
172
+ 'Fix each one as it says, then run `bundle exec proscenium install` again.'
173
+ ],
174
+ 'PSM-E-DRIFT' => [
175
+ "Some files are out of date with your bundle:\n%<list>s",
176
+ 'Run `bundle exec proscenium install`, then commit the files it lists.'
177
+ ],
178
+ 'PSM-E-NOT-REGISTERED' => [
179
+ "%<file>s doesn't list .proscenium/packages/*, so %<manager>s won't install " \
180
+ "your gems' NPM dependencies.",
181
+ 'Run `bundle exec proscenium install`, then commit %<file>s.'
182
+ ],
183
+ 'PSM-E-GITIGNORE' => [
184
+ ".gitignore is missing some of Proscenium's lines, or a later rule hides the " \
185
+ 'dependency contexts you need to commit.',
186
+ 'Run `bundle exec proscenium install` to add them, then commit .gitignore.'
187
+ ],
188
+ 'PSM-E-LOCKFILE-MISSING' => [
189
+ '%<lockfile>s is missing.',
190
+ 'Run `bundle exec proscenium install`, then commit %<lockfile>s.'
191
+ ],
192
+ 'PSM-E-NOT-INSTALLED' => [
193
+ "Your NPM dependencies aren't installed: there is no node_modules " \
194
+ 'directory.',
195
+ 'Run `bundle exec proscenium install`, or `bundle exec proscenium install --frozen` on ' \
196
+ 'a fresh checkout or in CI.'
197
+ ],
198
+ 'PSM-E-REGISTRY-TARBALL' => [
199
+ 'The lockfile installs %<packages>s from a registry or Git, instead of ' \
200
+ 'from the gem itself.',
201
+ 'Remove the registry or GitHub dependency on it from your package.json, since ' \
202
+ 'Gemfile.lock provides the gem now, then run `bundle exec proscenium install`.'
203
+ ],
204
+ 'PSM-E-WORKSPACE-MISSING' => [
205
+ "%<manager>s didn't install the dependency contexts for %<gems>s.",
206
+ 'Check that `packages` in pnpm-workspace.yaml, or `workspaces` in package.json, ' \
207
+ 'lists ".proscenium/packages/*", then run `bundle exec proscenium install` again.'
208
+ ],
209
+ 'PSM-E-PEER-SPLIT' => [
210
+ '%<gem>s and your app use different copies of %<package>s, so the page ' \
211
+ 'would load it twice.',
212
+ "Change your app's %<package>s to a version within %<gem>s's peer range, then run " \
213
+ '`bundle exec proscenium install` again.'
214
+ ],
215
+ 'PSM-E-COLLISION' => [
216
+ "Some of your app's dependencies use a name a gem's dependency context " \
217
+ "needs:\n%<list>s",
218
+ 'Remove each one, since Gemfile.lock provides the gem now, or rename the package.'
219
+ ],
220
+ 'PSM-E-BUN-DEFAULT-TRUSTED' => [
221
+ "`bun pm default-trusted` didn't print Bun's default list of trusted " \
222
+ "packages, so Proscenium can't check whose install scripts Bun would run.",
223
+ 'Add `"trustedDependencies": []` to package.json, listing any packages whose install ' \
224
+ 'scripts your app needs, so Bun uses that list instead of its default.'
225
+ ],
226
+ 'PSM-E-TRUSTED-SOURCE' => [
227
+ 'Some gems install a package from Git, a URL or an alias to another package, ' \
228
+ 'under a name your package manager trusts to run install scripts, so that ' \
229
+ "code's install script would run:\n%<list>s",
230
+ "Ask the gem's author to depend on the registry package instead, or opt the gem out " \
231
+ 'with `"proscenium": {"gemOverrides": {"<gem>": {"participate": false}}}` in ' \
232
+ 'package.json. pnpm trusts the packages pnpm-workspace.yaml approves builds for; Bun ' \
233
+ 'trusts trustedDependencies in package.json, or its own default list without one.'
234
+ ],
235
+ 'PSM-E-PLATFORM-VARIANT' => [
236
+ 'The platform-specific builds of %<variants>s differ from the one ' \
237
+ 'installed here in their NPM dependencies or whether they opt ' \
238
+ "in, or don't pass `proscenium gem check`.",
239
+ 'Make every platform build of the gem ship the same package.json dependencies and ' \
240
+ 'proscenium.dependencies setting, each passing `proscenium gem check`. Your lockfile ' \
241
+ 'can only hold one set.'
242
+ ],
243
+ 'PSM-E-UNC-SHIM' => [
244
+ "%<manager>s is installed as a .cmd script, and cmd.exe can't run in " \
245
+ "%<root>s because it's a network (UNC) path: it would run in " \
246
+ 'C:\\Windows instead.',
247
+ 'Map a drive letter to the share (`net use Z: \\\\server\\share`) and run from that ' \
248
+ 'drive, or install %<manager>s as a native executable.'
249
+ ],
250
+ 'PSM-E-OWNED-LINK' => [
251
+ '%<paths>s is a symbolic link, so installing would write outside your app.',
252
+ 'Replace it with a real directory. Proscenium only writes inside .proscenium/.'
253
+ ],
254
+ 'PSM-E-OWNED-DIR' => [
255
+ ".proscenium/packages/ contains things Proscenium didn't create: %<entries>s.",
256
+ 'Move them somewhere else. Proscenium manages that directory, and treats everything ' \
257
+ 'in it as a gem dependency context.'
258
+ ],
259
+ 'PSM-W-END-OF-LIFE' => [
260
+ '%<manager>s %<version>s reaches end of life on %<eol>s, and a future ' \
261
+ 'Proscenium release may stop supporting it.',
262
+ 'Plan an upgrade to a newer %<manager>s version; the upgrade guide explains the steps.'
263
+ ],
264
+ 'PSM-E-INTERNAL' => [
265
+ 'Something went wrong inside Proscenium: %<detail>s',
266
+ 'This is a bug in Proscenium. Please report it at ' \
267
+ 'https://github.com/joelmoss/proscenium/issues, including the output above.'
268
+ ]
269
+ }.freeze
270
+
271
+ def self.message(code, **args) = format(TEXT.fetch(code).first, **args)
272
+
273
+ def self.fix(code, **args) = format(TEXT.fetch(code).last, **args)
274
+ end
275
+ end
276
+ end
@@ -1,18 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require_relative '../context_map'
4
+ require_relative 'map'
5
5
  require_relative '../bundled_gems'
6
- require_relative '../dependency_context'
6
+ require_relative 'projection'
7
7
  require_relative 'rules'
8
8
 
9
9
  module Proscenium
10
- module CLI
11
- # The contexts the bundle calls for (#154): one per participating, installed gem, projected
12
- # from its package.json after the author contract and the gem-to-gem rules. Computes; never
13
- # writes.
14
- class Contexts
15
- DIR = File.join('.proscenium', 'packages')
10
+ module Contexts
11
+ # The contexts the bundle calls for (#154): one per participating, installed gem, projected from
12
+ # its package.json after the author contract and the gem-to-gem rules. Computes; never writes.
13
+ # Shared by the engine (Stale) and the CLI (install, inspect), so it loads nothing of the
14
+ # CLI; problems are `[code, args]` pairs, rendered by whoever reports them.
15
+ class Plan
16
16
  ESCAPE = "Or, to install %<gem>s's NPM dependencies yourself, opt it out with " \
17
17
  '`"proscenium": {"gemOverrides": {"%<gem>s": {"participate": false}}}` in ' \
18
18
  "package.json, then add the packages it needs to your app's own dependencies."
@@ -36,7 +36,7 @@ module Proscenium
36
36
 
37
37
  # The committed context directories under the app, by gem name.
38
38
  def self.committed(root)
39
- dir = File.join(root, DIR)
39
+ dir = File.join(root, Map::CONTEXTS)
40
40
  return {} unless Dir.exist?(dir)
41
41
 
42
42
  Dir.children(dir).select { File.directory?(File.join(dir, it)) }.sort
@@ -61,8 +61,8 @@ module Proscenium
61
61
  return unless fatal.empty?
62
62
 
63
63
  manifest = link_gems(gem, spec, manifest) or return
64
- context = DependencyContext.project(gem, manifest)
65
- @contexts[gem] = Context.new(gem:, json: DependencyContext.to_json(context),
64
+ context = Projection.project(gem, manifest)
65
+ @contexts[gem] = Context.new(gem:, json: Projection.to_json(context),
66
66
  git_and_url: git_and_url(context))
67
67
  end
68
68
 
@@ -75,7 +75,7 @@ module Proscenium
75
75
  return problem('PSM-E-MANIFEST', gem:, path: 'package.json', cause:)
76
76
  end
77
77
 
78
- manifest = ContextMap.read_json(path)
78
+ manifest = Map.read_json(path)
79
79
  return manifest if manifest.is_a?(Hash)
80
80
 
81
81
  problem('PSM-E-MANIFEST', gem:, path: 'package.json', cause: 'it is not a JSON object')
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Proscenium
7
+ module Contexts
8
+ # A participating gem's dependency context (#154): its package.json through the
9
+ # `dependency-context-v1` projection, which keeps installation metadata only and is committed at
10
+ # `.proscenium/packages/<gem>/package.json`.
11
+ #
12
+ # The one owner of the projection and its hash, shared by the CLI and the engine, so it must not
13
+ # load ActiveSupport or anything else the CLI may not.
14
+ module Projection
15
+ PROJECTION = 'dependency-context-v1'
16
+
17
+ # The manifest fields a context keeps, in the order it writes them. Everything else (scripts,
18
+ # devDependencies, version, entry points, workspaces) is left in the gem's own package.json.
19
+ FIELDS = %w[dependencies peerDependencies peerDependenciesMeta optionalDependencies engines os
20
+ cpu libc].freeze
21
+
22
+ module_function
23
+
24
+ def name_for(gem) = "@rubygems/#{gem}"
25
+
26
+ def description_for(gem)
27
+ "Generated by Proscenium from the #{gem} gem. Do not edit; " \
28
+ 'run bundle exec proscenium install.'
29
+ end
30
+
31
+ # The context for `gem` from its parsed `manifest`. An empty field is left out, as an absent
32
+ # one is: the two install the same, so they hash the same.
33
+ def project(gem, manifest)
34
+ fields = FIELDS.each_with_object({}) do |key, kept|
35
+ value = manifest[key]
36
+ kept[key] = value unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
37
+ end
38
+
39
+ { 'name' => name_for(gem), 'private' => true, 'description' => description_for(gem),
40
+ **fields,
41
+ 'proscenium' => { 'projection' => PROJECTION,
42
+ 'projectionSha256' => digest(gem, fields) } }
43
+ end
44
+
45
+ # The bytes committed for a context: fixed key order, two-space indent, a trailing newline,
46
+ # and nothing that varies by host or time.
47
+ def to_json(context) = "#{JSON.pretty_generate(context)}\n"
48
+
49
+ # The projection hash. It hashes a fixed-field encoding of the projected fields, not JSON, so
50
+ # it does not move with the json gem's output, key order, whitespace or escaping, and an edit
51
+ # to anything the projection drops leaves it alone.
52
+ def digest(gem, fields)
53
+ Digest::SHA256.hexdigest("#{PROJECTION}\n#{encode(name_for(gem))}#{encode(fields)}")
54
+ end
55
+
56
+ # A self-delimiting encoding: a type tag, a length where there is one, then the value. Hash
57
+ # keys are sorted by their bytes, so key order in the manifest does not matter; array order
58
+ # does, as it does to npm.
59
+ def encode(value)
60
+ case value
61
+ when String then "s#{value.bytesize}:#{value.b}"
62
+ when true then 't'
63
+ when false then 'f'
64
+ when nil then 'n'
65
+ when Integer then "i#{value};"
66
+ when Float then "d#{value};"
67
+ when Array then "a#{value.size}:#{value.map { encode(it) }.join}"
68
+ when Hash
69
+ pairs = value.sort_by { |key, _| key.to_s.b }
70
+ "h#{value.size}:#{pairs.map { |key, val| encode(key.to_s) + encode(val) }.join}"
71
+ else
72
+ raise ArgumentError, "cannot encode #{value.class} in a dependency context"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end