Packages
mob_dev
0.6.17
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_dev/plugin/report.ex
defmodule MobDev.Plugin.Report do
@moduledoc """
Pure transforms behind `mix mob.plugins`: turn discovered deps + the
activation list into report rows, and render them as a table.
Kept separate from the Mix task (which does the filesystem/config I/O) so
the classification and formatting are unit-testable without a project on disk.
"""
alias MobDev.Plugin.Manifest
@typedoc "An app and its loaded manifest (nil = no manifest / tier 0)."
@type dep :: {atom(), map() | nil}
@typedoc "Vetting summary attached to a row by `with_vetting/2`."
@type vetting :: %{
audit: %{high: non_neg_integer(), medium: non_neg_integer(), low: non_neg_integer()},
capability_errors: non_neg_integer()
}
@typedoc "One row of `mix mob.plugins` output."
@type row :: %{
name: atom(),
tier: 0..4,
hot_pushable: true | false | :partial,
status: :activated | :installed,
manifest?: boolean(),
description: String.t() | nil,
vetting: vetting() | nil
}
@doc """
Builds sorted report rows from all deps and the activated-plugin list.
A dep is a plugin row if it ships a manifest *or* is named in
`config :mob, :plugins`. A tier-0 plugin (no manifest) therefore only
appears once activated — otherwise it's indistinguishable from an ordinary
library dependency. Status is `:activated` when in the list, else
`:installed` (present in deps, not yet activated).
"""
@spec rows([dep()], [atom()]) :: [row()]
def rows(deps, activated) do
deps
|> Enum.filter(fn {name, manifest} -> manifest != nil or name in activated end)
|> Enum.map(fn {name, manifest} ->
%{
name: name,
tier: Manifest.tier(manifest),
hot_pushable: Manifest.hot_pushable(manifest),
status: if(name in activated, do: :activated, else: :installed),
manifest?: manifest != nil,
description: manifest && Map.get(manifest, :description),
vetting: nil
}
end)
|> Enum.sort_by(& &1.name)
end
@doc """
Adds a `:vetting` summary to each activated row by running the static-analysis
audit (`MobDev.Plugin.Audit.audit_plugin/2`) and the capability checks
(`MobDev.Plugin.Validator.activated_capability_errors/1`) over the plugin's
source tree.
`dep_dirs` is a map of `plugin_name => plugin_dir` (absolute path). Rows
whose plugin is not in `dep_dirs` (or which are `:installed` only — not
activated) get `vetting: nil` and render as a dash. This function performs
IO; pure rendering stays in `render/1`.
"""
@spec with_vetting([row()], %{atom() => Path.t()}) :: [row()]
def with_vetting(rows, dep_dirs) when is_map(dep_dirs) do
activated_with_manifests =
for row <- rows,
row.status == :activated,
row.manifest?,
dir = Map.get(dep_dirs, row.name),
is_binary(dir),
do: row.name
capability_errors_by_plugin =
compute_capability_errors_by_plugin(rows, dep_dirs, activated_with_manifests)
Enum.map(rows, fn row ->
case Map.get(dep_dirs, row.name) do
nil ->
row
dir when row.status == :activated and row.manifest? ->
manifest = load_manifest(dir)
audit = MobDev.Plugin.Audit.audit_plugin(dir, manifest).summary
%{
row
| vetting: %{
audit: audit,
capability_errors: Map.get(capability_errors_by_plugin, row.name, 0)
}
}
_ ->
row
end
end)
end
defp compute_capability_errors_by_plugin(rows, dep_dirs, activated_with_manifests) do
for(
name <- activated_with_manifests,
dir = Map.fetch!(dep_dirs, name),
manifest = load_manifest(dir),
do: {dir, manifest, find_row_name(rows, name)}
)
|> capability_errors_for()
end
@doc false
# Pure kernel: counts capability errors per plugin from {dir, manifest, name}
# tuples. Extracted so the Validator arg order — `(manifest, dir)`, NOT
# `(dir, manifest)` — is regression-tested without the dep-dir/filesystem
# discovery above. Passing them swapped raises FunctionClauseError.
@spec capability_errors_for([{Path.t(), map(), term()}]) ::
%{optional(term()) => non_neg_integer()}
def capability_errors_for(plugins) do
Enum.into(plugins, %{}, fn {dir, manifest, name} ->
errors =
MobDev.Plugin.Validator.validate_swift_imports(manifest, dir) ++
MobDev.Plugin.Validator.validate_android_permissions(manifest, dir)
{name, length(errors)}
end)
end
defp find_row_name(rows, name), do: Enum.find_value(rows, name, &(&1.name == name && name))
defp load_manifest(dir) do
case MobDev.Plugin.Manifest.load(dir) do
{:ok, manifest} -> manifest
_ -> %{}
end
end
@doc """
Renders rows as a human-readable table (or a friendly empty message).
"""
@spec render([row()]) :: String.t()
def render([]) do
"No mob plugins found.\n\n" <>
"A plugin appears here once it ships a priv/mob_plugin.exs manifest, or\n" <>
"is activated in mob.exs via `config :mob, :plugins, [...]`."
end
def render(rows) do
with_vetting? = Enum.any?(rows, &(&1.vetting != nil))
header =
" " <>
pad("PLUGIN", 26) <>
pad("TIER", 8) <>
pad("HOT-PUSH", 10) <>
if(with_vetting?, do: pad("VETTING", 16), else: "") <>
"STATUS"
rule_width = if with_vetting?, do: 74, else: 58
lines = Enum.map(rows, &render_row(&1, with_vetting?))
([header, " " <> String.duplicate("─", rule_width)] ++ lines ++ ["", legend(rows)])
|> Enum.join("\n")
end
defp render_row(row, with_vetting?) do
status =
case row.status do
:activated -> "activated"
:installed -> "installed (not activated)"
end
note = if row.manifest?, do: "", else: " — no manifest (regular dep)"
" " <>
pad(to_string(row.name), 26) <>
pad("tier #{row.tier}", 8) <>
pad(hot_push(row.hot_pushable), 10) <>
if(with_vetting?, do: pad(render_vetting(row.vetting), 16), else: "") <>
status <> note
end
defp render_vetting(nil), do: "—"
defp render_vetting(%{audit: %{high: 0, medium: 0, low: 0}, capability_errors: 0}),
do: "clean"
defp render_vetting(%{audit: audit, capability_errors: caps}) do
audit_part =
[{audit.high, "H"}, {audit.medium, "M"}, {audit.low, "L"}]
|> Enum.filter(fn {n, _} -> n > 0 end)
|> Enum.map_join(" ", fn {n, code} -> "#{n}#{code}" end)
case {audit_part, caps} do
{"", 0} -> "clean"
{"", n} -> "caps:#{n}"
{a, 0} -> a
{a, n} -> "caps:#{n} #{a}"
end
end
defp hot_push(true), do: "yes"
defp hot_push(false), do: "no"
defp hot_push(:partial), do: "partial"
defp legend(rows) do
not_activated = Enum.any?(rows, &(&1.status == :installed))
base =
"tier 0 = pure Elixir · 1 = NIF · 2 = component · 3 = screens · 4 = sub-app"
if not_activated do
base <>
"\nInstalled-but-not-activated plugins contribute nothing until added to\n" <>
"`config :mob, :plugins` in mob.exs."
else
base
end
end
defp pad(s, n), do: String.pad_trailing(s, n)
# ── audit rendering ───────────────────────────────────────────────────────
@doc """
Renders the output of `MobDev.Plugin.Audit.audit_plugin/2` (one or more)
for `mix mob.audit_plugins`. Pure.
`reports` is a list of `MobDev.Plugin.Audit.report()` maps. Output is
ordered by plugin name; findings within a plugin are already sorted by
severity → file → line by `audit_plugin/2`. A trailing summary line tallies
every severity across all reports.
"""
@spec render_audit([map()]) :: String.t()
def render_audit([]) do
"No plugins audited.\n\n" <>
"A plugin appears here once activated in `config :mob, :plugins`."
end
def render_audit(reports) do
sections =
reports
|> Enum.sort_by(fn r -> r.plugin || :"" end)
|> Enum.map(&render_audit_section/1)
roll_up = render_audit_summary(reports)
Enum.join(sections ++ ["", roll_up], "\n")
end
defp render_audit_section(%{plugin: plugin, findings: [], kotlin_or_swift_skipped: skipped}) do
base = " #{plugin_label(plugin)} — no findings"
if skipped do
base <> "\n (Kotlin/Swift sources present but not yet audited.)"
else
base
end
end
defp render_audit_section(%{
plugin: plugin,
findings: findings,
kotlin_or_swift_skipped: skipped
}) do
header = " #{plugin_label(plugin)}"
lines = Enum.map(findings, &render_finding/1)
tail =
if skipped do
[" (Kotlin/Swift sources present but not yet audited.)"]
else
[]
end
Enum.join([header] ++ lines ++ tail, "\n")
end
defp render_finding(f) do
badge =
case f.severity do
:high -> "✗ HIGH "
:medium -> "⚠ MED "
:low -> "· LOW "
end
loc =
case f.line do
nil -> f.file
ln -> "#{f.file}:#{ln}"
end
snippet = if f.snippet == "", do: "", else: " — #{f.snippet}"
" #{badge}[#{f.rule}] #{loc}#{snippet}\n #{f.hint}"
end
defp render_audit_summary(reports) do
totals =
Enum.reduce(reports, %{high: 0, medium: 0, low: 0}, fn r, acc ->
Map.merge(acc, r.summary, fn _k, a, b -> a + b end)
end)
count = length(reports)
plural = if count == 1, do: "plugin", else: "plugins"
" ── Audit summary ─────────────────────────────────────────────────\n" <>
" #{count} #{plural} scanned · " <>
"#{totals.high} high · #{totals.medium} medium · #{totals.low} low"
end
defp plugin_label(nil), do: "(unnamed plugin)"
defp plugin_label(name) when is_atom(name), do: to_string(name)
end