Skip to content
Merged
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@

### Changed

- Require `oxc` 0.18, `vize` 0.15, `quickbeam` 0.11.2, and `json_codec` 0.3.
- Require `oxc` 0.18.1, `vize` 0.15, `quickbeam` 0.11.2, and `json_codec` 0.3.

### Fixed

- Show the error overlay when a page loads with a module that fails to compile. The client previously loaded only through the failing module graph.
- Hide the overlay once errors are fixed, and report Tailwind build failures, including at startup.
- Reload the page after the HMR connection returns, since the server may have restarted.
- Type-aware checks run the type-aware rules that lint categories such as `"correctness"` select, honoring individual rule settings and per-file overrides ([#40](https://github.com/elixir-volt/volt/issues/40)).
- Type-aware checks extract single-file component scripts with the configured Volt plugins instead of the oxlint plugin names.

## 0.18.1 - 2026-09-15
Expand Down
2 changes: 2 additions & 0 deletions guides/features/formatting-and-linting.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,5 @@ config :volt, :lint,
Volt keeps the Oxlint-style rule shape: configure normal and type-aware rules together under `:rules`. When `--type-aware` is enabled, Volt still runs the normal syntax lint path and also invokes `tsgolint` for supported semantic TypeScript rules.

Exits with non-zero status on issues.

With `--type-aware`, categories also select type-aware rules from the enabled plugins. For example, `"correctness" => :deny` with `plugins: ["typescript"]` runs `typescript/no-floating-promises`. Individual rule settings override their categories, and per-file overrides apply before categories are expanded. Configurations without categories run only the type-aware rules they name. `--type-check` independently enables TypeScript compiler diagnostics.
36 changes: 15 additions & 21 deletions lib/volt/js/check.ex
Original file line number Diff line number Diff line change
Expand Up @@ -82,23 +82,23 @@ defmodule Volt.JS.Check do
files
|> Enum.group_by(fn file ->
original = Map.get(source_files, Path.expand(file), file)

lint_config
|> Volt.JS.Lint.Config.options(original)
|> Keyword.fetch!(:rules)
|> typescript_rules()
lint_config |> Volt.JS.Lint.Config.options(original) |> OXC.Lint.type_aware_rules()
end)
|> Enum.sort_by(fn {rules, _files} -> rules end)
|> Enum.flat_map(fn {rules, batch} ->
case run_type_aware_lint(batch, Keyword.put(common_opts, :rules, rules)) do
{:ok, diagnostics} ->
Enum.map(diagnostics, fn diagnostic ->
diagnostic |> restore_sfc_file(source_files) |> promote_type_check_diagnostic(opts)
end)

{:error, errors} ->
errors
end
|> Enum.flat_map(fn
{{:ok, rules}, batch} ->
case run_type_aware_lint(batch, Keyword.put(common_opts, :rules, rules)) do
{:ok, diagnostics} ->
Enum.map(diagnostics, fn diagnostic ->
diagnostic |> restore_sfc_file(source_files) |> promote_type_check_diagnostic(opts)
end)

{:error, errors} ->
errors
end

{{:error, errors}, _batch} ->
errors
end)
end

Expand Down Expand Up @@ -179,12 +179,6 @@ defmodule Volt.JS.Check do
end)
end

defp typescript_rules(rules) do
Map.filter(rules, fn {rule, _config} ->
String.starts_with?(to_string(rule), "typescript/")
end)
end

defp type_aware_options(config) do
config
|> Keyword.take([:tsgolint, :fix, :fix_suggestions, :cwd])
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ defmodule Volt.MixProject do
[
{:reach, "~> 2.6.1", only: [:dev, :test], runtime: false},
{:glob_ex, "~> 0.1.12"},
{:oxc, "~> 0.18.0"},
{:oxc, "~> 0.18.1"},
{:vize, "~> 0.15.0"},
{:oxide_ex, "~> 0.2.2"},
{:quickbeam, "~> 0.11.2"},
Expand Down
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"npm": {:hex, :npm, "0.7.6", "de8ca3dd26a40fac7ab8a17680088b8d55bab0e0294be9770b029c276361482f", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:npm_semver, "~> 0.1.0", [hex: :npm_semver, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "e0a1ce77e3e49ec108e095aba133bd315e86da298dba26b142a105972141d9ef"},
"npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"},
"owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"},
"oxc": {:hex, :oxc, "0.18.0", "0b22ae1ac62f6e985458aae53bb0020756bd790ed07bcda1a89168b266025415", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "121495bbbaba0807433653d4446cb9ed68bc7db35e6f11a9c82da56f89bcaa6c"},
"oxc": {:hex, :oxc, "0.18.1", "9fb4cd232ee2143364e86a021992958b1aecf07583432ebb3319386e2f6ce6c0", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "2653908736b6064c8363415280472bc687ad3028ee7f1441cb6ac458fc8e9a62"},
"oxide_ex": {:hex, :oxide_ex, "0.2.2", "063e0d2899e81632b7efc7849eb1dd89a7a623290a075c446b43bef14cf5795e", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "577a2fbd97c46904a31143bb700cdb374105085cfd1f7605416bb6c012cb7f60"},
"pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"},
"pi_bridge": {:hex, :pi_bridge, "0.6.21", "f1a63da5f49fef4f6b35e98edd5400cbd64a49fb8bda84be41b7e91f3deb0b89", [:mix], [{:bandit, "~> 1.8", [hex: :bandit, repo: "hexpm", optional: false]}, {:dune, "~> 0.3", [hex: :dune, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_ast, "~> 0.12", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.1.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:quackdb, "~> 0.5.4", [hex: :quackdb, repo: "hexpm", optional: false]}, {:reach, "~> 2.6", [hex: :reach, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "b226fb60965a8e928186af3c3b96fe8f0fefd8c6cc7be679d6e169ba84105de9"},
Expand Down
64 changes: 59 additions & 5 deletions test/mix/tasks/volt/js/check_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,54 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do
assert output =~ "typescript/no-floating-promises"
end

test "category-only configuration submits semantic rules to tsgolint" do
file = Path.join(@tmp_dir, "typed.ts")
File.write!(file, "Promise.resolve(1);\n")

Application.put_env(:volt, :lint,
tsgolint: fake_tsgolint!(@tmp_dir),
plugins: ["typescript"],
rules: %{"correctness" => :deny}
)

diagnostics = Volt.JS.Check.lint([file], type_aware: true)

assert Enum.any?(
diagnostics,
&(&1.rule == "typescript/no-floating-promises" and &1.severity == :error)
)
end

test "category expansion honors per-file semantic rule exclusions" do
first = Path.join(@tmp_dir, "first.ts")
second = Path.join(@tmp_dir, "second.ts")
Enum.each([first, second], &File.write!(&1, "Promise.resolve(1);\n"))
payload_path = Path.join(@tmp_dir, "categories.jsonl")

tsgolint =
fake_executable!(@tmp_dir, "tsgolint-categories", """
input = IO.binread(:stdio, :eof)
File.write!(#{inspect(payload_path)}, [input, "\\n"], [:append])
""")

Application.put_env(:volt, :lint,
root: @tmp_dir,
tsgolint: tsgolint,
plugins: ["typescript"],
rules: %{"correctness" => :deny},
overrides: [%{files: ["second.ts"], rules: %{"typescript/no-floating-promises" => :allow}}]
)

assert [] = Volt.JS.Check.lint([first, second], type_aware: true)
batches = payload_path |> File.stream!() |> Enum.map(&Jason.decode!/1)
configs = Enum.flat_map(batches, & &1["configs"])

for {file, selected} <- [{first, true}, {second, false}] do
config = Enum.find(configs, &(Path.expand(file) in &1["file_paths"]))
assert Enum.any?(config["rules"], &(&1["name"] == "no-floating-promises")) == selected
end
end

test "type-check diagnostics are promoted to errors" do
diagnostic = %{
rule: "typescript/TS2322",
Expand Down Expand Up @@ -110,7 +158,13 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do
end)

payload = @tmp_dir |> Path.join("payload.json") |> File.read!() |> Jason.decode!()
assert [%{"rules" => [%{"name" => "no-floating-promises"}]}] = payload["configs"]
assert [%{"rules" => rules}] = payload["configs"]
assert Enum.any?(rules, &(&1["name"] == "no-floating-promises"))

refute Enum.any?(
rules,
&(&1["name"] in ["correctness", "suspicious", "consistent-type-imports"])
)
end

test "type-aware check submits framework single-file component scripts as virtual files" do
Expand Down Expand Up @@ -176,7 +230,7 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do
Application.put_env(:volt, :lint,
root: @tmp_dir,
tsgolint: tsgolint,
rules: %{"typescript/no-floating-promises" => :deny},
rules: %{"correctness" => :deny, "typescript/no-floating-promises" => :deny},
overrides: [
%{files: ["**/*.{vue,svelte}"], rules: %{"typescript/no-floating-promises" => :warn}},
%{files: ["**/*.script0.ts"], rules: %{"typescript/no-floating-promises" => :allow}}
Expand All @@ -196,9 +250,9 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do
configs = Enum.flat_map(batches, & &1["configs"])
assert Enum.sort(Enum.map(configs, &length(&1["file_paths"]))) == [1, 2]

assert configs |> Enum.flat_map(& &1["rules"]) |> Enum.map(& &1["name"]) |> Enum.uniq() == [
"no-floating-promises"
]
names = configs |> Enum.flat_map(& &1["rules"]) |> Enum.map(& &1["name"])
assert "no-floating-promises" in names
refute "correctness" in names

assert Enum.find(diagnostics, &(&1.file == Path.expand(app))).severity == :error
assert Enum.find(diagnostics, &(&1.file == vue)).severity == :warning
Expand Down
Loading