zer0-image-generator 0.6.0 → 0.8.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +106 -2
- data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
- data/lib/zer0_image_generator/abc.rb +75 -0
- data/lib/zer0_image_generator/all.rb +57 -0
- data/lib/zer0_image_generator/claude/client.rb +291 -0
- data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
- data/lib/zer0_image_generator/cli.rb +318 -0
- data/lib/zer0_image_generator/config.rb +192 -0
- data/lib/zer0_image_generator/constants.rb +177 -0
- data/lib/zer0_image_generator/content.rb +379 -0
- data/lib/zer0_image_generator/engine.rb +21 -0
- data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
- data/lib/zer0_image_generator/freesvg/client.rb +467 -0
- data/lib/zer0_image_generator/http.rb +264 -0
- data/lib/zer0_image_generator/library.rb +201 -0
- data/lib/zer0_image_generator/logging.rb +236 -0
- data/lib/zer0_image_generator/preview_generator.py +1072 -100
- data/lib/zer0_image_generator/prompt.rb +48 -0
- data/lib/zer0_image_generator/providers/base.rb +152 -0
- data/lib/zer0_image_generator/providers/gemini.rb +60 -0
- data/lib/zer0_image_generator/providers/local.rb +90 -0
- data/lib/zer0_image_generator/providers/openai.rb +102 -0
- data/lib/zer0_image_generator/providers/stability.rb +64 -0
- data/lib/zer0_image_generator/providers/xai.rb +81 -0
- data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
- data/lib/zer0_image_generator/providers.rb +22 -0
- data/lib/zer0_image_generator/runner.rb +573 -0
- data/lib/zer0_image_generator/settings.rb +386 -0
- data/lib/zer0_image_generator/stats.rb +61 -0
- data/lib/zer0_image_generator/support/py_random.rb +189 -0
- data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
- data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
- data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
- data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
- data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
- data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
- data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
- data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
- data/lib/zer0_image_generator/svg/lint.rb +400 -0
- data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
- data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
- data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
- data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
- data/lib/zer0_image_generator/version.rb +1 -1
- metadata +43 -2
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/document"
|
|
4
|
+
|
|
5
|
+
module Zer0ImageGenerator
|
|
6
|
+
module Svg
|
|
7
|
+
# Deterministic SVG lint — a port of `tools/svg/svg-check.py`, which stays
|
|
8
|
+
# the oracle.
|
|
9
|
+
#
|
|
10
|
+
# These are the checks you do not want to burn model tokens on: vision is
|
|
11
|
+
# for composition, this is for correctness. The house rules it enforces
|
|
12
|
+
# live in `.claude/rules/svg.md` (viewBox required, role="img" + <title>,
|
|
13
|
+
# no raster/network payloads, coordinate sanity, live id references).
|
|
14
|
+
#
|
|
15
|
+
# Three things here are contract, not taste, because the PostToolUse hook
|
|
16
|
+
# (`tools/svg/svg-hook.sh` via `.claude/settings.json`) consumes them:
|
|
17
|
+
# - exit 0 means clean, exit 1 means the write was rejected;
|
|
18
|
+
# - findings go to stderr as `WARN …` / `ERROR …` lines, warnings first;
|
|
19
|
+
# - the exact wording is what Claude reads back as the reason to fix.
|
|
20
|
+
# Rewording a message or reordering the findings changes what the model is
|
|
21
|
+
# told, so both are pinned by the differential test against the Python.
|
|
22
|
+
module Lint
|
|
23
|
+
SVG_NS = "http://www.w3.org/2000/svg"
|
|
24
|
+
XLINK = "http://www.w3.org/1999/xlink"
|
|
25
|
+
|
|
26
|
+
# Same shape as svg-check.py's NUM. Ruby's `\d` is ASCII-only where
|
|
27
|
+
# Python's is Unicode-aware; SVG number syntax is ASCII either way.
|
|
28
|
+
NUM = /-?\d*\.?\d+(?:[eE][-+]?\d+)?/.freeze
|
|
29
|
+
|
|
30
|
+
# Absolute-coordinate commands only — see #coords_in_path.
|
|
31
|
+
PATH_SEGMENT = /[MLHVCSQTA][^A-Za-z]*/.freeze
|
|
32
|
+
|
|
33
|
+
DEFAULT_MAX_KB = 250.0
|
|
34
|
+
|
|
35
|
+
USAGE = "usage: lint [-h] [--max-kb MAX_KB] [--strict] path"
|
|
36
|
+
|
|
37
|
+
# A finished lint run.
|
|
38
|
+
#
|
|
39
|
+
# `fatal` models svg-check.py's two early `return 1` paths (missing file,
|
|
40
|
+
# unparseable XML): they print one ERROR line and never reach the
|
|
41
|
+
# WARN/ERROR accumulation, so a fatal report deliberately discards any
|
|
42
|
+
# findings gathered before the failure (the size check, notably).
|
|
43
|
+
class Report
|
|
44
|
+
attr_reader :path, :kilobytes, :errors, :warnings, :fatal
|
|
45
|
+
|
|
46
|
+
def initialize(path:, kilobytes: 0.0, errors: [], warnings: [], fatal: nil, strict: false)
|
|
47
|
+
@path = path
|
|
48
|
+
@kilobytes = kilobytes
|
|
49
|
+
@errors = errors
|
|
50
|
+
@warnings = warnings
|
|
51
|
+
@fatal = fatal
|
|
52
|
+
@strict = strict
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def strict? = @strict
|
|
56
|
+
|
|
57
|
+
# Warnings are advisory in normal mode and fail the run under --strict;
|
|
58
|
+
# that is the only difference between the two modes at the exit code.
|
|
59
|
+
def exit_code
|
|
60
|
+
return 1 if @fatal
|
|
61
|
+
return 1 if !@errors.empty? || (@strict && !@warnings.empty?)
|
|
62
|
+
|
|
63
|
+
0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clean? = exit_code.zero?
|
|
67
|
+
|
|
68
|
+
def stderr_lines
|
|
69
|
+
return ["ERROR #{@fatal}"] if @fatal
|
|
70
|
+
|
|
71
|
+
@warnings.map { |w| "WARN #{w}" } + @errors.map { |e| "ERROR #{e}" }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A run can be clean and still have warnings, and the "clean" line is
|
|
75
|
+
# the hook's only positive signal, so it is emitted on stdout alone.
|
|
76
|
+
def stdout_lines
|
|
77
|
+
return [] unless clean?
|
|
78
|
+
|
|
79
|
+
["svg-check: #{@path} clean (#{format('%.1f', @kilobytes)}KB)"]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
# The CLI shape of svg-check.py: returns the process exit code and
|
|
85
|
+
# writes the same lines to the same streams. Callers that want the
|
|
86
|
+
# findings as data should use .check instead.
|
|
87
|
+
def run(argv, stdout: $stdout, stderr: $stderr)
|
|
88
|
+
options = parse_argv(argv)
|
|
89
|
+
return usage_error(stderr, options[:error]) if options[:error]
|
|
90
|
+
|
|
91
|
+
if options[:help]
|
|
92
|
+
stdout.puts(USAGE)
|
|
93
|
+
return 0
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
report = check(options[:path], max_kb: options[:max_kb], strict: options[:strict])
|
|
97
|
+
report.stderr_lines.each { |line| stderr.puts(line) }
|
|
98
|
+
report.stdout_lines.each { |line| stdout.puts(line) }
|
|
99
|
+
report.exit_code
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def check(path, max_kb: DEFAULT_MAX_KB, strict: false)
|
|
103
|
+
unless File.exist?(path)
|
|
104
|
+
return Report.new(path: path, fatal: "missing file: #{path}", strict: strict)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
check_source(File.binread(path), path: path, max_kb: max_kb, strict: strict)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Same checks against an in-memory document. `path` only labels the
|
|
111
|
+
# output, so a caller with SVG bytes in hand (a web request, say) never
|
|
112
|
+
# has to round-trip through a tempfile.
|
|
113
|
+
def check_source(source, path:, max_kb: DEFAULT_MAX_KB, strict: false)
|
|
114
|
+
errors = []
|
|
115
|
+
warnings = []
|
|
116
|
+
kb = source.bytesize / 1024.0
|
|
117
|
+
|
|
118
|
+
if kb > max_kb
|
|
119
|
+
errors << "file is #{format('%.0f', kb)}KB, budget is #{format('%.0f', max_kb)}KB " \
|
|
120
|
+
"(run svgo, or reduce path precision)"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
root = parse_root(source)
|
|
124
|
+
if root.is_a?(String)
|
|
125
|
+
return Report.new(path: path, kilobytes: kb, fatal: root, strict: strict)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
errors << "root element is <#{root.name}>, expected <svg>" if root.name != "svg"
|
|
129
|
+
|
|
130
|
+
viewbox = parse_viewbox(root)
|
|
131
|
+
if viewbox.nil?
|
|
132
|
+
errors << "no viewBox on root — the art will not scale. " \
|
|
133
|
+
'Add viewBox="0 0 W H".'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
collect_root_findings(root, warnings)
|
|
137
|
+
scan_elements(root, viewbox, strict, errors, warnings)
|
|
138
|
+
|
|
139
|
+
Report.new(path: path, kilobytes: kb, errors: errors, warnings: warnings, strict: strict)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Rough absolute-coordinate sample. Ignores relative commands on
|
|
143
|
+
# purpose — we only want a cheap "is this wildly outside the canvas"
|
|
144
|
+
# signal, not a path interpreter.
|
|
145
|
+
def coords_in_path(data)
|
|
146
|
+
out = []
|
|
147
|
+
data.scan(PATH_SEGMENT) do |segment|
|
|
148
|
+
nums = segment[1..].scan(NUM).map(&:to_f)
|
|
149
|
+
case segment[0]
|
|
150
|
+
when "M", "L" then out.concat(stride_pairs(nums, 0, 1, 2)) if nums.length >= 2
|
|
151
|
+
when "C" then out.concat(stride_pairs(nums, 4, 5, 6))
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
out
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A viewBox needs exactly four numbers; anything else is treated as
|
|
158
|
+
# absent, matching the oracle (a 5-number viewBox is malformed anyway).
|
|
159
|
+
def parse_viewbox(root)
|
|
160
|
+
raw = plain_attribute(root, "viewBox")
|
|
161
|
+
return nil if raw.nil? || raw.empty?
|
|
162
|
+
|
|
163
|
+
parts = raw.scan(NUM).map(&:to_f)
|
|
164
|
+
parts.length == 4 ? parts : nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# Returns the root element, or a String describing the fatal parse
|
|
170
|
+
# failure. REXML happily returns a document with no root where
|
|
171
|
+
# ElementTree raises, so the empty case is folded in here.
|
|
172
|
+
def parse_root(source)
|
|
173
|
+
root = REXML::Document.new(source).root
|
|
174
|
+
return "not well-formed XML: no element found" if root.nil?
|
|
175
|
+
|
|
176
|
+
root
|
|
177
|
+
rescue REXML::ParseException => e
|
|
178
|
+
# Mirrors svg-check.py's `except ET.ParseError` — the run stops here
|
|
179
|
+
# rather than reporting on a half-built tree.
|
|
180
|
+
"not well-formed XML: #{e.message}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def collect_root_findings(root, warnings)
|
|
184
|
+
kids = []
|
|
185
|
+
root.elements.each { |child| kids << child.name }
|
|
186
|
+
|
|
187
|
+
unless kids.include?("title")
|
|
188
|
+
warnings << "no <title> as first child (screen readers, and it " \
|
|
189
|
+
"shows as a tooltip)"
|
|
190
|
+
end
|
|
191
|
+
warnings << 'missing role="img" on root' if plain_attribute(root, "role") != "img"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def scan_elements(root, viewbox, strict, errors, warnings)
|
|
195
|
+
ids = []
|
|
196
|
+
dupes = []
|
|
197
|
+
used_refs = []
|
|
198
|
+
ext_refs = []
|
|
199
|
+
|
|
200
|
+
each_element(root) do |el|
|
|
201
|
+
name = el.name
|
|
202
|
+
|
|
203
|
+
id = plain_attribute(el, "id")
|
|
204
|
+
if id && !id.empty?
|
|
205
|
+
if ids.include?(id)
|
|
206
|
+
dupes << id unless dupes.include?(id)
|
|
207
|
+
else
|
|
208
|
+
ids << id
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
classify_href(el, name, used_refs, ext_refs, errors)
|
|
213
|
+
|
|
214
|
+
el.attributes.each_attribute do |attribute|
|
|
215
|
+
match = /\Aurl\(#([^)]+)\)/.match(attribute.value.to_s.strip)
|
|
216
|
+
used_refs << match[1] if match
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
check_path(el, name, viewbox, warnings)
|
|
220
|
+
|
|
221
|
+
if name == "text" && strict
|
|
222
|
+
warnings << "<text> present — fonts are not guaranteed at " \
|
|
223
|
+
"render time. Convert to paths for distribution."
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
finish(ids, dupes, used_refs, ext_refs, errors)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def classify_href(el, name, used_refs, ext_refs, errors)
|
|
231
|
+
value = href(el)
|
|
232
|
+
return if value.nil? || value.empty?
|
|
233
|
+
|
|
234
|
+
if value.start_with?("#")
|
|
235
|
+
used_refs << value[1..]
|
|
236
|
+
elsif value.start_with?("http://", "https://", "//")
|
|
237
|
+
ext_refs << value
|
|
238
|
+
elsif value.start_with?("data:image") && name == "image"
|
|
239
|
+
errors << "embedded raster <image> — that is a PNG in a " \
|
|
240
|
+
"trenchcoat, not vector art"
|
|
241
|
+
elsif name == "image"
|
|
242
|
+
errors << "external <image> reference: #{value}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def check_path(el, name, viewbox, warnings)
|
|
247
|
+
return unless name == "path"
|
|
248
|
+
|
|
249
|
+
data = plain_attribute(el, "d")
|
|
250
|
+
return if data.nil? || data.empty?
|
|
251
|
+
|
|
252
|
+
check_path_bounds(data, viewbox, warnings) if viewbox
|
|
253
|
+
|
|
254
|
+
# Sub-pixel precision is invisible and triples file size; a handful
|
|
255
|
+
# of long decimals is noise, a hundred is a generator that forgot to
|
|
256
|
+
# round.
|
|
257
|
+
if data.scan(/\d+\.\d{5,}/).length > 20
|
|
258
|
+
warnings << "path data carries 5+ decimal places; round " \
|
|
259
|
+
"to 2-3 to cut file size ~40%"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def check_path_bounds(data, viewbox, warnings)
|
|
264
|
+
minx, miny, width, height = viewbox
|
|
265
|
+
pad = [width, height].max * 2
|
|
266
|
+
|
|
267
|
+
coords_in_path(data).each do |x, y|
|
|
268
|
+
next if minx - pad <= x && x <= minx + width + pad &&
|
|
269
|
+
miny - pad <= y && y <= miny + height + pad
|
|
270
|
+
|
|
271
|
+
warnings << "path coordinate (#{format('%g', x)},#{format('%g', y)}) is far outside the " \
|
|
272
|
+
"viewBox — likely a stray control point"
|
|
273
|
+
break
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def finish(ids, dupes, used_refs, ext_refs, errors)
|
|
278
|
+
errors << "external network references: #{ext_refs.first(3).join(', ')}" unless ext_refs.empty?
|
|
279
|
+
errors << "duplicate id(s): #{dupes.sort.join(', ')}" unless dupes.empty?
|
|
280
|
+
|
|
281
|
+
dangling = used_refs.uniq - ids
|
|
282
|
+
return if dangling.empty?
|
|
283
|
+
|
|
284
|
+
errors << "reference(s) to missing id(s): " \
|
|
285
|
+
"#{dangling.sort.join(', ')} — gradient or clipPath " \
|
|
286
|
+
"will silently render as black/none"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# ElementTree's iter() is a document-order walk that starts at the
|
|
290
|
+
# receiver; REXML's each_recursive is the same walk minus the receiver.
|
|
291
|
+
# Both yield elements only — comments and PIs are not visited.
|
|
292
|
+
def each_element(root, &block)
|
|
293
|
+
block.call(root)
|
|
294
|
+
root.each_recursive(&block)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# ElementTree's `el.get("id")` matches the attribute whose serialized
|
|
298
|
+
# name is exactly "id", i.e. the one in no namespace. REXML's
|
|
299
|
+
# Attributes#[] can fall through to a prefixed attribute of the same
|
|
300
|
+
# local name, and its Attribute#prefix inherits the element's prefix,
|
|
301
|
+
# so neither is a faithful stand-in — compare expanded_name directly.
|
|
302
|
+
def plain_attribute(el, name)
|
|
303
|
+
el.attributes.each_attribute do |attribute|
|
|
304
|
+
return attribute.value if attribute.expanded_name == name
|
|
305
|
+
end
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# The namespaced half of the same lookup: ElementTree keys these by
|
|
310
|
+
# namespace URI, so the document's choice of prefix must not matter.
|
|
311
|
+
def namespaced_attribute(el, uri, name)
|
|
312
|
+
el.attributes.each_attribute do |attribute|
|
|
313
|
+
prefix, separator, local = attribute.expanded_name.rpartition(":")
|
|
314
|
+
next if separator.empty? # unprefixed attributes are in no namespace
|
|
315
|
+
next unless local == name
|
|
316
|
+
|
|
317
|
+
return attribute.value if el.namespace(prefix) == uri
|
|
318
|
+
end
|
|
319
|
+
nil
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Python's `or` falls through on an empty string too, so an empty
|
|
323
|
+
# href="" still lets xlink:href be considered.
|
|
324
|
+
def href(el)
|
|
325
|
+
value = plain_attribute(el, "href")
|
|
326
|
+
return value unless value.nil? || value.empty?
|
|
327
|
+
|
|
328
|
+
namespaced_attribute(el, XLINK, "href")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Python's zip() stops at the shorter leg; Ruby's Array#zip pads the
|
|
332
|
+
# short one with nil, which would fabricate coordinates here.
|
|
333
|
+
def stride_pairs(nums, x_offset, y_offset, step)
|
|
334
|
+
xs = stride(nums, x_offset, step)
|
|
335
|
+
ys = stride(nums, y_offset, step)
|
|
336
|
+
Array.new([xs.length, ys.length].min) { |i| [xs[i], ys[i]] }
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def stride(nums, offset, step)
|
|
340
|
+
out = []
|
|
341
|
+
i = offset
|
|
342
|
+
while i < nums.length
|
|
343
|
+
out << nums[i]
|
|
344
|
+
i += step
|
|
345
|
+
end
|
|
346
|
+
out
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def parse_argv(argv)
|
|
350
|
+
options = { path: nil, max_kb: DEFAULT_MAX_KB, strict: false, help: false, error: nil }
|
|
351
|
+
rest = argv.dup
|
|
352
|
+
|
|
353
|
+
while (arg = rest.shift)
|
|
354
|
+
case arg
|
|
355
|
+
when "-h", "--help"
|
|
356
|
+
options[:help] = true
|
|
357
|
+
return options
|
|
358
|
+
when "--strict"
|
|
359
|
+
options[:strict] = true
|
|
360
|
+
when "--max-kb"
|
|
361
|
+
value = rest.shift
|
|
362
|
+
return options.merge(error: "argument --max-kb: expected one argument") if value.nil?
|
|
363
|
+
|
|
364
|
+
options[:max_kb] = parse_float(value) { |err| return options.merge(error: err) }
|
|
365
|
+
when /\A--max-kb=(.*)\z/m
|
|
366
|
+
value = Regexp.last_match(1)
|
|
367
|
+
options[:max_kb] = parse_float(value) { |err| return options.merge(error: err) }
|
|
368
|
+
else
|
|
369
|
+
if options[:path] || arg.start_with?("-")
|
|
370
|
+
return options.merge(error: "unrecognized arguments: #{arg}")
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
options[:path] = arg
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
return options.merge(error: "the following arguments are required: path") if options[:path].nil?
|
|
378
|
+
|
|
379
|
+
options
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Yields the argparse-shaped complaint rather than letting Float()'s
|
|
383
|
+
# own ArgumentError escape as a backtrace.
|
|
384
|
+
def parse_float(value)
|
|
385
|
+
Float(value)
|
|
386
|
+
rescue ArgumentError, TypeError
|
|
387
|
+
yield "argument --max-kb: invalid float value: '#{value}'"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# argparse exits 2 for a usage error; the hook only distinguishes zero
|
|
391
|
+
# from non-zero, but keeping 2 leaves that distinction available.
|
|
392
|
+
def usage_error(stderr, message)
|
|
393
|
+
stderr.puts(USAGE)
|
|
394
|
+
stderr.puts("lint: error: #{message}")
|
|
395
|
+
2
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|