herb-embedded 0.10.3.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 +7 -0
- data/exe/herb-lint-rb +7 -0
- data/js/host_shim.js +137 -0
- data/js/ruby_backend.js +204 -0
- data/lib/herb/embedded/adapters/mini_racer.rb +41 -0
- data/lib/herb/embedded/bridge.rb +126 -0
- data/lib/herb/embedded/bundle.rb +27 -0
- data/lib/herb/embedded/cli.rb +158 -0
- data/lib/herb/embedded/config.rb +51 -0
- data/lib/herb/embedded/custom_rule_loader.rb +95 -0
- data/lib/herb/embedded/diagnostic.rb +69 -0
- data/lib/herb/embedded/engine_adapter.rb +35 -0
- data/lib/herb/embedded/formatters/detailed.rb +29 -0
- data/lib/herb/embedded/formatters/github.rb +21 -0
- data/lib/herb/embedded/formatters/json.rb +34 -0
- data/lib/herb/embedded/formatters/simple.rb +28 -0
- data/lib/herb/embedded/formatters.rb +29 -0
- data/lib/herb/embedded/lint_result.rb +15 -0
- data/lib/herb/embedded/report.rb +40 -0
- data/lib/herb/embedded/result_envelope.rb +147 -0
- data/lib/herb/embedded/runner.rb +79 -0
- data/lib/herb/embedded/version.rb +7 -0
- data/lib/herb/embedded.rb +10 -0
- data/vendor/herb-linter.js +66574 -0
- metadata +92 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
require_relative "bundle"
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "bridge"
|
|
9
|
+
require_relative "runner"
|
|
10
|
+
require_relative "formatters"
|
|
11
|
+
require_relative "adapters/mini_racer"
|
|
12
|
+
|
|
13
|
+
module Herb
|
|
14
|
+
module Embedded
|
|
15
|
+
# The user-facing entry point (exe/herb-lint-rb). Flags mirror
|
|
16
|
+
# herb-lint; the -rb suffix lets both binaries coexist.
|
|
17
|
+
class CLI
|
|
18
|
+
EXIT_CLEAN = 0
|
|
19
|
+
EXIT_OFFENSES = 1
|
|
20
|
+
EXIT_CONFIG_ERROR = 2
|
|
21
|
+
|
|
22
|
+
VALID_FORMATS = %w[detailed simple json github].freeze
|
|
23
|
+
VALID_FAIL_LEVELS = %w[error warning info hint].freeze
|
|
24
|
+
|
|
25
|
+
HERB_YML_TEMPLATE = <<~YAML
|
|
26
|
+
version: "%<version>s"
|
|
27
|
+
files:
|
|
28
|
+
include:
|
|
29
|
+
- "**/*.html.erb"
|
|
30
|
+
- "**/*.herb"
|
|
31
|
+
exclude: []
|
|
32
|
+
linter:
|
|
33
|
+
fail_level: error
|
|
34
|
+
rules: {}
|
|
35
|
+
YAML
|
|
36
|
+
|
|
37
|
+
def initialize(argv, stdout:, stderr:)
|
|
38
|
+
@argv = argv.dup
|
|
39
|
+
@stdout = stdout
|
|
40
|
+
@stderr = stderr
|
|
41
|
+
@format = "detailed"
|
|
42
|
+
@fail_level = nil
|
|
43
|
+
@only = []
|
|
44
|
+
@fix = false
|
|
45
|
+
@unsafe = false
|
|
46
|
+
@mode = :lint
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def run
|
|
50
|
+
parser.parse!(@argv)
|
|
51
|
+
|
|
52
|
+
case @mode
|
|
53
|
+
when :help
|
|
54
|
+
@stdout.puts parser.help
|
|
55
|
+
EXIT_CLEAN
|
|
56
|
+
when :version
|
|
57
|
+
print_version
|
|
58
|
+
when :init
|
|
59
|
+
write_init_file
|
|
60
|
+
else
|
|
61
|
+
lint_and_report
|
|
62
|
+
end
|
|
63
|
+
rescue OptionParser::ParseError => e
|
|
64
|
+
@stderr.puts e.message
|
|
65
|
+
EXIT_CONFIG_ERROR
|
|
66
|
+
rescue Psych::SyntaxError => e
|
|
67
|
+
@stderr.puts "Invalid .herb.yml: #{e.message}"
|
|
68
|
+
EXIT_CONFIG_ERROR
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def parser
|
|
74
|
+
@parser ||= OptionParser.new do |opts|
|
|
75
|
+
opts.banner = "Usage: herb-lint-rb [options] [files...]"
|
|
76
|
+
|
|
77
|
+
define_fix_options(opts)
|
|
78
|
+
define_report_options(opts)
|
|
79
|
+
define_mode_options(opts)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def define_fix_options(opts)
|
|
84
|
+
opts.on("--fix", "Apply safe autocorrections") { @fix = true }
|
|
85
|
+
opts.on("--fix-unsafely", "Apply unsafe autocorrections too") do
|
|
86
|
+
@fix = true
|
|
87
|
+
@unsafe = true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def define_report_options(opts)
|
|
92
|
+
opts.on("--format FORMAT", VALID_FORMATS, "detailed | simple | json | github (default: detailed)") do |v|
|
|
93
|
+
@format = v
|
|
94
|
+
end
|
|
95
|
+
opts.on("--fail-level LEVEL", VALID_FAIL_LEVELS, "error | warning | info | hint") do |v|
|
|
96
|
+
@fail_level = v.to_sym
|
|
97
|
+
end
|
|
98
|
+
opts.on("--only RULE", "Run only this rule (repeatable)") { |v| @only << v }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def define_mode_options(opts)
|
|
102
|
+
opts.on("--init", "Write .herb.yml pinning the current linter version") { @mode = :init }
|
|
103
|
+
opts.on("--version", "Print herb-embedded and bundled @herb-tools/linter versions") { @mode = :version }
|
|
104
|
+
opts.on("-h", "--help", "Show this help") { @mode = :help }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def print_version
|
|
108
|
+
@stdout.puts "herb-embedded #{Herb::Embedded::VERSION} (bundled @herb-tools/linter #{Bundle.linter_version})"
|
|
109
|
+
EXIT_CLEAN
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def write_init_file
|
|
113
|
+
path = File.join(Dir.pwd, ".herb.yml")
|
|
114
|
+
File.write(path, format(HERB_YML_TEMPLATE, version: Bundle.linter_version))
|
|
115
|
+
@stdout.puts "Wrote #{path}"
|
|
116
|
+
EXIT_CLEAN
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def lint_and_report
|
|
120
|
+
root = Dir.pwd
|
|
121
|
+
config = Config.load(root)
|
|
122
|
+
bridge, runner = boot_bridge_and_runner(root, config)
|
|
123
|
+
|
|
124
|
+
unknown_rules = @only - bridge.rule_names
|
|
125
|
+
return report_unknown_rules(unknown_rules) if unknown_rules.any?
|
|
126
|
+
|
|
127
|
+
report = run_or_fix(runner)
|
|
128
|
+
@stdout.puts Formatters.fetch(@format.to_sym).render(report)
|
|
129
|
+
report.exit_code(fail_level: @fail_level || config.fail_level)
|
|
130
|
+
ensure
|
|
131
|
+
bridge&.dispose
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def boot_bridge_and_runner(root, config)
|
|
135
|
+
bridge = Bridge.new(adapter: Adapters::MiniRacer.new, bundle: Bundle).boot
|
|
136
|
+
runner = Runner.new(root: root, config: config, bridge: bridge)
|
|
137
|
+
runner.load_custom_rules!
|
|
138
|
+
[bridge, runner]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def report_unknown_rules(unknown_rules)
|
|
142
|
+
@stderr.puts "Unknown rule(s): #{unknown_rules.join(", ")}"
|
|
143
|
+
EXIT_CONFIG_ERROR
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def run_or_fix(runner)
|
|
147
|
+
paths = @argv.empty? ? nil : @argv
|
|
148
|
+
rules = @only.empty? ? nil : @only
|
|
149
|
+
|
|
150
|
+
if @fix
|
|
151
|
+
runner.fix(paths, rules: rules, unsafe: @unsafe)
|
|
152
|
+
else
|
|
153
|
+
runner.run(paths, rules: rules)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Herb
|
|
6
|
+
module Embedded
|
|
7
|
+
# Reads .herb.yml. The engine has no filesystem, so Ruby owns config
|
|
8
|
+
# entirely. Uses the same nested schema as upstream @herb-tools/config
|
|
9
|
+
# (only version, files.include/exclude, linter.fail_level, and
|
|
10
|
+
# linter.rules.<name>.enabled are read; other upstream keys are
|
|
11
|
+
# parsed as inert and ignored, not errored on).
|
|
12
|
+
class Config
|
|
13
|
+
CONFIG_FILENAME = ".herb.yml"
|
|
14
|
+
DEFAULT_INCLUDE_GLOBS = ["**/*.html.erb", "**/*.herb"].freeze
|
|
15
|
+
DEFAULT_EXCLUDE_GLOBS = [].freeze
|
|
16
|
+
DEFAULT_FAIL_LEVEL = :error
|
|
17
|
+
|
|
18
|
+
attr_reader :include_globs, :exclude_globs, :fail_level, :linter_version
|
|
19
|
+
|
|
20
|
+
def self.load(dir)
|
|
21
|
+
path = File.join(dir, CONFIG_FILENAME)
|
|
22
|
+
data = File.exist?(path) ? (YAML.load_file(path) || {}) : {}
|
|
23
|
+
|
|
24
|
+
new(data)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(data)
|
|
28
|
+
files = data["files"] || {}
|
|
29
|
+
linter = data["linter"] || {}
|
|
30
|
+
|
|
31
|
+
@include_globs = files["include"] || DEFAULT_INCLUDE_GLOBS.dup
|
|
32
|
+
@exclude_globs = files["exclude"] || DEFAULT_EXCLUDE_GLOBS.dup
|
|
33
|
+
@fail_level = (linter["fail_level"] || DEFAULT_FAIL_LEVEL).to_sym
|
|
34
|
+
@linter_version = data["version"]
|
|
35
|
+
@disabled_rule_names = disabled_rule_names(linter["rules"])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enabled_rule_names(all_rule_names)
|
|
39
|
+
all_rule_names - @disabled_rule_names
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def disabled_rule_names(rules)
|
|
45
|
+
(rules || {}).each_with_object([]) do |(name, rule_config), disabled|
|
|
46
|
+
disabled << name if rule_config.is_a?(Hash) && rule_config["enabled"] == false
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herb
|
|
4
|
+
module Embedded
|
|
5
|
+
# Loads project-local rules from .herb/rules/**/*.mjs. Upstream's own
|
|
6
|
+
# loader uses pathToFileURL, tinyglobby, and dynamic import() — none of
|
|
7
|
+
# which exist in an embedded engine. Ruby replaces the loader entirely:
|
|
8
|
+
# it globs and reads the files, rewrites their one supported import
|
|
9
|
+
# (@herb-tools/linter) into a destructure from the already-loaded
|
|
10
|
+
# bundle, and hands the result to the engine for registration.
|
|
11
|
+
class CustomRuleLoader
|
|
12
|
+
class UnsupportedImportError < StandardError; end
|
|
13
|
+
class InvalidRuleError < StandardError; end
|
|
14
|
+
|
|
15
|
+
RULES_GLOB = File.join(".herb", "rules", "**", "*.mjs")
|
|
16
|
+
ALLOWED_SPECIFIER = "@herb-tools/linter"
|
|
17
|
+
IMPORT_LINE = /^import\b.*$/
|
|
18
|
+
NAMED_IMPORT_LINE = /^import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["'];?\s*$/
|
|
19
|
+
EXPORT_DEFAULT_CLASS = /export\s+default\s+class\s+(\w+)/
|
|
20
|
+
|
|
21
|
+
def initialize(root:, bridge:)
|
|
22
|
+
@root = root
|
|
23
|
+
@bridge = bridge
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def load_all
|
|
27
|
+
Dir.glob(File.join(@root, RULES_GLOB)).map do |path|
|
|
28
|
+
rewritten = self.class.rewrite(File.read(path), path)
|
|
29
|
+
result = @bridge.register_custom_rule(rewritten, path)
|
|
30
|
+
|
|
31
|
+
if result["overrode"]
|
|
32
|
+
warn("Custom rule '#{result["ruleName"]}' at #{path} overrides a built-in rule of the same name")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
result["ruleName"]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Only a single supported specifier is rewritten: named imports from
|
|
40
|
+
# "@herb-tools/linter" become a destructure from the bundle-global
|
|
41
|
+
# HerbLinter. Anything else — a different specifier, or an import
|
|
42
|
+
# form other than named braces — fails loudly rather than producing
|
|
43
|
+
# a mysterious ReferenceError at rule-execution time.
|
|
44
|
+
def self.rewrite(source, path)
|
|
45
|
+
named_imports = extract_named_imports(source, path)
|
|
46
|
+
body = strip_import_lines(source)
|
|
47
|
+
body = rewrite_default_export(body, path)
|
|
48
|
+
destructure = named_imports.empty? ? "" : "const { #{named_imports.join(", ")} } = HerbLinter;\n"
|
|
49
|
+
|
|
50
|
+
<<~JS
|
|
51
|
+
(function () {
|
|
52
|
+
#{destructure}#{body}
|
|
53
|
+
return __herbCustomRule;
|
|
54
|
+
})()
|
|
55
|
+
JS
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.extract_named_imports(source, path)
|
|
59
|
+
named_imports = []
|
|
60
|
+
|
|
61
|
+
source.each_line do |line|
|
|
62
|
+
next unless line.match?(IMPORT_LINE)
|
|
63
|
+
|
|
64
|
+
match = line.match(NAMED_IMPORT_LINE)
|
|
65
|
+
raise UnsupportedImportError, "#{path}: unsupported import statement: #{line.strip}" unless match
|
|
66
|
+
|
|
67
|
+
names, specifier = match.captures
|
|
68
|
+
if specifier != ALLOWED_SPECIFIER
|
|
69
|
+
raise UnsupportedImportError,
|
|
70
|
+
"#{path}: unsupported import from \"#{specifier}\" (only \"#{ALLOWED_SPECIFIER}\" is supported)"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
named_imports.concat(names.split(",").map(&:strip).reject(&:empty?))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
named_imports
|
|
77
|
+
end
|
|
78
|
+
private_class_method :extract_named_imports
|
|
79
|
+
|
|
80
|
+
def self.strip_import_lines(source)
|
|
81
|
+
source.gsub(IMPORT_LINE, "")
|
|
82
|
+
end
|
|
83
|
+
private_class_method :strip_import_lines
|
|
84
|
+
|
|
85
|
+
def self.rewrite_default_export(body, path)
|
|
86
|
+
unless body.match?(EXPORT_DEFAULT_CLASS)
|
|
87
|
+
raise InvalidRuleError, "#{path}: no default export found; custom rules must use `export default class`"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
body.sub(EXPORT_DEFAULT_CLASS, 'const __herbCustomRule = class \1')
|
|
91
|
+
end
|
|
92
|
+
private_class_method :rewrite_default_export
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herb
|
|
4
|
+
module Embedded
|
|
5
|
+
# A single lint offense. Shaped to match upstream Herb::Diagnostic /
|
|
6
|
+
# Herb::LintOffense (marcoroth/herb#455, unmerged): #to_h's key set
|
|
7
|
+
# mirrors that class's #to_h exactly, so this drops into its socket
|
|
8
|
+
# without rewriting every consumer if/when that PR lands. #file,
|
|
9
|
+
# #line, #column, and #correctable? are this gem's own convenience
|
|
10
|
+
# accessors on top of that shape — upstream has no per-file scope
|
|
11
|
+
# (a single Herb.lint call is per-source) and derives correctability
|
|
12
|
+
# from a rule's static autocorrectable flag rather than a reader.
|
|
13
|
+
class Diagnostic
|
|
14
|
+
SEVERITIES = %i[error warning info hint].freeze
|
|
15
|
+
DEFAULT_SEVERITY = :error
|
|
16
|
+
|
|
17
|
+
attr_reader :file, :rule, :message, :severity, :location, :code, :source
|
|
18
|
+
|
|
19
|
+
# rubocop:disable Metrics/ParameterLists -- value object, one flat field per keyword
|
|
20
|
+
def initialize(file:, rule:, message:, severity:, location:, code: nil, source: nil, correctable: false)
|
|
21
|
+
@file = file
|
|
22
|
+
@rule = rule
|
|
23
|
+
@message = message
|
|
24
|
+
@severity = SEVERITIES.include?(severity) ? severity : DEFAULT_SEVERITY
|
|
25
|
+
@location = location
|
|
26
|
+
@code = code
|
|
27
|
+
@source = source
|
|
28
|
+
@correctable = correctable
|
|
29
|
+
end
|
|
30
|
+
# rubocop:enable Metrics/ParameterLists
|
|
31
|
+
|
|
32
|
+
def self.from_js(hash, file:)
|
|
33
|
+
new(
|
|
34
|
+
file: file,
|
|
35
|
+
rule: hash["rule"],
|
|
36
|
+
message: hash["message"],
|
|
37
|
+
severity: hash["severity"]&.to_sym,
|
|
38
|
+
location: hash["location"],
|
|
39
|
+
code: hash["code"],
|
|
40
|
+
source: hash["source"],
|
|
41
|
+
correctable: !hash["autofixContext"].nil?,
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def line
|
|
46
|
+
location&.dig("start", "line")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def column
|
|
50
|
+
location&.dig("start", "column")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def correctable?
|
|
54
|
+
@correctable
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_h
|
|
58
|
+
{
|
|
59
|
+
message: message,
|
|
60
|
+
location: location,
|
|
61
|
+
severity: severity,
|
|
62
|
+
code: code,
|
|
63
|
+
source: source,
|
|
64
|
+
rule: rule,
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herb
|
|
4
|
+
module Embedded
|
|
5
|
+
# Port between Ruby and whichever JavaScript engine executes Herb's
|
|
6
|
+
# rule code. Concrete engines (MiniRacer, and later QuickJS/Wasmtime)
|
|
7
|
+
# implement this interface; callers depend only on it.
|
|
8
|
+
class EngineAdapter
|
|
9
|
+
# Marks a Ruby string as binary data that must cross the engine
|
|
10
|
+
# boundary as a byte array (e.g. a Uint8Array), not a JS string,
|
|
11
|
+
# since arbitrary bytes are not valid UTF-8.
|
|
12
|
+
Binary = Struct.new(:raw)
|
|
13
|
+
|
|
14
|
+
def self.binary(bytes)
|
|
15
|
+
Binary.new(bytes)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def load(source)
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #load"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def attach(name, &)
|
|
23
|
+
raise NotImplementedError, "#{self.class} must implement #attach"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(function, *args)
|
|
27
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def dispose
|
|
31
|
+
raise NotImplementedError, "#{self.class} must implement #dispose"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herb
|
|
4
|
+
module Embedded
|
|
5
|
+
module Formatters
|
|
6
|
+
# Offenses grouped by file, with correctable offenses flagged.
|
|
7
|
+
class Detailed
|
|
8
|
+
def render(report)
|
|
9
|
+
report.diagnostics
|
|
10
|
+
.group_by(&:file)
|
|
11
|
+
.flat_map { |file, diagnostics| render_file(file, diagnostics) }
|
|
12
|
+
.join("\n")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def render_file(file, diagnostics)
|
|
18
|
+
["#{file}:"] + diagnostics.map { |diagnostic| render_diagnostic(diagnostic) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render_diagnostic(diagnostic)
|
|
22
|
+
flag = diagnostic.correctable? ? " [correctable]" : ""
|
|
23
|
+
" #{diagnostic.line}:#{diagnostic.column} #{diagnostic.severity} " \
|
|
24
|
+
"#{diagnostic.message} (#{diagnostic.rule})#{flag}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herb
|
|
4
|
+
module Embedded
|
|
5
|
+
module Formatters
|
|
6
|
+
# One GitHub Actions annotation line per offense:
|
|
7
|
+
# ::error file=...,line=...,col=...::message
|
|
8
|
+
class Github
|
|
9
|
+
def render(report)
|
|
10
|
+
report.diagnostics.map { |diagnostic| annotation(diagnostic) }.join("\n")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def annotation(diagnostic)
|
|
16
|
+
"::error file=#{diagnostic.file},line=#{diagnostic.line},col=#{diagnostic.column}::#{diagnostic.message}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Herb
|
|
6
|
+
module Embedded
|
|
7
|
+
module Formatters
|
|
8
|
+
# { offenses: [...], summary: { files_checked, offenses, correctable } }
|
|
9
|
+
class Json
|
|
10
|
+
def render(report)
|
|
11
|
+
{
|
|
12
|
+
offenses: report.diagnostics.map { |diagnostic| offense_hash(diagnostic) },
|
|
13
|
+
summary: {
|
|
14
|
+
files_checked: report.files_checked,
|
|
15
|
+
offenses: report.diagnostics.size,
|
|
16
|
+
correctable: report.correctable_count,
|
|
17
|
+
},
|
|
18
|
+
}.to_json
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def offense_hash(diagnostic)
|
|
24
|
+
diagnostic.to_h.merge(
|
|
25
|
+
file: diagnostic.file,
|
|
26
|
+
line: diagnostic.line,
|
|
27
|
+
column: diagnostic.column,
|
|
28
|
+
correctable: diagnostic.correctable?,
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herb
|
|
4
|
+
module Embedded
|
|
5
|
+
module Formatters
|
|
6
|
+
# One "file:line:col severity message (rule)" line per offense, plus
|
|
7
|
+
# a summary line.
|
|
8
|
+
class Simple
|
|
9
|
+
def render(report)
|
|
10
|
+
lines = report.diagnostics.map { |diagnostic| format_diagnostic(diagnostic) }
|
|
11
|
+
lines << summary(report)
|
|
12
|
+
lines.join("\n")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def format_diagnostic(diagnostic)
|
|
18
|
+
"#{diagnostic.file}:#{diagnostic.line}:#{diagnostic.column} " \
|
|
19
|
+
"#{diagnostic.severity} #{diagnostic.message} (#{diagnostic.rule})"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def summary(report)
|
|
23
|
+
"#{report.files_checked} files checked, #{report.diagnostics.size} offenses"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formatters/simple"
|
|
4
|
+
require_relative "formatters/detailed"
|
|
5
|
+
require_relative "formatters/json"
|
|
6
|
+
require_relative "formatters/github"
|
|
7
|
+
|
|
8
|
+
module Herb
|
|
9
|
+
module Embedded
|
|
10
|
+
# Formatters are pure functions of a Report: #render(report) -> String.
|
|
11
|
+
module Formatters
|
|
12
|
+
REGISTRY = {
|
|
13
|
+
simple: Simple,
|
|
14
|
+
detailed: Detailed,
|
|
15
|
+
json: Json,
|
|
16
|
+
github: Github,
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def fetch(name)
|
|
22
|
+
formatter_class = REGISTRY[name]
|
|
23
|
+
raise ArgumentError, "Unknown formatter: #{name.inspect}" unless formatter_class
|
|
24
|
+
|
|
25
|
+
formatter_class.new
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herb
|
|
4
|
+
module Embedded
|
|
5
|
+
# The diagnostics produced for a single file.
|
|
6
|
+
class LintResult
|
|
7
|
+
attr_reader :file, :diagnostics
|
|
8
|
+
|
|
9
|
+
def initialize(file:, diagnostics: [])
|
|
10
|
+
@file = file
|
|
11
|
+
@diagnostics = diagnostics
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herb
|
|
4
|
+
module Embedded
|
|
5
|
+
# Aggregates LintResults across a run.
|
|
6
|
+
class Report
|
|
7
|
+
SEVERITY_RANK = { error: 3, warning: 2, info: 1, hint: 0 }.freeze
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@lint_results = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add(lint_result)
|
|
14
|
+
@lint_results << lint_result
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def diagnostics
|
|
18
|
+
@lint_results.flat_map(&:diagnostics)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def files_checked
|
|
22
|
+
@lint_results.size
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def files_with_offenses
|
|
26
|
+
@lint_results.select { |result| result.diagnostics.any? }.map(&:file).uniq.size
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def correctable_count
|
|
30
|
+
diagnostics.count(&:correctable?)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def exit_code(fail_level:)
|
|
34
|
+
threshold = SEVERITY_RANK.fetch(fail_level)
|
|
35
|
+
reached = diagnostics.any? { |diagnostic| SEVERITY_RANK.fetch(diagnostic.severity) >= threshold }
|
|
36
|
+
reached ? 1 : 0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|