crosspack 0.2.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.
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Crosspack
6
+ # Canonical dependency names crosspack knows about. Anything else in
7
+ # deps.yaml still resolves, but produces a warning (possible typo).
8
+ KNOWN_CANONICAL = %w[
9
+ webkit2gtk gtk3 openmp ayatana-appindicator
10
+ glib2 cairo pango gdk-pixbuf alsa-lib pulseaudio dbus
11
+ ].freeze
12
+
13
+ # Scalar verdicts a target may carry instead of a package list.
14
+ VERDICTS = %w[system none bundled].freeze
15
+
16
+ CANONICAL_NAME_RE = /\A[a-z0-9][a-z0-9+._-]*\z/.freeze
17
+
18
+ # A single schema problem: full path into deps.yaml + human explanation.
19
+ class Issue
20
+ attr_reader :path, :message
21
+
22
+ def initialize(path, message)
23
+ @path = path
24
+ @message = message
25
+ end
26
+
27
+ def to_s
28
+ "#{@path}: #{@message}"
29
+ end
30
+ end
31
+
32
+ class InvalidManifestError < StandardError; end
33
+
34
+ # Parses and validates deps.yaml. Root keys are canonical dependency
35
+ # names; each maps to { targets: { family: { "version": [pkgs] } } } or a
36
+ # scalar verdict for a whole family.
37
+ class Manifest
38
+ attr_reader :path, :deps, :errors, :warnings
39
+
40
+ def self.load(path)
41
+ unless File.file?(path)
42
+ raise InvalidManifestError,
43
+ "Dependencies file not found: #{path}\n" \
44
+ 'Create deps.yaml next to the Rakefile (see crosspack/README.md).'
45
+ end
46
+ begin
47
+ raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
48
+ rescue Psych::SyntaxError => e
49
+ raise InvalidManifestError,
50
+ "#{path}: YAML syntax error on line #{e.line}: #{e.problem}"
51
+ end
52
+ new(path, raw)
53
+ end
54
+
55
+ def initialize(path, raw)
56
+ @path = path
57
+ @deps = raw.is_a?(Hash) ? raw : {}
58
+ @raw = raw
59
+ @errors = []
60
+ @warnings = []
61
+ validate
62
+ end
63
+
64
+ def valid?
65
+ @errors.empty?
66
+ end
67
+
68
+ # Raises with the full, formatted list of problems.
69
+ def validate!
70
+ return self if valid?
71
+
72
+ lines = ["#{path}: deps.yaml schema is invalid (#{@errors.size} errors):"]
73
+ @errors.each { |e| lines << " ✗ #{e}" }
74
+ lines << 'Build aborted: fix the listed nodes and retry.'
75
+ raise InvalidManifestError, lines.join("\n")
76
+ end
77
+
78
+ # Report used by rake abort: errors (if any) plus warnings.
79
+ def error_report
80
+ lines = []
81
+ if valid?
82
+ lines << "#{path}: schema is valid."
83
+ else
84
+ lines << "#{path}: deps.yaml schema is invalid (#{@errors.size} errors):"
85
+ @errors.each { |e| lines << " ✗ #{e}" }
86
+ end
87
+ @warnings.each { |w| lines << " ⚠ #{w}" }
88
+ lines.join("\n")
89
+ end
90
+
91
+ private
92
+
93
+ def validate
94
+ unless @raw.is_a?(Hash) && !@raw.empty?
95
+ @errors << Issue.new(
96
+ '(root)',
97
+ 'file must be a non-empty mapping: canonical name -> { targets: ... }'
98
+ )
99
+ return
100
+ end
101
+ @raw.each { |name, body| validate_dep(name.to_s, body) }
102
+ end
103
+
104
+ def validate_dep(name, body)
105
+ if name !~ CANONICAL_NAME_RE
106
+ @errors << Issue.new(
107
+ name,
108
+ "invalid canonical name #{name.inspect} " \
109
+ '(lowercase letters, digits, "-", "_", "."; must start with a letter or digit)'
110
+ )
111
+ end
112
+ unless KNOWN_CANONICAL.include?(name)
113
+ @warnings << Issue.new(
114
+ name,
115
+ 'unknown to crosspack — not an error if the rules below are correct (possible typo?)'
116
+ )
117
+ end
118
+ unless body.is_a?(Hash) && body.key?('targets')
119
+ @errors << Issue.new(
120
+ name,
121
+ "missing required field targets; expected:\n" \
122
+ " #{name}:\n targets:\n debian:\n \"12\": [package]"
123
+ )
124
+ return
125
+ end
126
+
127
+ targets = body['targets']
128
+ unless targets.is_a?(Hash) && !targets.empty?
129
+ @errors << Issue.new(
130
+ "#{name}.targets",
131
+ "must be a non-empty mapping: family -> rules (debian, ubuntu, fedora, ...)"
132
+ )
133
+ return
134
+ end
135
+ targets.each { |family, rules| validate_family(name, family.to_s, rules) }
136
+ end
137
+
138
+ def validate_family(name, family, rules)
139
+ path = "#{name}.targets.#{family}"
140
+ known = Target::FAMILIES.map(&:to_s)
141
+ unless known.include?(family)
142
+ suggestion = suggest(family, known)
143
+ hint = suggestion ? " (did you mean #{suggestion.inspect}?)" : ''
144
+ @errors << Issue.new(
145
+ path,
146
+ "unknown distro family #{family.inspect}#{hint}; " \
147
+ "allowed: #{known.join(', ')}"
148
+ )
149
+ return
150
+ end
151
+
152
+ if rules.is_a?(String)
153
+ validate_verdict(path, rules)
154
+ return
155
+ end
156
+ unless rules.is_a?(Hash) && !rules.empty?
157
+ @errors << Issue.new(
158
+ path,
159
+ "expected a mapping { \"version\": [packages], ... } or a scalar: #{VERDICTS.join(' / ')}"
160
+ )
161
+ return
162
+ end
163
+
164
+ rules.each do |version, value|
165
+ vpath = "#{path}.#{version.inspect}"
166
+ unless version.is_a?(String)
167
+ @errors << Issue.new(
168
+ vpath,
169
+ "version key must be a quoted string: \"#{version}\" — " \
170
+ 'without quotes YAML parses it as a number'
171
+ )
172
+ next
173
+ end
174
+ if value.is_a?(String)
175
+ validate_verdict(vpath, value)
176
+ elsif value.is_a?(Array)
177
+ validate_package_list(vpath, value)
178
+ else
179
+ @errors << Issue.new(
180
+ vpath,
181
+ "value must be a list of packages [pkg, ...] or a scalar: #{VERDICTS.join(' / ')}"
182
+ )
183
+ end
184
+ end
185
+ end
186
+
187
+ def validate_verdict(path, value)
188
+ return if VERDICTS.include?(value)
189
+
190
+ @errors << Issue.new(
191
+ path,
192
+ "unknown verdict #{value.inspect}; allowed: #{VERDICTS.join(' / ')}"
193
+ )
194
+ end
195
+
196
+ def validate_package_list(path, value)
197
+ if value.empty?
198
+ @errors << Issue.new(
199
+ path,
200
+ 'package list is empty — specify at least one package or the none verdict'
201
+ )
202
+ return
203
+ end
204
+ value.each_with_index do |pkg, i|
205
+ next if pkg.is_a?(String) && !pkg.strip.empty? && pkg == pkg.strip
206
+
207
+ @errors << Issue.new(
208
+ "#{path}[#{i}]",
209
+ 'package name must be a non-empty string without surrounding whitespace'
210
+ )
211
+ end
212
+ end
213
+
214
+ def suggest(word, candidates)
215
+ best = candidates.min_by { |c| levenshtein(word, c) }
216
+ distance = levenshtein(word, best)
217
+ distance <= 2 ? best : nil
218
+ end
219
+
220
+ def levenshtein(a, b)
221
+ prev = (0..b.length).to_a
222
+ a.chars.each_with_index do |ca, i|
223
+ curr = [i + 1]
224
+ b.chars.each_with_index do |cb, j|
225
+ cost = ca == cb ? 0 : 1
226
+ curr[j + 1] = [
227
+ curr[j] + 1, # insertion
228
+ prev[j + 1] + 1, # deletion
229
+ prev[j] + cost # substitution
230
+ ].min
231
+ end
232
+ prev = curr
233
+ end
234
+ prev[b.length]
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Crosspack
6
+ # Renders the dependency x target matrix straight from the manifest.
7
+ # Columns come from the targets declared in deps.yaml itself.
8
+ class Matrix
9
+ def initialize(manifest)
10
+ @manifest = manifest
11
+ end
12
+
13
+ def render
14
+ columns = target_columns
15
+ return 'deps.yaml is empty — nothing to show.' if columns.empty?
16
+
17
+ header = ['dependency'] + columns.map(&:to_s)
18
+ rows = @manifest.deps.map do |name, body|
19
+ [name] + columns.map { |t| cell(body['targets'], t) }
20
+ end
21
+
22
+ widths = header.each_index.map do |i|
23
+ ([header[i].length] + rows.map { |r| r[i].to_s.length }).max
24
+ end
25
+ lines = ([header] + rows).map do |row|
26
+ row.each_with_index.map { |c, i| c.to_s.ljust(widths[i]) }.join(' ')
27
+ end
28
+ lines.join("\n")
29
+ end
30
+
31
+ private
32
+
33
+ # Every target mentioned in the manifest: family + version pairs,
34
+ # ordered by the canonical family list.
35
+ def target_columns
36
+ cols = []
37
+ Target::FAMILIES.each do |family|
38
+ versions = Set.new
39
+ @manifest.deps.each_value do |body|
40
+ rule = body['targets'][family.to_s]
41
+ next if rule.nil?
42
+
43
+ if rule.is_a?(String)
44
+ versions.add(nil)
45
+ else
46
+ rule.each_key { |v| versions.add(v == '*' ? nil : v) }
47
+ end
48
+ end
49
+ versions.to_a.sort_by(&:to_s).each do |v|
50
+ cols << Target.new(family, v)
51
+ end
52
+ end
53
+ cols
54
+ end
55
+
56
+ def cell(targets, target)
57
+ rule = targets[target.family.to_s]
58
+ return '—' if rule.nil?
59
+
60
+ value = rule.is_a?(String) ? rule : (rule[target.version] || rule['*'])
61
+ return '(no rule)' if value.nil?
62
+ return value unless value.is_a?(Array)
63
+
64
+ value.join(' | ')
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Crosspack
6
+ # Packaging manifest (package.yaml): declares WHAT and HOW to pack —
7
+ # payload, executables, symlinks, desktop entry, icon. Compilation is not
8
+ # crosspack's business; artifacts are picked from `sources` as-is.
9
+ class PackageManifest
10
+ REQUIRED = %w[name maintainer description sources prefix payload].freeze
11
+ OPTIONAL = %w[summary license section lib_dir executables links desktop icon].freeze
12
+
13
+ attr_reader :path, :data, :errors, :warnings
14
+
15
+ def self.load(path)
16
+ unless File.file?(path)
17
+ raise InvalidManifestError,
18
+ "Packaging manifest not found: #{path}\n" \
19
+ 'Create package.yaml next to the Rakefile (see crosspack/README.md).'
20
+ end
21
+ begin
22
+ raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
23
+ rescue Psych::SyntaxError => e
24
+ raise InvalidManifestError,
25
+ "#{path}: YAML syntax error on line #{e.line}: #{e.problem}"
26
+ end
27
+ new(path, raw)
28
+ end
29
+
30
+ def initialize(path, raw)
31
+ @path = path
32
+ @data = raw.is_a?(Hash) ? raw : {}
33
+ @raw = raw
34
+ @errors = []
35
+ @warnings = []
36
+ validate
37
+ end
38
+
39
+ def valid?
40
+ @errors.empty?
41
+ end
42
+
43
+ def validate!
44
+ return self if valid?
45
+
46
+ lines = ["#{path}: package.yaml schema is invalid (#{@errors.size} errors):"]
47
+ @errors.each { |e| lines << " ✗ #{e}" }
48
+ lines << 'Packing aborted: fix the listed nodes and retry.'
49
+ raise InvalidManifestError, lines.join("\n")
50
+ end
51
+
52
+ def error_report
53
+ if valid?
54
+ "#{path}: schema is valid."
55
+ else
56
+ ["#{path}: package.yaml schema is invalid (#{@errors.size} errors):",
57
+ *@errors.map { |e| " ✗ #{e}" }].join("\n")
58
+ end
59
+ end
60
+
61
+ # ---- typed accessors (validated values) ----
62
+
63
+ def name
64
+ @data['name']
65
+ end
66
+
67
+ def maintainer
68
+ @data['maintainer']
69
+ end
70
+
71
+ def description
72
+ @data['description'].to_s
73
+ end
74
+
75
+ def license
76
+ @data['license'] || 'Proprietary'
77
+ end
78
+
79
+ def section
80
+ @data['section'] || 'utils'
81
+ end
82
+
83
+ def sources
84
+ @data['sources']
85
+ end
86
+
87
+ def prefix
88
+ @data['prefix']
89
+ end
90
+
91
+ def payload
92
+ Array(@data['payload'])
93
+ end
94
+
95
+ def executables
96
+ Array(@data['executables'])
97
+ end
98
+
99
+ def summary
100
+ @data['summary'] || description.lines.map(&:chomp).reject(&:empty?).first || name
101
+ end
102
+
103
+ def lib_dir
104
+ @data['lib_dir'] || File.join('lib', name.to_s)
105
+ end
106
+
107
+ def links
108
+ @data['links'].is_a?(Hash) ? @data['links'] : {}
109
+ end
110
+
111
+ def desktop
112
+ @data['desktop'].is_a?(Hash) ? @data['desktop'] : nil
113
+ end
114
+
115
+ def icon
116
+ @data['icon']
117
+ end
118
+
119
+ private
120
+
121
+ def validate
122
+ unless @raw.is_a?(Hash) && !@raw.empty?
123
+ @errors << Issue.new('(root)', 'file must be a non-empty mapping: name, maintainer, payload, ...')
124
+ return
125
+ end
126
+
127
+ REQUIRED.each do |key|
128
+ next unless @data[key].nil?
129
+
130
+ @errors << Issue.new(key, 'required field is missing')
131
+ end
132
+
133
+ unknown = @data.keys - REQUIRED - OPTIONAL
134
+ unknown.each do |key|
135
+ @errors << Issue.new(key, "unknown field; allowed: #{(REQUIRED + OPTIONAL).sort.join(', ')}")
136
+ end
137
+
138
+ validate_string('name', /\A[a-z0-9][a-z0-9+._-]*\z/)
139
+ validate_string('maintainer', nil)
140
+ validate_string('description', nil)
141
+ validate_string('sources', nil)
142
+ validate_string('prefix', /\A[a-z0-9][a-z0-9+._\/-]*\z/)
143
+ validate_payload
144
+ validate_executables
145
+ validate_links
146
+ validate_desktop
147
+ end
148
+
149
+ def validate_string(key, re)
150
+ value = @data[key]
151
+ return if value.nil? # already reported via REQUIRED
152
+
153
+ if !value.is_a?(String) || value.strip.empty?
154
+ @errors << Issue.new(key, 'must be a non-empty string')
155
+ return
156
+ end
157
+ return unless re && !value.match?(re)
158
+
159
+ @errors << Issue.new(key, "invalid value #{value.inspect}")
160
+ end
161
+
162
+ def validate_payload
163
+ value = @data['payload']
164
+ return if value.nil? # already reported as missing
165
+
166
+ unless value.is_a?(Array) && !value.empty?
167
+ @errors << Issue.new('payload', 'must be a non-empty list of artifact files from sources')
168
+ return
169
+ end
170
+ value.each_with_index do |item, i|
171
+ next if item.is_a?(String) && !item.strip.empty? && !item.start_with?('/')
172
+
173
+ @errors << Issue.new("payload[#{i}]", 'file name must be a non-empty relative string (no leading "/")')
174
+ end
175
+ end
176
+
177
+ def validate_executables
178
+ value = @data['executables']
179
+ return if value.nil?
180
+
181
+ unless value.is_a?(Array)
182
+ @errors << Issue.new('executables', 'must be a list of names from payload')
183
+ return
184
+ end
185
+ value.each do |exe|
186
+ next if payload.include?(exe)
187
+
188
+ @errors << Issue.new('executables', "#{exe.inspect} is not in payload — only files from payload can be executable")
189
+ end
190
+ end
191
+
192
+ def validate_links
193
+ value = @data['links']
194
+ return if value.nil?
195
+
196
+ unless value.is_a?(Hash) && !value.empty?
197
+ @errors << Issue.new('links', 'must be a mapping: relative symlink path -> target')
198
+ return
199
+ end
200
+ value.each do |link, target|
201
+ if link.to_s.start_with?('/')
202
+ @errors << Issue.new("links.#{link}", 'symlink path must be relative to prefix (no leading "/")')
203
+ end
204
+ unless target.is_a?(String) && !target.strip.empty?
205
+ @errors << Issue.new("links.#{link}", 'symlink target must be a non-empty string')
206
+ end
207
+ end
208
+ end
209
+
210
+ def validate_desktop
211
+ value = @data['desktop']
212
+ return if value.nil?
213
+
214
+ unless value.is_a?(Hash) && !value.empty?
215
+ @errors << Issue.new('desktop', 'must be a mapping: name, comment, categories, exec (optional)')
216
+ end
217
+ end
218
+ end
219
+ end