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.
@@ -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