Current section

Files

Jump to
reach lib reach config.ex
Raw

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: false
end