Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,42 @@
# herb-embedded Changelog

Follows [Semantic Versioning](https://semver.org/). Gem version mirrors the vendored
`@herb-tools/linter` version plus a gem-side segment: `0.10.3.N` vendors `@herb-tools/linter@0.10.3`.
`@herb-tools/linter` version plus a gem-side segment: `0.11.1.N` vendors `@herb-tools/linter@0.11.1`.

---

## unreleased

(nothing yet)
### Changed

* Upgraded the vendored `@herb-tools/linter` from 0.10.3 to 0.11.1 (and the `herb` gem
dependency to `~> 0.11.1`). The bundle now registers 170 rules, up from 100, including the
new Action View, ERB, HTML, SVG, Turbo, and UJS rules and the `herb-*` directive rules.
* `.herb.yml`'s top-level `framework` key is now read and passed to the engine. Rules scoped
to Action View only run when it is `actionview`; omitting it keeps upstream's `ruby`
default (and the informational `herb-config-framework-option` diagnostic).
* Explicit rule selection (`--only` / `Bridge#lint(rules:)`) now mirrors upstream's `--only`:
it bypasses framework, environment, and path filters, since naming a rule is itself the
answer to whether it should run.
* `ResultEnvelope` forwards the new parser options the 0.11 rule set uses:
`herb_directives`, `iteration_nodes`, and `erb_openers`.
* The bundle now includes `postcss` for the CSS/scoped-style rules. Its Node-only source-map
and terminal-color paths are replaced at build time with filesystem-free stubs
(`stubs/fs.mjs`, `stubs/path.mjs`, etc.), keeping the runtime free of Node built-ins.

### Fixed

* Added the `defaultERBOpenings` callback required by `HerbBackend` in Herb 0.11.

### Conformance

* Added a fixture for every newly registered rule (170 total).
* Two fixtures are marked pending rather than compared: `actionview-no-strict-locals-error`
and `actionview-no-mistyped-locals` need a project-wide partial index that the standalone
conformance harness does not build. `actionview-no-void-element-content` and
`erb-no-javascript-tag-helper` are asserted on the bridge only because upstream 0.11.1's
CLI computes parser options before merging the config's framework, so it cannot trigger
them even with `framework: actionview`.

---

Expand Down
8 changes: 4 additions & 4 deletions CHARTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## What this is

herb-embedded runs [Herb](https://herb-tools.dev)'s real HTML+ERB lint rules — all 107 of
herb-embedded runs [Herb](https://herb-tools.dev)'s real HTML+ERB lint rules — all 170 of
them, spanning accessibility, Action View, ERB, HTML, SVG, Turbo, and UJS, plus autofix —
in a pure Ruby process, with no Node.js required at runtime. It does this by vendoring
Herb's rule engine as JavaScript and executing it in-process through a pluggable embedded
Expand Down Expand Up @@ -48,7 +48,7 @@ already sitting in the `herb` gem's native C extension the whole time.

## Out of scope

- **Porting rules to Ruby.** The 107 rules are imperative TypeScript, not data. Porting them
- **Porting rules to Ruby.** The 170 rules are imperative TypeScript, not data. Porting them
forks the rule set the day it ships and it diverges from there.
- **`herb format`.** The formatter is a separate package with its own semantics, and
formatter bugs are more visible than linter bugs because they rewrite whole files.
Expand Down Expand Up @@ -116,7 +116,7 @@ already sitting in the `herb` gem's native C extension the whole time.
gates nothing under 0.x semver. `Bundle` records the exact `herb` gem versions it was
validated against; `Bridge` checks at boot against that set.
- **Gem version mirrors the vendored linter version, plus a gem-side segment:**
`herb-embedded 0.10.3.N` vendors `@herb-tools/linter@0.10.3`. `Gemfile.lock` then answers
`herb-embedded 0.11.1.N` vendors `@herb-tools/linter@0.11.1`. `Gemfile.lock` then answers
"which rules do I have" unambiguously.
- **Namespace `Herb::Embedded`; executable `herb-lint-rb`.** The `-rb` suffix lets it coexist
with the npm `herb-lint` binary rather than shadowing it.
Expand Down Expand Up @@ -164,7 +164,7 @@ already sitting in the `herb` gem's native C extension the whole time.
`prism_program`/`prism_nodes`, see the Shape decisions entry above) are resolved; each had
a named cause tracked as implementation work in `bd` (`herb-embedded-6dp` and related
beads), not a design gap. `spec/conformance/conformance_spec.rb` now runs the differential
offense-for-offense diff against a hand-written fixture for every one of the 100 rules the
offense-for-offense diff against a hand-written fixture for every one of the 170 rules the
vendored bundle registers (`herb-embedded-ag7`), gated so an upstream rule with no fixture
fails the build — not just the 5 general-purpose fixtures from before. `.herb.yml` config
application is unaffected by that work and still worth a look if it's ever suspected of
Expand Down
11 changes: 6 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,12 @@ Two shapes cross the Ruby/JS boundary, for two different reasons:
self-describing versioned binary format, wrapped as `MiniRacer::Binary` so
the adapter marshals it as a `Uint8Array`.

`Bridge` currently attaches **six** callbacks — `rbParse`, `rbLex`,
`rbExtractRuby`, `rbExtractHTML`, `rbParseRuby`, `rbVersion` — one per
`HerbBackend` method the bundle calls, not just the two (`parse`/`parseRuby`)
that matter for rule execution. The other three exist because `HerbBackend`'s
interface requires them, not because more rules depend on them.
`Bridge` currently attaches **seven** callbacks — `rbParse`, `rbLex`,
`rbExtractRuby`, `rbExtractHTML`, `rbDefaultERBOpenings`, `rbParseRuby`,
`rbVersion` — one per `HerbBackend` method the bundle calls, not just the two
(`parse`/`parseRuby`) that matter for rule execution. The other five exist
because `HerbBackend`'s interface requires them, not because more rules depend
on them.

### Extension points

Expand Down
34 changes: 17 additions & 17 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
PATH
remote: .
specs:
herb-embedded (0.10.3.0)
herb (~> 0.10.3)
herb-embedded (0.11.1.0)
herb (~> 0.11.1)
mini_racer (~> 0.22)

GEM
Expand All @@ -13,13 +13,13 @@ GEM
bundler (>= 1.2.0)
thor (~> 1.0)
diff-lcs (1.6.2)
herb (0.10.3)
herb (0.10.3-aarch64-linux-gnu)
herb (0.10.3-aarch64-linux-musl)
herb (0.10.3-arm64-darwin)
herb (0.10.3-x86_64-darwin)
herb (0.10.3-x86_64-linux-gnu)
herb (0.10.3-x86_64-linux-musl)
herb (0.11.1)
herb (0.11.1-aarch64-linux-gnu)
herb (0.11.1-aarch64-linux-musl)
herb (0.11.1-arm64-darwin)
herb (0.11.1-x86_64-darwin)
herb (0.11.1-x86_64-linux-gnu)
herb (0.11.1-x86_64-linux-musl)
json (2.21.2)
language_server-protocol (3.17.0.6)
libv8-node (24.12.0.1)
Expand Down Expand Up @@ -104,14 +104,14 @@ CHECKSUMS
bundler (4.0.16) sha256=d6ca5dd440c24f9abce9844cf44cc8e18c6a553de65a47efb4544137af92c47d
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
herb (0.10.3) sha256=da76e8bfbb302586ee51a4fb608624f48218fbfea198a9493540b42f7654024b
herb (0.10.3-aarch64-linux-gnu) sha256=1eaea87e6577557492a1b0ddaae989fba8a8ddd382e888ee5f939ce6ded06eb8
herb (0.10.3-aarch64-linux-musl) sha256=4ac7ccd194f8f284ffc7bf093676f5194cf0f44ea4a7d3888eac095e81c43e1f
herb (0.10.3-arm64-darwin) sha256=1a5bef57d925d775496328e7a01a9c1260e1d83bed8038b60a4f8c81b12c15a3
herb (0.10.3-x86_64-darwin) sha256=e02e43b3bb1977f4fd08f3330b5f2aa3292ca2d572a8bd59688eca8b79725f46
herb (0.10.3-x86_64-linux-gnu) sha256=cbb49b66f2bdc92d17109d1ea9452e88ba6a8ae52e9ddd5279deff83dcc4c66d
herb (0.10.3-x86_64-linux-musl) sha256=c84482d6b57f13c2e1a0fa029649efb0e5fdc876a3fd564bae09cf42bd4d5ae1
herb-embedded (0.10.3.0)
herb (0.11.1) sha256=7a5a74c4931b341a2b7b7f5a530616801f5721a633d2655f002156ff553cd2d7
herb (0.11.1-aarch64-linux-gnu) sha256=5484e0fc0bd5d040380cbbf503e86dccf3fc1d33428b7ad02715ee7dcaa018f9
herb (0.11.1-aarch64-linux-musl) sha256=1113663d5e7832dad601547edb4606557ac84def516479ee5136fc7072747a58
herb (0.11.1-arm64-darwin) sha256=c5d3c1d5f88a504bcfa6a4bb52f56650dc40345e23cdf051b02f3390cf184d9e
herb (0.11.1-x86_64-darwin) sha256=f73cad010299bf998bd4ead676b80ae2c1f0ff5487a2ef852e8d83652f74ced0
herb (0.11.1-x86_64-linux-gnu) sha256=e3104c5dab07300a2ea6fc3898014cf57db978fbf09f5554a2a6ef6c46f973f8
herb (0.11.1-x86_64-linux-musl) sha256=0c687b667b612e7cc2403e9991ee7c8e8d10cfef026cf41165f9d99284630b09
herb-embedded (0.11.1.0)
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
libv8-node (24.12.0.1) sha256=d93d23b861bfe5de3ba829e34a142402fb83b4c3592dd58d9ac4e027588d739a
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# herb-embedded

Lints ERB templates with [Herb](https://herb-tools.dev)'s actual rule set — 100 built-in
Lints ERB templates with [Herb](https://herb-tools.dev)'s actual rule set — 170 built-in
rules spanning accessibility, Action View, ERB, HTML, SVG, and Turbo markup, plus autofix —
as a plain Ruby dependency. Nothing shells out to Node while it runs.

Expand Down Expand Up @@ -77,7 +77,7 @@ Exit codes: `0` clean, `1` offenses found at or above the fail level, `2` a conf
`bundle exec herb-lint-rb --init` writes a starter file:

```yaml
version: "0.10.3"
version: "0.11.1"
files:
include:
- "**/*.html.erb"
Expand All @@ -92,6 +92,9 @@ linter:
informational, not enforced.
- `files.include` / `files.exclude` are glob lists (relative to the directory `herb-lint-rb`
runs from) that control which files a no-argument run discovers.
- `framework` tells the engine which framework's helpers and conventions to assume
(`ruby`, `actionview`, `hanami`, or `sinatra`). It defaults to `ruby` when absent;
Action View-specific rules only run when it is set to `actionview`.
- `linter.fail_level` sets the default minimum severity that produces a non-zero exit code
(overridable per-run with `--fail-level`).
- `linter.rules.<rule-name>.enabled: false` disables a built-in rule:
Expand All @@ -103,8 +106,9 @@ linter:
enabled: false
```

Only these keys are read; any other key from upstream `@herb-tools/config`'s schema is parsed
but ignored, not an error.
Only these keys (`version`, `framework`, `files.include`, `files.exclude`,
`linter.fail_level`, and `linter.rules`) are read; any other key from upstream
`@herb-tools/config`'s schema is parsed but ignored, not an error.

## Custom rules

Expand Down
2 changes: 1 addition & 1 deletion herb-embedded.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ Gem::Specification.new do |spec|
"rubygems_mfa_required" => "true",
}

spec.add_dependency "herb", "~> 0.10.3"
spec.add_dependency "herb", "~> 0.11.1"
spec.add_dependency "mini_racer", "~> 0.22"
end
26 changes: 9 additions & 17 deletions js/entry.mjs
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
// Bundle entry point for vendor/herb-linter.js. Imports directly from
// dist/linter.js and dist/rules.js, bypassing @herb-tools/linter's
// package exports map — its exports map only exposes "." (the CLI's
// composed entry point, which pulls in cli.js), "./cli", and "./loader",
// none of which fit. Going straight to the dist files excludes the
// three files with Node built-in references: cli.js, lint-worker.js,
// and custom-rule-loader.js.
export * from "../node_modules/@herb-tools/linter/dist/linter.js";
export * from "../node_modules/@herb-tools/linter/dist/rules.js";

// ParserRule/LexerRule/SourceRule (the base classes custom rules extend)
// live in dist/types.js, which dist/linter.js only partially imports
// (DEFAULT_RULE_CONFIG) without re-exporting the rest. types.js has no
// imports of its own — zero risk of pulling in Node builtins, unlike the
// package's main "." export (dist/index.js), which unconditionally
// imports "fs" and "path".
export * from "../node_modules/@herb-tools/linter/dist/types.js";
// dist/index.js, bypassing @herb-tools/linter's package exports map —
// "." resolves to that same file in 0.11, but the public entry point is
// the only stable path across releases. The CLI and lint worker (the
// files full of Node built-in imports) live in dist/cli.js and
// dist/lint-worker.js, which this does not touch; the remaining Node
// references come from postcss and are replaced by build-time stubs (see
// rakelib/bundle.rake).
export * from "../node_modules/@herb-tools/linter/dist/index.js";

// @herb-tools/core's package exports map correctly exposes its ESM entry
// (unlike @herb-tools/linter's), so this needs no exports-map bypass. It's
// already present in the bundle transitively (dist/linter.js imports from
// already present in the bundle transitively (dist/index.js imports from
// it internally); re-exporting it by the same specifier lets esbuild
// resolve both to the same module instance rather than duplicating it.
// HerbBackend, ParseResult, LexResult, and friends aren't otherwise
Expand Down
44 changes: 39 additions & 5 deletions js/ruby_backend.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Wires the vendored @herb-tools/linter bundle to Ruby. Must load after
// vendor/herb-linter.js and after the six rbXxx callbacks below are
// vendor/herb-linter.js and after the seven rbXxx callbacks below are
// attached (Bridge#boot handles the ordering).
//
// HerbBackend (from @herb-tools/core) must be subclassed: its `version`
Expand All @@ -18,6 +18,9 @@ var libHerbBackend = {
extractHTML: function (source) {
return rbExtractHTML(source);
},
defaultERBOpenings: function () {
return rbDefaultERBOpenings();
},
parseRuby: function (source) {
return rbParseRuby(source);
},
Expand Down Expand Up @@ -98,6 +101,22 @@ function __herbRuleNames() {
});
}

// Rule metadata the Ruby side needs to reproduce upstream's framework
// scoping (e.g. so the conformance harness can lint an Action View rule
// under `framework: actionview`, the only framework it answers in).
function __herbRuleFrameworks() {
return HerbLinter.rules.map(function (ruleClass) {
var config;
try {
config = new ruleClass().defaultConfig;
} catch (e) {
config = null;
}

return { name: ruleClass.ruleName, frameworks: (config && config.frameworks) || null };
});
}

// Rule selection happens before execution: Linter's constructor takes an
// explicit rules array, so an unselected rule is never instantiated or
// checked, not merely filtered out of the offenses it already produced.
Expand All @@ -119,6 +138,19 @@ function __herbSelectRules(ruleNames) {
});
}

// Mirrors Linter.from({ only }): naming rules explicitly must bypass the
// framework/environment/path filters that apply to a normal default run.
// Without this, `--only actionview-no-silent-render` stays silent because
// the context has no framework (so it defaults to "ruby") even though the
// caller asked for that rule by name.
function __herbApplyExplicitSelection(linter, ruleNames) {
if (ruleNames && ruleNames.length) {
linter.onlyRules = Array.from(new Set(ruleNames));
}

return linter;
}

// Registers a custom rule from Ruby-rewritten source (see
// CustomRuleLoader.rewrite): an IIFE expression that destructures its
// base class from HerbLinter and returns the rule class. A rule sharing
Expand Down Expand Up @@ -158,10 +190,11 @@ function __herbRegisterCustomRule(rewrittenSource, path) {
// it walks offenses from every selected rule together, unlike #lint's
// per-rule crash isolation). The parse-doesn't-break safety check lives
// on the Ruby side (Bridge#autofix), since it just needs Herb.parse.
function __herbAutofix(source, file, ruleNames, includeUnsafe) {
function __herbAutofix(source, file, ruleNames, includeUnsafe, framework) {
var ruleClasses = __herbSelectRules(ruleNames);
var context = { fileName: file, filename: file };
var context = { fileName: file, filename: file, framework: framework || undefined };
var linter = new HerbLinter.Linter(__herbEmbeddedBridge.instance, ruleClasses, undefined, HerbLinter.rules);
__herbApplyExplicitSelection(linter, ruleNames);
var result = linter.autofix(source, context, undefined, { includeUnsafe: !!includeUnsafe });

return JSON.stringify({ source: result.source, fixed: result.fixed });
Expand All @@ -180,14 +213,15 @@ function __herbAutofix(source, file, ruleNames, includeUnsafe) {
// left validRuleNames scoped to whatever single rule __herbLint happened
// to be running, so the rule silently never matched anything outside
// itself. Caught by conformance fixture coverage (herb-embedded-ag7).
function __herbLint(source, file, ruleNames) {
function __herbLint(source, file, ruleNames, framework) {
var ruleClasses = __herbSelectRules(ruleNames);
var context = { fileName: file, filename: file };
var context = { fileName: file, filename: file, framework: framework || undefined };
var offenses = [];

ruleClasses.forEach(function (ruleClass) {
try {
var linter = new HerbLinter.Linter(__herbEmbeddedBridge.instance, [ruleClass], undefined, HerbLinter.rules);
__herbApplyExplicitSelection(linter, ruleNames);
var result = linter.lint(source, context);
offenses = offenses.concat(result.offenses);
} catch (e) {
Expand Down
28 changes: 23 additions & 5 deletions lib/herb/embedded/bridge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
module Herb
module Embedded
# Boots the JS engine: loads the host shim and vendored bundle,
# attaches the six Ruby callbacks HerbBackend needs, then loads the
# attaches the seven Ruby callbacks HerbBackend needs, then loads the
# RubyBackend subclass and waits for its async init to resolve.
class Bridge
# Rescuing Herb::Embedded::Error catches any of these without also
Expand Down Expand Up @@ -68,9 +68,18 @@ def rule_names
@adapter.call("__herbRuleNames")
end

def lint(source, file:, rules: nil)
# Rule name => list of frameworks the rule is scoped to (nil when
# it applies everywhere). Used by conformance to lint each rule the
# way upstream looks at it, and by callers that need to know
# whether a rule only answers under `framework: actionview`.
def rule_frameworks
ensure_booted!
offenses = JSON.parse(@adapter.call("__herbLint", source, file, rules))
@adapter.call("__herbRuleFrameworks").to_h { |entry| [entry["name"], entry["frameworks"]] }
end

def lint(source, file:, rules: nil, framework: nil)
ensure_booted!
offenses = JSON.parse(@adapter.call("__herbLint", source, file, rules, framework))
offenses.map { |offense| Diagnostic.from_js(offense, file: file) }
end

Expand All @@ -83,9 +92,9 @@ def register_custom_rule(source, path)
# parse into a user's working tree. If the fixed output fails to
# parse while the original input parsed cleanly, the fix is
# discarded rather than trusted.
def autofix(source, file:, rules: nil, unsafe: false)
def autofix(source, file:, rules: nil, unsafe: false, framework: nil)
ensure_booted!
raw = JSON.parse(@adapter.call("__herbAutofix", source, file, rules, unsafe))
raw = JSON.parse(@adapter.call("__herbAutofix", source, file, rules, unsafe, framework))

if source_parses?(source) && !source_parses?(raw["source"])
return { source: source, applied: [], discarded: true }
Expand All @@ -106,10 +115,19 @@ def ensure_booted!
end

def attach_callbacks
attach_parse_callbacks
attach_ruby_callbacks
end

def attach_parse_callbacks
@adapter.attach("rbParse") { |source, options| ResultEnvelope.parse(source, options || {}) }
@adapter.attach("rbLex") { |source| ResultEnvelope.lex(source) }
@adapter.attach("rbExtractRuby") { |source, options| ::Herb.extract_ruby(source, **symbolize(options)) }
@adapter.attach("rbExtractHTML") { |source| ::Herb.extract_html(source) }
end

def attach_ruby_callbacks
@adapter.attach("rbDefaultERBOpenings") { ::Herb.default_erb_openings }
@adapter.attach("rbParseRuby") { |source| EngineAdapter.binary(::Prism.dump(source)) }
@adapter.attach("rbVersion") { ::Herb.version }
end
Expand Down
4 changes: 2 additions & 2 deletions lib/herb/embedded/bundle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ module Herb
module Embedded
# The vendored @herb-tools/linter JS bundle (see rakelib/bundle.rake).
module Bundle
LINTER_VERSION = "0.10.3"
HERB_VERSIONS = ["0.10.3"].freeze
LINTER_VERSION = "0.11.1"
HERB_VERSIONS = ["0.11.1"].freeze

VENDOR_PATH = File.expand_path("../../../vendor/herb-linter.js", __dir__)

Expand Down
Loading