From 5fd284833cf95a9747446290600eb30b7ea2ba43 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 00:50:28 +0300 Subject: [PATCH] Support per-file lint configuration overrides --- .reach.exs | 1 + CHANGELOG.md | 2 + guides/features/formatting-and-linting.md | 36 +++++++ lib/mix/tasks/volt/lint.ex | 26 +++-- lib/volt/js/check.ex | 59 ++++++----- lib/volt/js/lint/config.ex | 108 ++++++++++++++++++++ mix.exs | 1 + test/mix/tasks/volt/js/check_test.exs | 58 ++++++++++- test/mix/tasks/volt/lint_test.exs | 117 ++++++++++++++++++++++ test/volt/js/lint/config_test.exs | 87 ++++++++++++++++ 10 files changed, 454 insertions(+), 41 deletions(-) create mode 100644 lib/volt/js/lint/config.ex create mode 100644 test/volt/js/lint/config_test.exs diff --git a/.reach.exs b/.reach.exs index 0c8fdc1..8eabe9f 100644 --- a/.reach.exs +++ b/.reach.exs @@ -99,6 +99,7 @@ logic = [ "Volt.HTMLEntry", "Volt.JS.AST", "Volt.JS.Check", + "Volt.JS.Lint.Config", "Volt.JS.Extensions", "Volt.JS.Format", "Volt.JS.Discovery", diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1d68a..0bf6f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- Support root-relative, ordered lint overrides for rules, environments, and globals in both lint commands, including per-component TypeScript rule selection in type-aware checks ([#35](https://github.com/elixir-volt/volt/issues/35)). - Add `Volt.build/1` as the library API for complete frontend builds with one typed result and merged manifest for scripts, styles, chunks, and emitted assets. - Isolate Tailwind scanner and CSS state by profile and stylesheet root while sharing one supervised compiler runtime. - Allow Tailwind roots to configure their logical output name, development URL, and source globs. @@ -22,6 +23,7 @@ ### Fixed +- Apply configured environments and globals in `mix volt.lint`, matching `mix volt.js.check`. - Allow JavaScript formatting and checks when the bundle format is configured as `:esm`, without treating the format atom as formatter options ([#36](https://github.com/elixir-volt/volt/pull/36), fixes [#34](https://github.com/elixir-volt/volt/issues/34)). - Handle filesystem watcher events for the watched root itself when normalizing macOS path aliases. - Apply Vue scoped styles without capturing component-module bindings such as `Object`. diff --git a/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index f5f0a6a..453caaf 100644 --- a/guides/features/formatting-and-linting.md +++ b/guides/features/formatting-and-linting.md @@ -67,6 +67,42 @@ config :volt, :lint, Lint-specific `:root`, `:sources`, and `:ignore` values override the build source set without changing which files the formatter checks. This is useful when canonical fixtures must be linted but retain their original formatting. +### Per-file overrides + +Both `mix volt.lint` and `mix volt.js.check` resolve the same per-file settings: + +```elixir +config :volt, :lint, + root: ".", + sources: ["assets/**/*.js", "scripts/**/*.js"], + plugins: [:typescript, :unicorn], + env: [:browser], + rules: %{"correctness" => :deny, "unicorn/no-null" => :deny}, + overrides: [ + %{ + files: ["assets/colocated/**/*.js"], + rules: %{"unicorn/filename-case" => :allow} + }, + %{ + files: ["assets/js/dom.js"], + rules: %{"unicorn/no-null" => :allow} + }, + %{ + files: ["scripts/**/*.js"], + env: %{browser: false, node: true}, + globals: %{"BuildContext" => :readonly} + } + ] +``` + +- `:files` is a non-empty list of globs relative to the lint root (`:root` under `:lint`, or the build root, which defaults to `assets`). Use `**/*.js` for nested files; `*.js` matches only the root level. Brace alternatives such as `**/*.{js,ts}` are supported. +- Overrides change settings for discovered files; they do not add files or change `:sources`/`:ignore`. Already-discovered dotfiles can match override globs. +- All matching overrides apply in declaration order. Later values win for each rule, environment or global; unrelated inherited entries remain intact. Rule severities are `:allow`, `:warn`, or `:deny`. +- Environment lists enable names; maps can enable or disable them. Set an inherited global to `:off` to remove it, or use `:readonly`/`:writable` to set its access. +- Override entries may be maps or keyword lists and support only `:files`, `:rules`, `:env`, and `:globals`. Plugins and custom rules remain run-wide; `mix volt.lint --plugin` retains its precedence over configured plugins. + +Type-aware checks group files by their effective TypeScript rules. Vue/Svelte script overrides match the original component path, not the generated virtual filename. Every group retains the complete set of extracted script sources, and diagnostics are mapped back to component paths. Environment/global overrides apply to syntax linting, not to TypeScript's project-level compiler configuration. File discovery and formatting settings are unchanged. + ### Custom Rules Custom lint rules can be written in Elixir using the `OXC.Lint.Rule` behaviour — see the [oxc docs](https://hexdocs.pm/oxc/OXC.Lint.Rule.html). diff --git a/lib/mix/tasks/volt/lint.ex b/lib/mix/tasks/volt/lint.ex index 0b5317e..0d9d0f4 100644 --- a/lib/mix/tasks/volt/lint.ex +++ b/lib/mix/tasks/volt/lint.ex @@ -31,7 +31,17 @@ defmodule Mix.Tasks.Volt.Lint do }, custom_rules: [ {MyApp.NoConsoleLog, :warn} + ], + overrides: [ + %{ + files: ["colocated/**/*.js"], + rules: %{"unicorn/filename-case" => :allow} + } ] + + Overrides are relative to the lint root and apply in order. They merge + individual `:rules`, `:env`, and `:globals` entries; later matches win. + Both lint commands share these settings. Plugins remain run-wide. """ use Mix.Task @@ -75,8 +85,7 @@ defmodule Mix.Tasks.Volt.Lint do cli_plugins -> Enum.map(cli_plugins, &String.to_atom/1) end - rules = Keyword.get(config, :rules, %{}) - custom_rules = Keyword.get(config, :custom_rules, []) + lint_config = Volt.JS.Lint.Config.new(config, Volt.Config.build().root) fix = Keyword.get(parsed, :fix, false) files = Volt.JS.Discovery.files(tool: :lint, only: ~w".js .ts .jsx .tsx") @@ -85,21 +94,18 @@ defmodule Mix.Tasks.Volt.Lint do Mix.shell().info("No lintable files found") :ok else - results = lint_files(files, plugins, rules, custom_rules, fix) + results = lint_files(files, lint_config, plugins, fix) print_results(results, files) end end - defp lint_files(files, plugins, rules, custom_rules, fix) do + defp lint_files(files, config, plugins, fix) do Enum.flat_map(files, fn file -> source = File.read!(file) + options = Volt.JS.Lint.Config.options(config, file) + options = Keyword.merge(options, plugins: plugins, fix: fix) - case OXC.Lint.run(source, file, - plugins: plugins, - rules: rules, - custom_rules: custom_rules, - fix: fix - ) do + case OXC.Lint.run(source, file, options) do {:ok, diags} -> Enum.map(diags, &Map.put(&1, :file, file)) diff --git a/lib/volt/js/check.ex b/lib/volt/js/check.ex index f5033f8..696ee8f 100644 --- a/lib/volt/js/check.ex +++ b/lib/volt/js/check.ex @@ -27,13 +27,13 @@ defmodule Volt.JS.Check do def lint(files, opts \\ []) do config = Application.get_env(:volt, :lint, []) - rules = Keyword.get(config, :rules, %{}) + lint_config = Volt.JS.Lint.Config.new(config, Volt.Config.build().root) if opts[:type_aware] do - ast_lint(Enum.filter(files, &type_aware_file?/1), config, rules) ++ - type_aware_lint(files, config, typescript_rules(rules), opts) + ast_lint(Enum.filter(files, &type_aware_file?/1), lint_config) ++ + type_aware_lint(files, config, lint_config, opts) else - ast_lint(files, config, rules) + ast_lint(files, lint_config) end end @@ -55,50 +55,49 @@ defmodule Volt.JS.Check do def lint_error_message(message) when is_binary(message), do: message def lint_error_message(message), do: inspect(message) - defp ast_lint(files, config, rules) do - plugins = Keyword.get(config, :plugins, [:typescript]) - custom_rules = Keyword.get(config, :custom_rules, []) - env = Keyword.get(config, :env, []) - globals = Keyword.get(config, :globals, %{}) - + defp ast_lint(files, config) do Enum.flat_map(files, fn file -> source = File.read!(file) + options = Volt.JS.Lint.Config.options(config, file) - case OXC.Lint.run(source, file, - plugins: plugins, - rules: rules, - env: env, - globals: globals, - custom_rules: custom_rules - ) do + case OXC.Lint.run(source, file, options) do {:ok, diagnostics} -> Enum.map(diagnostics, &Map.put(&1, :file, file)) {:error, errors} -> Enum.map(errors, &lint_error(&1, file)) end end) end - defp type_aware_lint(files, config, rules, opts) do + defp type_aware_lint(files, config, lint_config, opts) do {files, source_overrides, source_files} = type_aware_inputs(files, config) - lint_opts = + common_opts = [ type_aware: true, type_check: opts[:type_check] == true, - rules: rules, source_overrides: Map.merge(source_overrides, Keyword.get(config, :source_overrides, %{})) ] ++ type_aware_options(config) - case run_type_aware_lint(files, lint_opts) do - {:ok, diagnostics} -> - Enum.map(diagnostics, fn diagnostic -> - diagnostic - |> restore_sfc_file(source_files) - |> promote_type_check_diagnostic(opts) - end) + files + |> Enum.group_by(fn file -> + original = Map.get(source_files, Path.expand(file), file) - {:error, errors} -> - Enum.map(errors, &lint_error/1) - end + lint_config + |> Volt.JS.Lint.Config.options(original) + |> Keyword.fetch!(:rules) + |> typescript_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} -> + Enum.map(errors, &lint_error/1) + end + end) end defp type_aware_inputs(files, config) do diff --git a/lib/volt/js/lint/config.ex b/lib/volt/js/lint/config.ex new file mode 100644 index 0000000..e19e931 --- /dev/null +++ b/lib/volt/js/lint/config.ex @@ -0,0 +1,108 @@ +defmodule Volt.JS.Lint.Config do + @moduledoc """ + Resolves lint options for a source file without reading the filesystem. + + Override globs are relative to the lint discovery root. Every matching override + is applied in declaration order; later entries replace individual rules, + environments and globals, not their entire maps. Environment lists enable + names; environment maps can also disable inherited names. + """ + + @enforce_keys [:root, :options, :overrides] + defstruct [:root, :options, :overrides] + + @type t :: %__MODULE__{ + root: String.t(), + options: keyword(), + overrides: [{[GlobEx.t()], keyword()}] + } + + @doc "Compiles a lint configuration, using the build root unless lint sets its own root." + @spec new(keyword(), String.t()) :: t() + def new(config, root) do + options = + [plugins: [:typescript], custom_rules: [], fix: false] + |> Keyword.merge(Keyword.take(config, [:plugins, :custom_rules, :fix])) + |> Keyword.merge(maps(config)) + + overrides = + Enum.map(Keyword.get(config, :overrides, []), fn override -> + override = Map.new(override) + unknown = Map.keys(override) -- [:files, :rules, :env, :globals] + + if unknown != [] do + raise ArgumentError, "unsupported lint override keys: #{inspect(unknown)}" + end + + globs = + case Map.fetch(override, :files) do + {:ok, [_ | _] = files} -> + Enum.map(files, &compile_glob!/1) + + _ -> + raise ArgumentError, + "lint override :files must be a non-empty list of relative globs" + end + + {globs, maps(Map.to_list(override))} + end) + + %__MODULE__{ + root: Path.expand(Keyword.get(config, :root, root)), + options: options, + overrides: overrides + } + end + + @doc "Returns effective OXC syntax-lint options for the original source path." + @spec options(t(), String.t()) :: keyword() + def options(%__MODULE__{} = config, file) do + relative = file |> Path.expand() |> Path.relative_to(config.root) + + if Path.type(relative) == :relative and List.first(Path.split(relative)) != ".." do + relative = relative |> Path.split() |> Enum.join("/") + + Enum.reduce(config.overrides, config.options, &apply_override(&1, relative, &2)) + else + config.options + end + end + + defp apply_override({globs, options}, relative, inherited) do + if Enum.any?(globs, &GlobEx.match?(&1, relative)) do + Keyword.merge(inherited, options, fn _key, base, override -> Map.merge(base, override) end) + else + inherited + end + end + + defp maps(config) do + [ + rules: names(Keyword.get(config, :rules, %{})), + globals: names(Keyword.get(config, :globals, %{})), + env: environments(Keyword.get(config, :env, [])) + ] + end + + defp names(map), do: Map.new(map, fn {name, value} -> {to_string(name), value} end) + + defp environments(env) when is_list(env), do: Map.new(env, &{to_string(&1), true}) + defp environments(env) when is_map(env), do: names(env) + + defp compile_glob!(pattern) when is_binary(pattern) do + if Path.type(pattern) != :relative or ".." in Path.split(pattern) do + raise ArgumentError, + "lint override globs must stay relative to the lint root: #{inspect(pattern)}" + end + + pattern + |> Path.split() + |> Enum.reject(&(&1 == ".")) + |> Enum.join("/") + |> GlobEx.compile!(match_dot: true) + end + + defp compile_glob!(pattern) do + raise ArgumentError, "lint override glob must be a string, got: #{inspect(pattern)}" + end +end diff --git a/mix.exs b/mix.exs index 16f5d12..06ffeed 100644 --- a/mix.exs +++ b/mix.exs @@ -222,6 +222,7 @@ defmodule Volt.MixProject do Volt.JS.AST, Volt.JS.Extensions, Volt.JS.Discovery, + Volt.JS.Lint.Config, Volt.JS.ImportExtractor, Volt.JS.ImportExtractor.Result, Volt.JS.Package, diff --git a/test/mix/tasks/volt/js/check_test.exs b/test/mix/tasks/volt/js/check_test.exs index b6f3bf1..85a0b60 100644 --- a/test/mix/tasks/volt/js/check_test.exs +++ b/test/mix/tasks/volt/js/check_test.exs @@ -154,6 +154,62 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do "svelteValue" end + test "type-aware overrides batch by effective rules and use original SFC paths" do + files = Enum.map(["app.ts", "Component.vue", "Widget.svelte"], &Path.join(@tmp_dir, &1)) + [app, vue, svelte] = files + File.write!(app, "export const value = 1;\n") + File.write!(vue, "\n") + File.write!(svelte, "\n") + payload_path = Path.join(@tmp_dir, "batches.jsonl") + + tsgolint = + fake_executable!(@tmp_dir, "tsgolint-batches", """ + input = IO.binread(:stdio, :eof) + File.write!(#{inspect(payload_path)}, [input, "\\n"], [:append]) + payload = JSON.decode!(input) + for config <- payload["configs"], file <- config["file_paths"] do + json = JSON.encode!(%{rule: "no-floating-promises", message: %{description: "batch diagnostic"}, file_path: file, range: %{pos: 0, end: 1}}) + IO.binwrite(<>) + end + """) + + Application.put_env(:volt, :lint, + root: @tmp_dir, + tsgolint: tsgolint, + rules: %{"typescript/no-floating-promises" => :deny}, + overrides: [ + %{files: ["**/*.{vue,svelte}"], rules: %{"typescript/no-floating-promises" => :warn}}, + %{files: ["**/*.script0.ts"], rules: %{"typescript/no-floating-promises" => :allow}} + ] + ) + + diagnostics = Volt.JS.Check.lint(files, type_aware: true) + + assert Enum.sort(Enum.map(diagnostics, & &1.file)) == + Enum.sort([Path.expand(app), vue, svelte]) + + batches = + payload_path |> File.read!() |> String.split("\n", trim: true) |> Enum.map(&Jason.decode!/1) + + assert length(batches) == 2 + + 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" + ] + + assert Enum.find(diagnostics, &(&1.file == Path.expand(app))).severity == :deny + assert Enum.find(diagnostics, &(&1.file == vue)).severity == :warn + assert Enum.find(diagnostics, &(&1.file == svelte)).severity == :warn + + for batch <- batches do + assert batch["source_overrides"][Path.expand(vue <> ".script0.ts")] =~ "vueValue" + assert batch["source_overrides"][Path.expand(svelte <> ".script0.ts")] =~ "svelteValue" + end + end + defp fake_tsgolint!(dir) do fake_executable!(dir, "tsgolint", """ json = ~s({"rule":"no-floating-promises","message":{"description":"floating promise"},"file_path":"typed.ts","range":{"pos":0,"end":5}}) @@ -188,7 +244,7 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do defp fake_executable!(dir, name, code) do script = Path.expand("#{name}.exs", dir) - File.write!(script, code) + File.write!(script, ":io.setopts(:standard_io, encoding: :latin1)\n" <> code) case :os.type() do {:win32, _name} -> diff --git a/test/mix/tasks/volt/lint_test.exs b/test/mix/tasks/volt/lint_test.exs index c532867..29adcfe 100644 --- a/test/mix/tasks/volt/lint_test.exs +++ b/test/mix/tasks/volt/lint_test.exs @@ -70,6 +70,123 @@ defmodule Mix.Tasks.Volt.LintTest do assert output =~ "no-explicit-any" end + test "generated filenames can be exempted without disabling filename-case elsewhere" do + File.mkdir_p!(Path.join(@tmp_dir, "colocated/DemoWeb")) + generated = Path.join(@tmp_dir, "colocated/DemoWeb/BadName.js") + authored = Path.join(@tmp_dir, "BadName.js") + for file <- [generated, authored], do: File.write!(file, "export const value = 1;\n") + + Application.put_env(:volt, :lint, + plugins: [:unicorn], + rules: %{"unicorn/filename-case" => :deny}, + overrides: [%{files: ["colocated/**/*.js"], rules: %{"unicorn/filename-case" => :allow}}] + ) + + assert [%{file: ^authored, rule: rule}] = Volt.JS.Check.lint([generated, authored]) + assert rule =~ "filename-case" + + output = + capture_io(fn -> assert catch_exit(Mix.Tasks.Volt.Lint.run([])) == {:shutdown, 1} end) + + refute output =~ "colocated/DemoWeb" + assert output =~ "BadName.js" + assert output =~ "filename-case" + end + + test "CLI plugins still enable rules introduced by an override" do + File.write!(Path.join(@tmp_dir, "typed.ts"), "export function foo(x: any) { return x; }\n") + + Application.put_env(:volt, :lint, + plugins: [], + overrides: [%{files: ["typed.ts"], rules: %{"typescript/no-explicit-any" => :warn}}] + ) + + output = capture_io(fn -> Mix.Tasks.Volt.Lint.run(["--plugin", "typescript"]) end) + assert output =~ "no-explicit-any" + end + + test "applies scoped rules, environments and globals in both lint commands" do + File.mkdir_p!(Path.join(@tmp_dir, "scripts")) + script = Path.join(@tmp_dir, "scripts/build.js") + browser = Path.join(@tmp_dir, "browser.js") + File.write!(script, "process.exitCode = externalValue;\nexport const value = null;\n") + File.write!(browser, "document.title = externalValue;\nexport const value = null;\n") + + Application.put_env(:volt, :lint, + root: @tmp_dir, + plugins: [:unicorn], + env: [:browser], + rules: %{"no-undef" => :deny, "unicorn/no-null" => :deny}, + overrides: [ + %{ + files: ["scripts/**/*.js"], + env: %{browser: false, node: true}, + globals: %{"externalValue" => :readonly}, + rules: %{"unicorn/no-null" => :allow} + } + ] + ) + + diagnostics = Volt.JS.Check.lint([script, browser]) + refute Enum.any?(diagnostics, &(&1.file == script)) + assert Enum.any?(diagnostics, &(&1.file == browser and &1.rule =~ "no-null")) + assert Enum.any?(diagnostics, &(&1.file == browser and &1.message =~ "externalValue")) + + output = + capture_io(fn -> assert catch_exit(Mix.Tasks.Volt.Lint.run([])) == {:shutdown, 1} end) + + refute output =~ "scripts/build.js" + assert output =~ "browser.js" + assert output =~ "no-null" + assert output =~ "externalValue" + + for file <- [script, browser] do + {:ok, formatted} = OXC.Format.run(File.read!(file), file, Volt.JS.Format.load_config()) + File.write!(file, formatted) + end + + check_output = + capture_io(:stderr, fn -> + capture_io(fn -> assert catch_exit(Mix.Tasks.Volt.Js.Check.run([])) == {:shutdown, 1} end) + end) + + refute check_output =~ "scripts/build.js" + assert check_output =~ "browser.js" + assert check_output =~ "no-null" + assert check_output =~ "externalValue" + end + + test "overrides can disable inherited environments and globals" do + file = Path.join(@tmp_dir, "server.js") + File.write!(file, "document.title = sharedGlobal;\nprocess.exitCode = 0;\n") + + Application.put_env(:volt, :lint, + env: [:browser], + globals: %{"sharedGlobal" => :readonly}, + rules: %{"no-undef" => :deny}, + overrides: [ + %{ + files: ["server.js"], + env: %{browser: false, node: true}, + globals: %{"sharedGlobal" => :off} + } + ] + ) + + diagnostics = Volt.JS.Check.lint([file]) + assert length(diagnostics) == 2 + assert Enum.any?(diagnostics, &(&1.message =~ "document")) + assert Enum.any?(diagnostics, &(&1.message =~ "sharedGlobal")) + refute Enum.any?(diagnostics, &(&1.message =~ "process")) + + output = + capture_io(fn -> assert catch_exit(Mix.Tasks.Volt.Lint.run([])) == {:shutdown, 1} end) + + assert output =~ "document" + assert output =~ "sharedGlobal" + refute output =~ "process" + end + test "skips node_modules" do File.mkdir_p!(Path.join(@tmp_dir, "node_modules/pkg")) File.write!(Path.join([@tmp_dir, "node_modules", "pkg", "bad.js"]), "debugger;\n") diff --git a/test/volt/js/lint/config_test.exs b/test/volt/js/lint/config_test.exs new file mode 100644 index 0000000..ab37a46 --- /dev/null +++ b/test/volt/js/lint/config_test.exs @@ -0,0 +1,87 @@ +defmodule Volt.JS.Lint.ConfigTest do + use ExUnit.Case, async: true + + alias Volt.JS.Lint.Config + + test "merges all matching overrides in order and normalizes environment/global names" do + config = + Config.new( + [ + rules: %{"correctness" => :deny, "unicorn/no-null" => :deny}, + env: [:browser], + globals: %{shared: :readonly, retained: :readonly}, + overrides: [ + %{ + files: ["scripts/**/*.{js,ts}", "build.js"], + env: %{browser: false, node: true}, + globals: %{"shared" => :writable}, + rules: %{"unicorn/no-null" => :allow} + }, + [ + files: ["scripts/release.*"], + env: [:mocha], + globals: %{shared: :off}, + rules: %{"unicorn/no-null" => :warn} + ] + ] + ], + "assets" + ) + + options = Config.options(config, "assets/scripts/release.ts") + assert options[:rules] == %{"correctness" => :deny, "unicorn/no-null" => :warn} + assert options[:env] == %{"browser" => false, "node" => true, "mocha" => true} + assert options[:globals] == %{"shared" => :off, "retained" => :readonly} + assert options[:plugins] == [:typescript] + assert Config.options(config, "assets/build.js")[:rules]["unicorn/no-null"] == :allow + assert Config.options(config, "assets/app.js")[:rules]["unicorn/no-null"] == :deny + end + + test "uses the lint root, handles absolute paths, and never matches outside the root" do + config = + Config.new( + [ + root: ".", + overrides: [%{files: ["./assets/**/*.js"], rules: %{"no-debugger" => :allow}}] + ], + "other" + ) + + relative = Config.options(config, "assets/nested/file.js") + assert relative[:rules] == %{"no-debugger" => :allow} + assert relative == Config.options(config, Path.expand("assets/nested/file.js")) + assert Config.options(config, "elsewhere/file.js")[:rules] == %{} + + scoped = + Config.new( + [overrides: [%{files: ["**/*.js"], rules: %{"no-debugger" => :allow}}]], + "assets" + ) + + assert Config.options(scoped, "assets-other/file.js")[:rules] == %{} + assert Config.options(scoped, "assets/../file.js")[:rules] == %{} + end + + test "matches already-discovered hidden files without expanding the filesystem" do + config = + Config.new([overrides: [%{files: ["**/*.js"], env: [:node]}]], "hidden/.worktree/assets") + + assert Config.options(config, "hidden/.worktree/assets/.generated/build.js")[:env] == %{ + "node" => true + } + end + + test "requires scoped globs and rejects unsupported override settings" do + for files <- [[], "*.js", [123], ["../*.js"], [Path.expand("*.js")]] do + assert_raise ArgumentError, fn -> Config.new([overrides: [%{files: files}]], "assets") end + end + + assert_raise ArgumentError, ~r/unsupported lint override keys/, fn -> + Config.new([overrides: [%{files: ["*.js"], plugins: [:node]}]], "assets") + end + + assert_raise GlobEx.CompileError, fn -> + Config.new([overrides: [%{files: ["{broken"]}]], "assets") + end + end +end