Current section
Files
Jump to
Current section
Files
lib/reach/config.ex
defmodule Reach.Config do @moduledoc "Parses and normalizes .reach.exs architecture policy configuration." alias Reach.Check.Violation defmodule Deps do @moduledoc false defstruct forbidden: [] end defmodule Calls do @moduledoc false defstruct forbidden: [] end defmodule Effects do @moduledoc false defstruct allowed: [] end defmodule Boundaries do @moduledoc false defstruct public: [], internal: [], internal_callers: [] end defmodule Tests do @moduledoc false defstruct hints: [] end defmodule Source do @moduledoc false defstruct forbidden_modules: [], forbidden_files: [] end defmodule Risk do @moduledoc false defstruct changed: nil end defmodule Risk.Changed do @moduledoc false defstruct many_direct_callers: 5, wide_transitive_callers: 10, branch_heavy: 8, high_risk_reason_count: 3 end defmodule Candidates do @moduledoc false defstruct thresholds: nil, limits: nil end defmodule Candidates.Thresholds do @moduledoc false defstruct mixed_effect_count: 2, branchy_function_branches: 8, high_risk_direct_callers: 4 end defmodule Candidates.Limits do @moduledoc false defstruct per_kind: 20, representative_calls: 10, representative_calls_per_edge: 3 end defmodule CloneAnalysis do @moduledoc false defstruct provider: :ex_dna, min_mass: 30, min_similarity: 1.0, max_clones: 50 end defmodule Smells do @moduledoc false defstruct fixed_shape_map: nil, behaviour_candidate: nil end defmodule Smells.FixedShapeMap do @moduledoc false defstruct min_keys: 3, min_occurrences: 3, evidence_limit: 10 end defmodule Smells.BehaviourCandidate do @moduledoc false defstruct min_modules: 3, min_callbacks: 3, module_display_limit: 8, callback_display_limit: 8 end defmodule Error do @moduledoc false defstruct [:path, :message] def to_violation(%__MODULE__{path: path, message: message}) do wrapped_path = List.wrap(path) key = Enum.map_join(wrapped_path, ".", &to_string/1) Violation.new( type: :config_error, key: key, path: Enum.map(wrapped_path, &to_string/1), message: message ) end end defstruct layers: [], deps: nil, calls: nil, effects: nil, boundaries: nil, tests: nil, source: nil, risk: nil, candidates: nil, smells: nil, clone_analysis: nil @top_level_keys [ :layers, :deps, :calls, :effects, :boundaries, :tests, :source, :risk, :candidates, :smells, :clone_analysis, :forbidden_deps, :allowed_effects, :forbidden_calls, :public_api, :internal, :internal_callers, :test_hints, :forbidden_modules, :forbidden_files ] def read(path \\ ".reach.exs") do if File.exists?(path), do: Code.eval_file(path) |> elem(0), else: [] end def read!(path \\ ".reach.exs") do unless File.exists?(path) do Mix.raise("No #{path} architecture policy found") end config = Code.eval_file(path) |> elem(0) unless is_list(config) do Mix.raise("#{path} must evaluate to a keyword list") end config end def from_terms(%__MODULE__{} = config), do: {:ok, normalize(config)} def from_terms(config) when is_list(config) do errors = errors(config) if errors == [] do {:ok, normalize(config)} else {:error, errors} end end def from_terms(_config) do {:error, [%Error{path: [], message: "expected .reach.exs to evaluate to a keyword list"}]} end def normalize(%__MODULE__{} = config) do %__MODULE__{ config | deps: config.deps || %Deps{}, calls: config.calls || %Calls{}, effects: config.effects || %Effects{}, boundaries: config.boundaries || %Boundaries{}, tests: config.tests || %Tests{}, source: config.source || %Source{}, risk: normalize_risk(config.risk), candidates: normalize_candidates(config.candidates), smells: normalize_smells(config.smells), clone_analysis: normalize_clone_analysis(config.clone_analysis) } end def normalize(config) when is_list(config) do %__MODULE__{ layers: Keyword.get(config, :layers, []), deps: %Deps{forbidden: nested(config, [:deps, :forbidden], :forbidden_deps, [])}, calls: %Calls{forbidden: nested(config, [:calls, :forbidden], :forbidden_calls, [])}, effects: %Effects{allowed: nested(config, [:effects, :allowed], :allowed_effects, [])}, boundaries: %Boundaries{ public: nested(config, [:boundaries, :public], :public_api, []), internal: nested(config, [:boundaries, :internal], :internal, []), internal_callers: nested(config, [:boundaries, :internal_callers], :internal_callers, []) }, tests: %Tests{hints: nested(config, [:tests, :hints], :test_hints, [])}, source: %Source{ forbidden_modules: nested(config, [:source, :forbidden_modules], :forbidden_modules, []), forbidden_files: nested(config, [:source, :forbidden_files], :forbidden_files, []) }, risk: %Risk{ changed: %Risk.Changed{ many_direct_callers: nested(config, [:risk, :changed, :many_direct_callers], nil, 5), wide_transitive_callers: nested(config, [:risk, :changed, :wide_transitive_callers], nil, 10), branch_heavy: nested(config, [:risk, :changed, :branch_heavy], nil, 8), high_risk_reason_count: nested(config, [:risk, :changed, :high_risk_reason_count], nil, 3) } }, candidates: %Candidates{ thresholds: %Candidates.Thresholds{ mixed_effect_count: nested(config, [:candidates, :thresholds, :mixed_effect_count], nil, 2), branchy_function_branches: nested(config, [:candidates, :thresholds, :branchy_function_branches], nil, 8), high_risk_direct_callers: nested(config, [:candidates, :thresholds, :high_risk_direct_callers], nil, 4) }, limits: %Candidates.Limits{ per_kind: nested(config, [:candidates, :limits, :per_kind], nil, 20), representative_calls: nested(config, [:candidates, :limits, :representative_calls], nil, 10), representative_calls_per_edge: nested(config, [:candidates, :limits, :representative_calls_per_edge], nil, 3) } }, clone_analysis: %CloneAnalysis{ provider: nested(config, [:clone_analysis, :provider], nil, :ex_dna), min_mass: nested(config, [:clone_analysis, :min_mass], nil, 30), min_similarity: nested(config, [:clone_analysis, :min_similarity], nil, 1.0), max_clones: nested(config, [:clone_analysis, :max_clones], nil, 50) }, smells: %Smells{ fixed_shape_map: %Smells.FixedShapeMap{ min_keys: nested(config, [:smells, :fixed_shape_map, :min_keys], nil, 3), min_occurrences: nested(config, [:smells, :fixed_shape_map, :min_occurrences], nil, 3), evidence_limit: nested(config, [:smells, :fixed_shape_map, :evidence_limit], nil, 10) }, behaviour_candidate: %Smells.BehaviourCandidate{ min_modules: nested(config, [:smells, :behaviour_candidate, :min_modules], nil, 3), min_callbacks: nested(config, [:smells, :behaviour_candidate, :min_callbacks], nil, 3), module_display_limit: nested(config, [:smells, :behaviour_candidate, :module_display_limit], nil, 8), callback_display_limit: nested(config, [:smells, :behaviour_candidate, :callback_display_limit], nil, 8) } } } end defp normalize_risk(%Risk{} = risk), do: %{risk | changed: risk.changed || %Risk.Changed{}} defp normalize_risk(_risk), do: %Risk{} defp normalize_candidates(%Candidates{} = candidates) do %{ candidates | thresholds: candidates.thresholds || %Candidates.Thresholds{}, limits: candidates.limits || %Candidates.Limits{} } end defp normalize_candidates(_candidates), do: %Candidates{} defp normalize_clone_analysis(%CloneAnalysis{} = clone_analysis), do: clone_analysis defp normalize_clone_analysis(_clone_analysis), do: %CloneAnalysis{} defp normalize_smells(%Smells{} = smells) do %{ smells | fixed_shape_map: smells.fixed_shape_map || %Smells.FixedShapeMap{}, behaviour_candidate: smells.behaviour_candidate || %Smells.BehaviourCandidate{} } end defp normalize_smells(_smells) do %Smells{ fixed_shape_map: %Smells.FixedShapeMap{}, behaviour_candidate: %Smells.BehaviourCandidate{} } end def errors(config) when is_list(config) do if Keyword.keyword?(config) do unknown_key_errors(config) ++ shape_errors(config) else [%Error{path: [], message: "expected keyword list"}] end end def errors(_config), do: [%Error{path: [], message: "expected keyword list"}] defp unknown_key_errors(config) do config |> Keyword.keys() |> Enum.reject(&(&1 in @top_level_keys)) |> Enum.map(fn key -> %Error{path: [key], message: "Unknown .reach.exs key #{inspect(key)}"} end) end defp shape_errors(config) do [] |> check(config, [:layers], &valid_layers?/1, "expected keyword list of layer: patterns") |> check(config, [:deps], &valid_group?/1, "expected keyword list") |> check( config, [:deps, :forbidden], &valid_forbidden_deps?/1, "expected list of {from_layer, to_layer}" ) |> check(config, [:calls], &valid_group?/1, "expected keyword list") |> check( config, [:calls, :forbidden], &valid_forbidden_calls?/1, "expected list of {caller_patterns, call_patterns} or {caller_patterns, call_patterns, opts}" ) |> check(config, [:effects], &valid_group?/1, "expected keyword list") |> check( config, [:effects, :allowed], &valid_allowed_effects?/1, "expected list of {module_pattern, effects}" ) |> check(config, [:boundaries], &valid_group?/1, "expected keyword list") |> check( config, [:boundaries, :public], &valid_pattern_list?/1, "expected string or list of module patterns" ) |> check( config, [:boundaries, :internal], &valid_pattern_list?/1, "expected string or list of module patterns" ) |> check( config, [:boundaries, :internal_callers], &valid_internal_callers?/1, "expected list of {internal_pattern, caller_patterns}" ) |> check(config, [:tests], &valid_group?/1, "expected keyword list") |> check( config, [:tests, :hints], &valid_test_hints?/1, "expected list of {path_glob, test_paths}" ) |> check(config, [:source], &valid_group?/1, "expected keyword list") |> check(config, [:risk], &valid_group?/1, "expected keyword list") |> check(config, [:risk, :changed], &valid_group?/1, "expected keyword list") |> check( config, [:risk, :changed, :many_direct_callers], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:risk, :changed, :wide_transitive_callers], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:risk, :changed, :branch_heavy], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:risk, :changed, :high_risk_reason_count], &valid_positive_integer?/1, "expected positive integer" ) |> check(config, [:candidates], &valid_group?/1, "expected keyword list") |> check(config, [:clone_analysis], &valid_group?/1, "expected keyword list") |> check( config, [:clone_analysis, :provider], &valid_clone_provider?/1, "expected :ex_dna or false" ) |> check( config, [:clone_analysis, :min_mass], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:clone_analysis, :min_similarity], &valid_similarity?/1, "expected float between 0.0 and 1.0" ) |> check( config, [:clone_analysis, :max_clones], &valid_positive_integer?/1, "expected positive integer" ) |> check(config, [:smells], &valid_group?/1, "expected keyword list") |> check(config, [:smells, :fixed_shape_map], &valid_group?/1, "expected keyword list") |> check(config, [:smells, :behaviour_candidate], &valid_group?/1, "expected keyword list") |> check( config, [:smells, :fixed_shape_map, :min_keys], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:smells, :fixed_shape_map, :min_occurrences], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:smells, :fixed_shape_map, :evidence_limit], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:smells, :behaviour_candidate, :min_modules], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:smells, :behaviour_candidate, :min_callbacks], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:smells, :behaviour_candidate, :module_display_limit], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:smells, :behaviour_candidate, :callback_display_limit], &valid_positive_integer?/1, "expected positive integer" ) |> check(config, [:candidates, :thresholds], &valid_group?/1, "expected keyword list") |> check( config, [:candidates, :thresholds, :mixed_effect_count], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:candidates, :thresholds, :branchy_function_branches], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:candidates, :thresholds, :high_risk_direct_callers], &valid_positive_integer?/1, "expected positive integer" ) |> check(config, [:candidates, :limits], &valid_group?/1, "expected keyword list") |> check( config, [:candidates, :limits, :per_kind], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:candidates, :limits, :representative_calls], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:candidates, :limits, :representative_calls_per_edge], &valid_positive_integer?/1, "expected positive integer" ) |> check( config, [:source, :forbidden_modules], &valid_pattern_list?/1, "expected string or list of module patterns" ) |> check( config, [:source, :forbidden_files], &valid_pattern_list?/1, "expected string or list of path globs" ) |> check( config, [:forbidden_deps], &valid_forbidden_deps?/1, "expected list of {from_layer, to_layer}" ) |> check( config, [:allowed_effects], &valid_allowed_effects?/1, "expected list of {module_pattern, effects}" ) |> check( config, [:forbidden_calls], &valid_forbidden_calls?/1, "expected list of {caller_patterns, call_patterns} or {caller_patterns, call_patterns, opts}" ) |> check( config, [:public_api], &valid_pattern_list?/1, "expected string or list of module patterns" ) |> check( config, [:internal], &valid_pattern_list?/1, "expected string or list of module patterns" ) |> check( config, [:internal_callers], &valid_internal_callers?/1, "expected list of {internal_pattern, caller_patterns}" ) |> check( config, [:test_hints], &valid_test_hints?/1, "expected list of {path_glob, test_paths}" ) |> check( config, [:forbidden_modules], &valid_pattern_list?/1, "expected string or list of module patterns" ) |> check( config, [:forbidden_files], &valid_pattern_list?/1, "expected string or list of path globs" ) |> unknown_nested_key_errors(config, [:deps], [:forbidden]) |> unknown_nested_key_errors(config, [:calls], [:forbidden]) |> unknown_nested_key_errors(config, [:effects], [:allowed]) |> unknown_nested_key_errors(config, [:boundaries], [:public, :internal, :internal_callers]) |> unknown_nested_key_errors(config, [:tests], [:hints]) |> unknown_nested_key_errors(config, [:source], [:forbidden_modules, :forbidden_files]) |> unknown_nested_key_errors(config, [:risk], [:changed]) |> unknown_nested_key_errors(config, [:risk, :changed], [ :many_direct_callers, :wide_transitive_callers, :branch_heavy, :high_risk_reason_count ]) |> unknown_nested_key_errors(config, [:clone_analysis], [ :provider, :min_mass, :min_similarity, :max_clones ]) |> unknown_nested_key_errors(config, [:candidates], [:thresholds, :limits]) |> unknown_nested_key_errors(config, [:candidates, :thresholds], [ :mixed_effect_count, :branchy_function_branches, :high_risk_direct_callers ]) |> unknown_nested_key_errors(config, [:candidates, :limits], [ :per_kind, :representative_calls, :representative_calls_per_edge ]) |> unknown_nested_key_errors(config, [:smells], [ :fixed_shape_map, :behaviour_candidate ]) |> unknown_nested_key_errors(config, [:smells, :fixed_shape_map], [ :min_keys, :min_occurrences, :evidence_limit ]) |> unknown_nested_key_errors(config, [:smells, :behaviour_candidate], [ :min_modules, :min_callbacks, :module_display_limit, :callback_display_limit ]) end defp check(errors, config, path, validator, message) do case get_in_config(config, path) do :missing -> errors value -> if validator.(value), do: errors, else: [%Error{path: path, message: message} | errors] end end defp unknown_nested_key_errors(errors, config, path, allowed_keys) do case get_in_config(config, path) do value when is_list(value) -> nested_key_errors(value, path, allowed_keys) ++ errors _ -> errors end end defp nested_key_errors(value, path, allowed_keys) do if Keyword.keyword?(value) do value |> Keyword.keys() |> Enum.reject(&(&1 in allowed_keys)) |> Enum.map(fn key -> %Error{path: path ++ [key], message: "Unknown .reach.exs key #{inspect(key)}"} end) else [] end end defp nested(config, path, flat_key, default) do case get_in_config(config, path) do :missing -> Keyword.get(config, flat_key, default) value -> value end end defp get_in_config(config, [key]) do if Keyword.has_key?(config, key), do: Keyword.get(config, key), else: :missing end defp get_in_config(config, [key | rest]) do case get_in_config(config, [key]) do value when is_list(value) -> get_in_config(value, rest) :missing -> :missing _value -> :missing end end defp valid_group?(value), do: Keyword.keyword?(value) defp valid_positive_integer?(value), do: is_integer(value) and value > 0 defp valid_similarity?(value) when is_number(value), do: value >= 0.0 and value <= 1.0 defp valid_similarity?(_value), do: false defp valid_clone_provider?(value), do: value in [:ex_dna, false, nil] defp valid_layers?(value) when is_list(value) do Enum.all?(value, fn {layer, patterns} when is_atom(layer) -> valid_pattern_list?(patterns) _ -> false end) end defp valid_layers?(_value), do: false defp valid_forbidden_deps?(value) when is_list(value) do Enum.all?(value, fn {from, to} when is_atom(from) and is_atom(to) -> true _ -> false end) end defp valid_forbidden_deps?(_value), do: false defp valid_allowed_effects?(value) when is_list(value) do Enum.all?(value, fn {pattern, effects} when is_binary(pattern) and is_list(effects) -> Enum.all?(effects, &is_atom/1) _ -> false end) end defp valid_allowed_effects?(_value), do: false defp valid_forbidden_calls?(value) when is_list(value) do Enum.all?(value, fn {caller_patterns, call_patterns} -> valid_pattern_list?(caller_patterns) and valid_pattern_list?(call_patterns) {caller_patterns, call_patterns, opts} when is_list(opts) -> valid_pattern_list?(caller_patterns) and valid_pattern_list?(call_patterns) and valid_pattern_list?(Keyword.get(opts, :except, [])) _ -> false end) end defp valid_forbidden_calls?(_value), do: false defp valid_pattern_list?(value) when is_binary(value), do: true defp valid_pattern_list?(value) when is_list(value), do: Enum.all?(value, &is_binary/1) defp valid_pattern_list?(_value), do: false defp valid_internal_callers?(value) when is_list(value) do Enum.all?(value, fn {internal_pattern, caller_patterns} when is_binary(internal_pattern) -> valid_pattern_list?(caller_patterns) _ -> false end) end defp valid_internal_callers?(_value), do: false defp valid_test_hints?(value) when is_list(value) do Enum.all?(value, fn {pattern, tests} when is_binary(pattern) and is_list(tests) -> Enum.all?(tests, &is_binary/1) _ -> false end) end defp valid_test_hints?(_value), do: falseend