diff --git a/CHARTER.md b/CHARTER.md index ea76fbc..d93efad 100644 --- a/CHARTER.md +++ b/CHARTER.md @@ -74,6 +74,26 @@ already sitting in the `herb` gem's native C extension the whole time. every future adapter. Prism-backed rules need real walkable `PrismNode` objects, not JSON, so `Prism.dump`'s self-describing versioned binary format crosses as a `Uint8Array` via `MiniRacer::Binary` — verified byte-accurate including non-ASCII source. + - **`prism_program` rules are wired; `prism_nodes`/`prism_nodes_deep` rules are not (yet).** + 12 rules in the vendored bundle declare which Prism mode they need via `parserOptions`: + `erb-no-debug-output` and `erb-no-instance-variables-in-partials` + ask for `prism_program` (one whole-document Prism parse, attached to the root + `DocumentNode`); the other 10 (`a11y-no-autofocus-attribute`, + `actionview-no-silent-helper`, `actionview-no-unnecessary-tag-attributes`, + `erb-no-output-in-attribute-position`, `erb-no-silent-statement`, `erb-no-unsafe-raw`, + `erb-no-unsafe-script-interpolation`, `erb-no-unused-expressions`, + `erb-no-unused-literals`, `erb-prefer-direct-output`) ask for `prism_nodes` (a separate + Prism parse per embedded-Ruby node). `ResultEnvelope.parse` implements the `prism_program` + case: it never asks `Herb.parse` itself to embed `prism_node` (that comes back as a raw + ASCII-8BIT String and blows up `.to_json`, the bug `FORWARDABLE_OPTIONS` still guards + against); instead it separately computes `Prism.dump(Herb.extract_ruby(source)).bytes` — a + plain JSON-safe `Integer` array — and injects it onto the root value's `prism_node` key. + `Herb.extract_ruby` blanks non-Ruby content but preserves byte length/position, so the + resulting Prism byte offsets still line up with `DocumentNode#prismNode`'s use of the + original (whole-file) `source`. The `prism_nodes`/`prism_nodes_deep` case — injection onto + individual `ERBContentNode`s rather than the document root — needs its own design pass + (which nodes carry embedded Ruby, how offsets there compose) and remains unimplemented; see + herb-embedded-gu7. - **Exceptions cross the boundary raw, not as structured errors.** An earlier design assumed exceptions needed to be caught and translated before crossing. Sixteen probes showed raw crossing is catchable, non-poisoning, and preserves the original Ruby class in the outward diff --git a/lib/herb/embedded/result_envelope.rb b/lib/herb/embedded/result_envelope.rb index a05b579..c9796d2 100644 --- a/lib/herb/embedded/result_envelope.rb +++ b/lib/herb/embedded/result_envelope.rb @@ -2,6 +2,7 @@ require "herb" require "json" +require "prism" module Herb module Embedded @@ -11,9 +12,12 @@ module Embedded module ResultEnvelope # Allowlist of Herb::ParserOptions keys safe to forward from # caller-supplied options. Deliberately excludes prism_nodes, - # prism_nodes_deep, and prism_program: Prism data crosses the - # engine boundary as binary (Uint8Array), and forwarding it here - # raises JSON::GeneratorError on ASCII-8BIT content. Also excludes + # prism_nodes_deep, and prism_program: asking Herb.parse itself to + # embed prism_node populates it with a raw ASCII-8BIT String, and + # forwarding that through raises JSON::GeneratorError. prism_program + # is instead handled below by computing a JSON-safe byte array + # ourselves; prism_nodes/prism_nodes_deep (per-ERBContentNode + # injection) remain unimplemented — see CHARTER.md. Also excludes # timeout and max_errors (timing/error-cap options, not shape). FORWARDABLE_OPTIONS = %i[ strict @@ -28,10 +32,14 @@ module ResultEnvelope module_function def parse(source, options_hash = {}) + options_hash = (options_hash || {}).transform_keys(&:to_sym) result = Herb.parse(source, **forwardable(options_hash)) + value_hash = result.value.to_hash + value_hash[:prism_node] = prism_program_bytes(source) if options_hash[:prism_program] + { - value: result.value, + value: value_hash, source: result.source, warnings: result.warnings, errors: result.errors, @@ -57,6 +65,18 @@ def forwardable(options_hash) end end private_class_method :forwardable + + # @herb-tools/core's DocumentNode#prismNode getter deserializes + # prism_node bytes against the node's own (whole-file) `source`, so + # the bytes must come from parsing something byte-length-identical + # to source with Ruby content at the same offsets — exactly what + # Herb.extract_ruby produces (non-Ruby content blanked, not + # stripped). A plain Array of bytes (not the ASCII-8BIT String + # Prism.dump returns) is what keeps this JSON-safe. + def prism_program_bytes(source) + Prism.dump(Herb.extract_ruby(source)).bytes + end + private_class_method :prism_program_bytes end end end diff --git a/spec/bridge_lint_spec.rb b/spec/bridge_lint_spec.rb index 4fed5d7..b756014 100644 --- a/spec/bridge_lint_spec.rb +++ b/spec/bridge_lint_spec.rb @@ -50,12 +50,9 @@ class SpyRule { expect(adapter.call("__spyRuleInstantiationCount")).to eq(2) end - # check() depends on result.value.prismNode, which is never populated - # under the current architecture (see herb-embedded-gu7) — Task 5's - # ResultEnvelope deliberately never forwards prism_program/prism_nodes, - # so check() silently returns [] regardless of context. isEnabled() only - # reads context.fileName, so it's the only way to prove context - # threading for this rule until gu7 wires live Prism injection. + # isEnabled() only reads context.fileName, so it's a narrower probe than + # check() (see the prism_program-backed diagnostic test below) — kept + # separately since it isolates context threading from prismNode wiring. it "threads file: into LintContext under both fileName and filename" do bridge @@ -99,6 +96,18 @@ class FakeCrashRule { expect(bridge.lint(%(
x
), file: "x.html.erb", rules: ["html-no-space-in-tag"])).to be_empty end + it "produces a real offense for a prism_program-backed rule, not just isEnabled() firing" do + source = %(
<%= @foo %>
\n) + + diagnostics = bridge.lint(source, file: "app/views/_form.html.erb", rules: ["erb-no-instance-variables-in-partials"]) + + expect(diagnostics.size).to eq(1) + expect(diagnostics.first.message).to include("@foo") + + non_partial_diagnostics = bridge.lint(source, file: "app/views/form.html.erb", rules: ["erb-no-instance-variables-in-partials"]) + expect(non_partial_diagnostics).to be_empty + end + it "excludes a rule not enabled by default when rules: is nil, but includes it when explicitly selected" do bridge diff --git a/spec/result_envelope_spec.rb b/spec/result_envelope_spec.rb index 3733104..3ecd1ef 100644 --- a/spec/result_envelope_spec.rb +++ b/spec/result_envelope_spec.rb @@ -40,6 +40,22 @@ expect { JSON.parse(json) }.not_to raise_error end + it "injects a JSON-safe prism_node byte array on the root value when prism_program is requested" do + json = described_class.parse("
<%= @foo %>
\n", prism_program: true) + envelope = JSON.parse(json) + + expect(envelope["value"]["prism_node"]).to be_an(Array) + expect(envelope["value"]["prism_node"]).not_to be_empty + expect(envelope["value"]["prism_node"]).to all(be_an(Integer)) + end + + it "leaves prism_node nil on the root value when prism_program is not requested" do + json = described_class.parse("
<%= @foo %>
\n", {}) + envelope = JSON.parse(json) + + expect(envelope["value"]["prism_node"]).to be_nil + end + it "forwards track_whitespace and observably changes parse output" do source = %(
x
)