Current section
Files
Jump to
Current section
Files
lib/reach/check/architecture.ex
defmodule Reach.Check.Architecture do
@moduledoc "Validates `.reach.exs` architecture policies against project structure."
defmodule Result do
@moduledoc false
defstruct config: ".reach.exs",
status: "ok",
violations: [],
finding_count: 0,
baseline_count: 0
end
alias Reach.Check.Violation
alias Reach.Config
alias Reach.Effects
alias Reach.IR
def run(project, config) do
violations =
case Config.from_terms(config) do
{:ok, normalized} -> violations(project, normalized)
{:error, errors} -> Enum.map(errors, &Config.Error.to_violation/1)
end
%Result{status: if(violations == [], do: "ok", else: "failed"), violations: violations}
end
def violations(project, config) do
graph = layer_graph(project, config)
source_policy_violations(project, config) ++
layer_coverage_violations(project, config) ++
dependency_violations(project, config, graph) ++
public_boundary_violations(project, config) ++
forbidden_call_violations(project, config) ++
layer_cycle_violations(graph) ++
effect_policy_violations(project, config)
end
def config_violations(config) do
config
|> Config.errors()
|> Enum.map(&Config.Error.to_violation/1)
end
def layer_graph(project, config) do
config = Config.normalize(config)
layers = config.layers
module_by_file = module_by_file(project)
edges =
for({_id, node} <- project.nodes, remote_call?(node), do: node)
|> Enum.flat_map(fn node ->
caller_module = node.source_span && Map.get(module_by_file, node.source_span.file)
callee_module = node.meta[:module]
with caller when not is_nil(caller) <- caller_module,
callee when not is_nil(callee) <- callee_module,
caller_layer when not is_nil(caller_layer) <- module_layer(caller, layers),
callee_layer when not is_nil(callee_layer) <- module_layer(callee, layers),
true <- caller_layer != callee_layer do
[%{from: caller_layer, to: callee_layer, node: node, caller: caller, callee: callee}]
else
_ -> []
end
end)
%{edges: edges, adjacency: adjacency(edges)}
end
def dependency_violations(_project, config, layer_graph) do
config = Config.normalize(config)
layer_graph.edges
|> Enum.filter(&dependency_violation_edge?(&1, config.deps))
|> Enum.map(fn edge ->
edge
|> edge_violation_attrs()
|> Map.put(:type, :forbidden_dependency)
|> Violation.new()
end)
end
def module_by_file(project) do
for {_id, node} <- project.nodes,
node.type == :module_def and node.source_span,
into: %{},
do: {node.source_span.file, node.meta[:name]}
end
def module_matches_any?(module, patterns) do
name = inspect(module)
Enum.any?(patterns, &glob_match?(name, to_string(&1)))
end
def glob_match?(value, pattern) do
pattern_regex =
pattern
|> Regex.escape()
|> String.replace("\\*\\*", ".*")
|> String.replace("\\*", "[^/]*")
Regex.match?(~r/^#{pattern_regex}$/, value)
end
def function_effects(func, plugins \\ nil) do
func
|> IR.all_nodes()
|> Enum.map(&Effects.classify(&1, plugins))
|> Enum.uniq()
|> Enum.sort()
end
def concrete_effects(func, plugins \\ nil),
do: function_effects(func, plugins) -- [:pure, :unknown, :exception]
defp source_policy_violations(project, config) do
config = Config.normalize(config)
forbidden_module_violations(project, List.wrap(config.source.forbidden_modules)) ++
forbidden_file_violations(project, List.wrap(config.source.forbidden_files))
end
defp forbidden_module_violations(_project, []), do: []
defp forbidden_module_violations(project, patterns) do
for({_id, node} <- project.nodes, forbidden_module?(node, patterns), do: node)
|> Enum.map(fn node ->
Violation.new(
type: :forbidden_module,
module: inspect(node.meta[:name]),
file: node.source_span.file,
line: node.source_span.start_line,
rule: "configured forbidden module"
)
end)
end
defp forbidden_module?(node, patterns) do
node.type == :module_def and not is_nil(node.source_span) and
module_matches_any?(node.meta[:name], patterns)
end
defp forbidden_file_violations(_project, []), do: []
defp forbidden_file_violations(project, patterns) do
for({_id, node} <- project.nodes, node.source_span, do: node.source_span.file)
|> Enum.uniq()
|> Enum.filter(fn file -> Enum.any?(patterns, &glob_match?(file, to_string(&1))) end)
|> Enum.map(fn file ->
Violation.new(
type: :forbidden_file,
file: file,
rule: "configured forbidden file"
)
end)
end
defp adjacency(edges) do
Enum.reduce(edges, %{}, fn edge, acc ->
Map.update(acc, edge.from, MapSet.new([edge.to]), &MapSet.put(&1, edge.to))
end)
end
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp public_boundary_violations(project, config) do
config = Config.normalize(config)
public_api = List.wrap(config.boundaries.public)
internal = List.wrap(config.boundaries.internal)
internal_callers = config.boundaries.internal_callers
module_by_file = module_by_file(project)
for({_id, node} <- project.nodes, remote_call?(node), do: node)
|> Enum.flat_map(fn node ->
caller = node.source_span && Map.get(module_by_file, node.source_span.file)
callee = node.meta[:module]
cond do
caller == nil or callee == nil ->
[]
public_api != [] and top_level_api_call?(caller, callee, public_api, internal) ->
[
Violation.new(
type: :public_api_boundary,
caller_module: inspect(caller),
callee_module: inspect(callee),
file: node.source_span.file,
line: node.source_span.start_line,
call: "#{inspect(callee)}.#{node.meta[:function]}/#{node.meta[:arity]}",
rule: "calls into non-public API module"
)
]
internal != [] and internal_call_violation?(caller, callee, internal, internal_callers) ->
[
Violation.new(
type: :internal_boundary,
caller_module: inspect(caller),
callee_module: inspect(callee),
file: node.source_span.file,
line: node.source_span.start_line,
call: "#{inspect(callee)}.#{node.meta[:function]}/#{node.meta[:arity]}",
rule: "caller is not allowed to call configured internal module"
)
]
true ->
[]
end
end)
end
defp forbidden_call_violations(project, config) do
config = Config.normalize(config)
rules = config.calls.forbidden
module_by_file = module_by_file(project)
for({_id, node} <- project.nodes, remote_call?(node), do: node)
|> Enum.flat_map(&forbidden_call_violations_for_node(&1, rules, module_by_file))
end
defp forbidden_call_violations_for_node(node, rules, module_by_file) do
caller = node.source_span && Map.get(module_by_file, node.source_span.file)
call = remote_call_name(node)
rules
|> Enum.filter(&forbidden_call_rule_matches?(&1, caller, call))
|> Enum.map(fn _rule ->
Violation.new(
type: :forbidden_call,
caller_module: inspect(caller),
call: call,
file: node.source_span.file,
line: node.source_span.start_line,
rule: "configured forbidden call"
)
end)
end
defp forbidden_call_rule_matches?(_rule, nil, _call), do: false
defp forbidden_call_rule_matches?(rule, caller, call) do
{caller_patterns, call_patterns, except_patterns} = forbidden_call_rule(rule)
module_matches_any?(caller, List.wrap(caller_patterns)) and
not module_matches_any?(caller, List.wrap(except_patterns)) and
call_matches_any?(call, List.wrap(call_patterns))
end
defp forbidden_call_rule({caller_patterns, call_patterns}),
do: {caller_patterns, call_patterns, []}
defp forbidden_call_rule({caller_patterns, call_patterns, opts}) do
{caller_patterns, call_patterns, Keyword.get(opts, :except, [])}
end
defp remote_call_name(node) do
module = node.meta[:module] |> inspect() |> String.replace_leading("Elixir.", "")
function = node.meta[:function]
arity = node.meta[:arity]
"#{module}.#{function}/#{arity}"
end
defp call_matches_any?(call, patterns) do
Enum.any?(patterns, fn pattern ->
pattern = to_string(pattern)
glob_match?(call, pattern) or glob_match?(call_without_arity(call), pattern)
end)
end
defp call_without_arity(call), do: call |> String.split("/", parts: 2) |> List.first()
defp top_level_api_call?(caller, callee, public_api, internal) do
public_api
|> Enum.map(&public_api_namespace/1)
|> Enum.reject(&is_nil/1)
|> Enum.any?(fn namespace ->
module_under_namespace?(callee, namespace) and
not module_under_namespace?(caller, namespace) and
not module_matches_any?(callee, public_api) and
not module_matches_any?(callee, internal)
end)
end
defp internal_call_violation?(caller, callee, internal, internal_callers) do
module_matches_any?(callee, internal) and
not allowed_internal_caller?(caller, callee, internal_callers)
end
defp allowed_internal_caller?(caller, callee, internal_callers) do
matching_rule =
Enum.find(internal_callers, fn {internal_pattern, _caller_patterns} ->
module_matches_any?(callee, [internal_pattern])
end)
case matching_rule do
{_internal_pattern, caller_patterns} ->
module_matches_any?(caller, List.wrap(caller_patterns))
nil ->
module_namespace(caller) == module_namespace(callee)
end
end
defp public_api_namespace(pattern) do
pattern
|> to_string()
|> String.replace_suffix(".*", "")
|> String.trim_trailing("*")
|> String.trim_trailing(".")
|> case do
"" -> nil
namespace -> namespace
end
end
defp module_under_namespace?(module, namespace) do
name = module_name(module)
name == namespace or String.starts_with?(name, namespace <> ".")
end
defp module_namespace(module) do
module
|> module_name()
|> String.split(".", parts: 2)
|> List.first()
end
defp module_name(module) do
module
|> Atom.to_string()
|> String.replace_leading("Elixir.", "")
end
defp layer_cycle_violations(%{adjacency: adjacency, edges: edges}) do
adjacency
|> layer_cycle_components()
|> Enum.map(fn cycle ->
Violation.new(type: :layer_cycle, layers: cycle, edges: cycle_edge_witnesses(cycle, edges))
end)
end
defp layer_cycle_components(adjacency) do
adjacency
|> Enum.reduce(Graph.new(type: :directed), fn {layer, deps}, graph ->
Enum.reduce(deps, Graph.add_vertex(graph, layer), fn dep, graph ->
Graph.add_edge(graph, layer, dep)
end)
end)
|> Reach.GraphAlgorithms.cycle_components(&canonical_cycle/1)
end
defp canonical_cycle(cycle), do: Enum.sort(cycle)
defp effect_policy_violations(project, config) do
config = Config.normalize(config)
module_by_file = module_by_file(project)
for({_id, node} <- project.nodes, node.type == :function_def, do: node)
|> Enum.flat_map(&effect_policy_violation(&1, config, module_by_file, project.plugins))
end
defp effect_policy_violation(func, config, module_by_file, plugins) do
module =
func.meta[:module] || (func.source_span && Map.get(module_by_file, func.source_span.file))
with allowed when not is_nil(allowed) <- allowed_effects_for(module, config),
false <- allowed == :any,
effects <- function_effects(func, plugins),
disallowed when disallowed != [] <- effects -- allowed do
[
Violation.new(
type: :effect_policy,
module: inspect(module),
function: "#{func.meta[:name]}/#{func.meta[:arity]}",
allowed_effects: Enum.map(allowed, &to_string/1),
actual_effects: Enum.map(effects, &to_string/1),
disallowed_effects: Enum.map(disallowed, &to_string/1),
file: func.source_span && func.source_span.file,
line: func.source_span && func.source_span.start_line
)
]
else
_ -> []
end
end
defp allowed_effects_for(module, config) do
Enum.find_value(config.effects.allowed, fn {pattern, effects} ->
if module_matches_any?(module, [pattern]), do: effects
end) || allowed_effects_for_layer(module, config)
end
defp allowed_effects_for_layer(module, config) do
with layer when not is_nil(layer) <- module_layer(module, config.layers) do
Keyword.get(config.effects.by_layer, layer)
end
end
def remote_call?(node) do
node.type == :call and node.meta[:kind] == :remote and is_atom(node.meta[:module]) and
node.meta[:function] not in [:__aliases__, :{}]
end
defp layer_coverage_violations(project, config) do
config = Config.normalize(config)
case config.checks.layer_coverage do
nil ->
[]
coverage ->
project_modules(project)
|> Enum.reject(&module_matches_any?(&1, coverage.ignore))
|> Enum.flat_map(&layer_coverage_violations_for_module(&1, config.layers, coverage))
end
end
defp layer_coverage_violations_for_module(module, layers, coverage) do
matches = matching_layers(layers, module)
cond do
coverage.require_all_modules and matches == [] ->
[
Violation.new(
type: :missing_layer,
module: inspect(module),
rule: "module does not match any configured layer"
)
]
coverage.forbid_multiple_matches and length(matches) > 1 ->
[
Violation.new(
type: :multiple_layers,
module: inspect(module),
matched_layers: matches,
rule: "module matches multiple configured layers"
)
]
true ->
[]
end
end
defp project_modules(project) do
project.nodes
|> Enum.flat_map(fn
{_id, %{type: :module_def, meta: %{name: module}}} -> [module]
_ -> []
end)
|> Enum.uniq()
|> Enum.sort()
end
defp dependency_violation_edge?(%{from: layer, to: layer}, _deps), do: false
defp dependency_violation_edge?(edge, %{mode: :allowlist, allowed: allowed}) do
not allowed_dependency?(edge, allowed)
end
defp dependency_violation_edge?(edge, deps) do
Enum.any?(deps.forbidden, &forbidden_dependency_rule_matches?(&1, edge))
end
defp allowed_dependency?(edge, allowed) do
edge.to in Keyword.get(allowed, edge.from, [])
end
defp forbidden_dependency_rule_matches?({from, to}, edge),
do: edge.from == from and edge.to == to
defp forbidden_dependency_rule_matches?({from, to, opts}, edge) do
edge.from == from and edge.to == to and not dependency_excepted?(edge, opts)
end
defp forbidden_dependency_rule_matches?(_rule, _edge), do: false
defp dependency_excepted?(edge, opts) do
module_matches_any?(edge.caller, Keyword.get(opts, :except, [])) or
Enum.any?(Keyword.get(opts, :except_edges, []), fn {caller_pattern, callee_pattern} ->
module_matches_any?(edge.caller, [caller_pattern]) and
module_matches_any?(edge.callee, [callee_pattern])
end)
end
defp cycle_edge_witnesses(cycle, edges) do
cycle_set = MapSet.new(cycle)
cycle
|> Enum.flat_map(fn from ->
case Enum.find(edges, &(&1.from == from and MapSet.member?(cycle_set, &1.to))) do
nil ->
[]
edge ->
[
edge_violation_attrs(edge)
|> Map.take([
:caller_module,
:caller_layer,
:callee_module,
:callee_layer,
:file,
:line,
:call
])
]
end
end)
end
defp edge_violation_attrs(edge) do
%{
caller_module: inspect(edge.caller),
caller_layer: edge.from,
callee_module: inspect(edge.callee),
callee_layer: edge.to,
file: edge.node.source_span.file,
line: edge.node.source_span.start_line,
call: "#{inspect(edge.callee)}.#{edge.node.meta[:function]}/#{edge.node.meta[:arity]}"
}
end
defp module_layer(module, layers) do
layers
|> matching_layers(module)
|> List.first()
end
defp matching_layers(layers, module) when is_list(layers) do
layers
|> Enum.filter(fn {_layer, patterns} -> module_matches_any?(module, List.wrap(patterns)) end)
|> Enum.map(fn {layer, _patterns} -> layer end)
end
end