From 5997cba609e366bf7b3598fd9e1f5f59f280958e Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 12 Jun 2026 19:27:25 +0200 Subject: [PATCH 1/3] ci: add public API snapshot check --- .github/workflows/ci.yml | 3 + lib/mix/tasks/posthog.public_api.ex | 194 ++++++++++++++++++++++++++++ public_api.snapshot | 163 +++++++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 lib/mix/tasks/posthog.public_api.ex create mode 100644 public_api.snapshot diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 958ef6c..79a65ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,9 @@ jobs: - name: Compile with warnings as errors run: mix compile --warnings-as-errors + - name: Check public API snapshot + run: mix posthog.public_api --check + package: name: Hex package build runs-on: ubuntu-latest diff --git a/lib/mix/tasks/posthog.public_api.ex b/lib/mix/tasks/posthog.public_api.ex new file mode 100644 index 0000000..d1aeaed --- /dev/null +++ b/lib/mix/tasks/posthog.public_api.ex @@ -0,0 +1,194 @@ +defmodule Mix.Tasks.Posthog.PublicApi do + @moduledoc """ + Checks the committed public API snapshot. + + The snapshot is generated from compiled BEAM metadata. Public modules are + discovered from the current Mix application, `Code.fetch_docs/1` is used to + identify documented modules and function visibility, and `__info__/1` is used + to enumerate exported functions and macros. + + mix posthog.public_api --check + mix posthog.public_api --update + + ## Options + + * `--check` - Compare the generated snapshot with the committed snapshot. + This is the default. + * `--update` - Regenerate the committed snapshot. + * `--snapshot` - Snapshot path. Defaults to `public_api.snapshot`. + """ + + use Mix.Task + + @shortdoc "Checks the committed public API snapshot" + @default_snapshot "public_api.snapshot" + + @impl true + def run(args) do + {opts, _, _} = + OptionParser.parse(args, + strict: [check: :boolean, update: :boolean, snapshot: :string] + ) + + check? = Keyword.get(opts, :check, false) + update? = Keyword.get(opts, :update, false) + snapshot_path = Keyword.get(opts, :snapshot, @default_snapshot) + + if check? and update? do + Mix.raise("Pass only one of --check or --update") + end + + Mix.Task.run("compile") + + snapshot = generate_snapshot() + + if update? do + write_snapshot(snapshot_path, snapshot) + else + check_snapshot(snapshot_path, snapshot) + end + end + + defp generate_snapshot do + modules = public_modules() + + [ + "# This file is generated by `mix posthog.public_api --update`.", + "# Do not edit manually.", + "", + "# Public modules/functions/macros for #{Mix.Project.config()[:app]}", + "" + | Enum.flat_map(modules, &module_snapshot/1) + ] + |> Enum.join("\n") + |> then(&(&1 <> "\n")) + end + + defp public_modules do + app = Mix.Project.config()[:app] + + Application.load(app) + + case :application.get_key(app, :modules) do + {:ok, modules} -> + modules + |> Enum.filter(&posthog_module?/1) + |> Enum.reject(&hidden_module?/1) + |> Enum.sort_by(&inspect/1) + + :undefined -> + Mix.raise("Could not load compiled modules for #{inspect(app)}") + end + end + + defp posthog_module?(module) do + module + |> Atom.to_string() + |> String.starts_with?("Elixir.PostHog") + end + + defp hidden_module?(module) do + module + |> fetch_docs() + |> elem(0) + |> Kernel.==(:hidden) + end + + defp module_snapshot(module) do + {module_doc, docs} = fetch_docs(module) + docs_by_id = docs_by_id(docs) + + [ + inspect(module), + " module_doc: #{doc_visibility(module_doc)}" + | exported_snapshot(module, :functions, :function, docs_by_id) ++ + exported_snapshot(module, :macros, :macro, docs_by_id) ++ [""] + ] + end + + defp exported_snapshot(module, info_key, kind, docs_by_id) do + exports = module.__info__(info_key) + + lines = + exports + |> Enum.sort() + |> Enum.reject(fn {name, arity} -> + Map.get(docs_by_id, {kind, name, arity}, :none) == :hidden + end) + |> Enum.map(fn {name, arity} -> + doc = Map.get(docs_by_id, {kind, name, arity}, :none) + + " #{name}/#{arity} doc: #{doc_visibility(doc)}" + end) + + [" #{info_key}:" | empty_or_lines(lines)] + end + + defp empty_or_lines([]), do: [" (none)"] + defp empty_or_lines(lines), do: lines + + defp docs_by_id(docs) do + Map.new(docs, fn {{kind, name, arity}, _line, _signature, doc, _metadata} -> + {{kind, name, arity}, doc} + end) + end + + defp fetch_docs(module) do + case Code.fetch_docs(module) do + {:docs_v1, _anno, _beam_language, _format, module_doc, _metadata, docs} -> + {module_doc, docs} + + {:error, reason} -> + Mix.raise("Could not fetch docs for #{inspect(module)}: #{inspect(reason)}") + end + end + + defp doc_visibility(:hidden), do: "hidden" + defp doc_visibility(:none), do: "none" + defp doc_visibility(_doc), do: "documented" + + defp write_snapshot(path, snapshot) do + path + |> Path.dirname() + |> File.mkdir_p!() + + File.write!(path, snapshot) + Mix.shell().info("Updated #{path}") + end + + defp check_snapshot(path, snapshot) do + case File.read(path) do + {:ok, ^snapshot} -> + Mix.shell().info("Public API snapshot is up to date") + + {:ok, current} -> + write_generated_snapshot(path, snapshot) + + Mix.raise(""" + Public API snapshot is out of date. + + Run `mix posthog.public_api --update` and commit #{path}. + To inspect the change locally, run `git diff -- #{path}`. + + Current snapshot bytes: #{byte_size(current)} + Generated snapshot bytes: #{byte_size(snapshot)} + """) + + {:error, :enoent} -> + write_generated_snapshot(path, snapshot) + + Mix.raise(""" + Public API snapshot does not exist. + + Run `mix posthog.public_api --update` and commit #{path}. + """) + + {:error, reason} -> + Mix.raise("Could not read #{path}: #{inspect(reason)}") + end + end + + defp write_generated_snapshot(path, snapshot) do + File.write!(path, snapshot) + end +end diff --git a/public_api.snapshot b/public_api.snapshot new file mode 100644 index 0000000..b20b4c7 --- /dev/null +++ b/public_api.snapshot @@ -0,0 +1,163 @@ +# This file is generated by `mix posthog.public_api --update`. +# Do not edit manually. + +# Public modules/functions/macros for posthog + +PostHog + module_doc: documented + functions: + bare_capture/2 doc: none + bare_capture/4 doc: documented + capture/1 doc: none + capture/3 doc: documented + config/0 doc: none + config/1 doc: documented + get_context/0 doc: none + get_context/1 doc: documented + get_event_context/1 doc: none + get_event_context/2 doc: documented + set_context/1 doc: none + set_context/2 doc: documented + set_event_context/2 doc: none + set_event_context/3 doc: documented + macros: + (none) + +PostHog.API.Client + module_doc: documented + functions: + user_agent/0 doc: documented + macros: + (none) + +PostHog.Config + module_doc: documented + functions: + validate/1 doc: documented + validate!/1 doc: documented + macros: + (none) + +PostHog.Error + module_doc: documented + functions: + (none) + macros: + (none) + +PostHog.FeatureFlags + module_doc: documented + functions: + check/1 doc: none + check/3 doc: documented + check!/1 doc: none + check!/3 doc: documented + evaluate_flags/0 doc: none + evaluate_flags/2 doc: documented + flags/1 doc: none + flags/2 doc: documented + flags_for/0 doc: none + flags_for/2 doc: documented + get_feature_flag_result/1 doc: none + get_feature_flag_result/4 doc: documented + get_feature_flag_result!/1 doc: none + get_feature_flag_result!/4 doc: documented + set_in_context/1 doc: documented + set_in_context/2 doc: documented + macros: + (none) + +PostHog.FeatureFlags.Evaluations + module_doc: documented + functions: + accessed/1 doc: documented + enabled?/2 doc: documented + event_properties/1 doc: documented + get_flag/2 doc: documented + get_flag_payload/2 doc: documented + keys/1 doc: documented + only/2 doc: documented + only_accessed/1 doc: documented + macros: + (none) + +PostHog.FeatureFlags.Result + module_doc: documented + functions: + value/1 doc: documented + macros: + (none) + +PostHog.Handler + module_doc: documented + functions: + (none) + macros: + (none) + +PostHog.Integrations.LLMAnalytics.Req + module_doc: documented + functions: + attach/1 doc: none + attach/2 doc: documented + macros: + (none) + +PostHog.Integrations.Plug + module_doc: documented + functions: + (none) + macros: + (none) + +PostHog.LLMAnalytics + module_doc: documented + functions: + capture_current_span/1 doc: none + capture_current_span/3 doc: documented + capture_span/1 doc: none + capture_span/3 doc: documented + get_root_span/0 doc: none + get_root_span/1 doc: documented + get_session/0 doc: none + get_session/1 doc: documented + get_trace/0 doc: none + get_trace/1 doc: documented + set_root_span/1 doc: none + set_root_span/2 doc: documented + set_session/0 doc: none + set_session/2 doc: documented + set_trace/0 doc: none + set_trace/2 doc: documented + start_span/0 doc: none + start_span/2 doc: documented + macros: + (none) + +PostHog.Supervisor + module_doc: documented + functions: + child_spec/1 doc: documented + start_link/1 doc: documented + macros: + (none) + +PostHog.Test + module_doc: documented + functions: + all_captured/0 doc: none + all_captured/1 doc: documented + allow/2 doc: none + allow/3 doc: documented + set_posthog_shared/0 doc: none + set_posthog_shared/1 doc: documented + macros: + (none) + +PostHog.UnexpectedResponseError + module_doc: documented + functions: + (none) + macros: + (none) + From f927bbf84be09706f6e65e72feeadbf2aa959f7f Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 15 Jun 2026 11:35:58 +0200 Subject: [PATCH 2/3] fix: keep public API check read-only --- lib/mix/tasks/posthog.public_api.ex | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/mix/tasks/posthog.public_api.ex b/lib/mix/tasks/posthog.public_api.ex index d1aeaed..0734a21 100644 --- a/lib/mix/tasks/posthog.public_api.ex +++ b/lib/mix/tasks/posthog.public_api.ex @@ -162,21 +162,17 @@ defmodule Mix.Tasks.Posthog.PublicApi do Mix.shell().info("Public API snapshot is up to date") {:ok, current} -> - write_generated_snapshot(path, snapshot) - Mix.raise(""" Public API snapshot is out of date. Run `mix posthog.public_api --update` and commit #{path}. - To inspect the change locally, run `git diff -- #{path}`. + To inspect the change locally, run `mix posthog.public_api --update` and then `git diff -- #{path}`. Current snapshot bytes: #{byte_size(current)} Generated snapshot bytes: #{byte_size(snapshot)} """) {:error, :enoent} -> - write_generated_snapshot(path, snapshot) - Mix.raise(""" Public API snapshot does not exist. @@ -187,8 +183,4 @@ defmodule Mix.Tasks.Posthog.PublicApi do Mix.raise("Could not read #{path}: #{inspect(reason)}") end end - - defp write_generated_snapshot(path, snapshot) do - File.write!(path, snapshot) - end end From 942d782148842a1957eda0b1fcd211783b1827ea Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 15 Jun 2026 17:16:13 +0200 Subject: [PATCH 3/3] fix: show public API snapshot diff --- lib/mix/tasks/posthog.public_api.ex | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/posthog.public_api.ex b/lib/mix/tasks/posthog.public_api.ex index 0734a21..b12d2ed 100644 --- a/lib/mix/tasks/posthog.public_api.ex +++ b/lib/mix/tasks/posthog.public_api.ex @@ -168,8 +168,8 @@ defmodule Mix.Tasks.Posthog.PublicApi do Run `mix posthog.public_api --update` and commit #{path}. To inspect the change locally, run `mix posthog.public_api --update` and then `git diff -- #{path}`. - Current snapshot bytes: #{byte_size(current)} - Generated snapshot bytes: #{byte_size(snapshot)} + Diff: + #{snapshot_diff(path, current, snapshot)} """) {:error, :enoent} -> @@ -183,4 +183,26 @@ defmodule Mix.Tasks.Posthog.PublicApi do Mix.raise("Could not read #{path}: #{inspect(reason)}") end end + + defp snapshot_diff(path, current, snapshot) do + generated_path = + System.tmp_dir!() + |> Path.join("#{Path.basename(path)}.generated-#{System.unique_integer([:positive])}") + + try do + File.write!(generated_path, snapshot) + + {diff, _status} = + System.cmd("git", ["diff", "--no-index", "--", path, generated_path], + stderr_to_stdout: true + ) + + diff + rescue + _error -> + "Could not generate diff. Current snapshot bytes: #{byte_size(current)}. Generated snapshot bytes: #{byte_size(snapshot)}." + after + File.rm(generated_path) + end + end end