proscenium 0.26.1-aarch64-linux-gnu → 0.27.0-aarch64-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.
- checksums.yaml +4 -4
- data/README.md +50 -3
- data/lib/proscenium/app_package.rb +8 -4
- data/lib/proscenium/builder/request.rb +72 -0
- data/lib/proscenium/builder.rb +10 -74
- data/lib/proscenium/cli/bun_linker.rb +2 -2
- data/lib/proscenium/cli/collisions.rb +7 -9
- data/lib/proscenium/cli/error.rb +58 -265
- data/lib/proscenium/cli/gem_archive.rb +4 -2
- data/lib/proscenium/cli/gem_check.rb +13 -11
- data/lib/proscenium/cli/gitignore.rb +2 -1
- data/lib/proscenium/cli/inspect.rb +12 -11
- data/lib/proscenium/cli/install.rb +35 -46
- data/lib/proscenium/cli/manager.rb +6 -6
- data/lib/proscenium/cli/platform_variants.rb +8 -8
- data/lib/proscenium/cli/pnpm_approvals.rb +3 -3
- data/lib/proscenium/cli/pnpm_workspace.rb +15 -14
- data/lib/proscenium/cli/project_lock.rb +3 -3
- data/lib/proscenium/cli/registration.rb +5 -5
- data/lib/proscenium/cli/reporter.rb +2 -2
- data/lib/proscenium/cli/verify.rb +9 -50
- data/lib/proscenium/cli.rb +5 -4
- data/lib/proscenium/contexts/map.rb +183 -0
- data/lib/proscenium/contexts/mapping_generation.rb +68 -0
- data/lib/proscenium/contexts/messages.rb +276 -0
- data/lib/proscenium/{cli/contexts.rb → contexts/plan.rb} +12 -12
- data/lib/proscenium/contexts/projection.rb +77 -0
- data/lib/proscenium/{cli → contexts}/rules.rb +1 -1
- data/lib/proscenium/contexts/stale.rb +174 -0
- data/lib/proscenium/contexts.rb +16 -0
- data/lib/proscenium/css_module/path.rb +1 -0
- data/lib/proscenium/css_module/transformer.rb +15 -0
- data/lib/proscenium/css_module.rb +3 -2
- data/lib/proscenium/ext/proscenium +0 -0
- data/lib/proscenium/helper.rb +6 -1
- data/lib/proscenium/importer.rb +13 -1
- data/lib/proscenium/middleware/base.rb +0 -4
- data/lib/proscenium/middleware.rb +0 -2
- data/lib/proscenium/railtie.rb +4 -4
- data/lib/proscenium/resolver.rb +3 -1
- data/lib/proscenium/side_load.rb +6 -2
- data/lib/proscenium/source_path.rb +2 -0
- data/lib/proscenium/utils.rb +2 -1
- data/lib/proscenium/version.rb +1 -1
- data/lib/proscenium.rb +5 -5
- metadata +10 -7
- data/lib/proscenium/context_map.rb +0 -181
- data/lib/proscenium/dependency_context.rb +0 -74
- data/lib/proscenium/mapping_generation.rb +0 -64
- 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
|