Current section

Files

Jump to
threadline mix.exs
Raw

mix.exs

defmodule Threadline.MixProject do
  use Mix.Project

  @version "1.0.1"
  @source_url "https://github.com/szTheory/threadline"

  def cli do
    # Run the whole CI chain in :test so `test` picks up config/test.exs (Postgres, repo).
    # Topology tasks need `test/support` (Threadline.Test.Repo) on the compile path.
    [
      preferred_envs: [
        "ci.all": :test,
        "verify.dialyzer": :dev,
        "verify.dialyzer_slice": :test,
        "verify.release": :dev,
        "verify.bump_rehearsal": :dev,
        "verify.test": :test,
        "verify.test_partitioned": :test,
        # `test.reset` runs `ecto.drop -r Threadline.Test.Repo`, and that repo only
        # exists on the :test compile path (see elixirc_paths/1) — without this it
        # fails with `Could not load Threadline.Test.Repo, error: :nofile`.
        "test.reset": :test,
        "test.setup": :test,
        "verify.mechanical": :test,
        "verify.critic_trust": :test,
        "verify.capture": :test,
        "verify.topology": :test,
        "threadline.verify_topology": :test,
        "verify.example": :test,
        "verify.hex_evaluator": :test
      ]
    ]
  end

  def project do
    [
      app: :threadline,
      version: @version,
      # Support contract: Elixir 1.15 floor / 1.17.3 current, OTP 26 min / 27 current,
      # PostgreSQL 15 min / 16 current. The floor is honored by the CI `min` lane (full
      # suite on 1.15/OTP26/PG15) — NOT by raising this requirement. Do not bump "~> 1.15"
      # to a newer minor: that would strand applications on the supported floor.
      # The CI `latest` lane also runs the suite on the newest stable Elixir/OTP/
      # PostgreSQL (exact pins in ci.yml). That is tested-on evidence, not a support
      # promise, and never a reason to raise this requirement.
      elixir: "~> 1.15",
      elixirc_paths: elixirc_paths(Mix.env()),
      start_permanent: Mix.env() == :prod,
      deps: deps(),
      aliases: aliases(),
      package: package(),
      name: "Threadline",
      description: "Audit platform for Elixir teams using Phoenix, Ecto, and PostgreSQL",
      source_url: @source_url,
      docs: docs(),
      dialyzer: [
        plt_local_path: ".dialyzer",
        plt_core_path: ".dialyzer",
        plt_add_apps: [
          :mix,
          :ex_unit,
          :phoenix,
          :phoenix_live_view,
          :phoenix_html,
          :phoenix_pubsub,
          :oban,
          :ex_aws,
          :ex_aws_s3,
          :req,
          :sweet_xml
        ],
        flags: [:unmatched_returns, :extra_return, :missing_return, :underspecs, :error_handling],
        ignore_warnings: ".dialyzer_ignore.exs",
        list_unused_filters: true
      ]
    ]
  end

  def application do
    [
      extra_applications: [:logger, :crypto],
      mod: {Threadline.Application, []}
    ]
  end

  defp elixirc_paths(:test), do: ["lib", "test/support"]
  defp elixirc_paths(_), do: ["lib"]

  defp deps do
    [
      {:ecto_sql, "~> 3.10"},
      {:postgrex, "~> 0.17"},
      {:jason, "~> 1.4"},
      {:nimble_csv, "~> 1.2"},
      {:plug, "~> 1.15"},
      {:telemetry, "~> 1.2"},
      {:phoenix, "~> 1.7", optional: true},
      {:phoenix_live_view, "~> 1.0", optional: true},
      {:phoenix_html, "~> 4.0", optional: true},
      {:phoenix_pubsub, "~> 2.1", optional: true},
      {:oban, "~> 2.15", optional: true},
      {:ex_aws, "~> 2.7", optional: true},
      {:ex_aws_s3, "~> 2.4", optional: true},
      {:req, "~> 0.7", optional: true},
      {:sweet_xml, "~> 0.7", optional: true},
      {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
      {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
      {:ex_doc, "~> 0.40", only: :dev, runtime: false},
      {:lazy_html, "~> 0.1.0", only: :test},
      # Test-only. Powers ExUnitProperties property tests for generated
      # identifier names. Never reaches consumers of the published package.
      # 1.4 floors at Elixir ~> 1.14, so the 1.15 support floor holds; the
      # dependency floor guard in the test suite enforces this invariant.
      {:stream_data, "~> 1.4", only: :test},
      # Test-only. Parses .github issue forms as real YAML so the
      # community-health render contract validates GitHub's issue-forms schema
      # instead of pattern-matching prose. Never reaches consumers of the
      # published package.
      #
      # Pinned to the 2.11.x series deliberately. 2.12 floors at Elixir
      # ~> 1.17 and 2.12.0 at ~> 1.18; either would silently falsify this
      # package's own Elixir 1.15 support floor, since a test-only dependency
      # still has to install on the minimum supported lane. 2.11.0 floors at
      # ~> 1.8. Use `~> 2.11.0` rather than `~> 2.11` — the latter admits
      # 2.12.x and reintroduces the break. The dependency floor guard in the
      # test suite enforces this invariant.
      {:yaml_elixir, "~> 2.11.0", only: :test, runtime: false}
    ]
  end

  defp aliases do
    [
      "verify.format": ["format --check-formatted"],
      "verify.credo": ["credo --strict"],
      "verify.dialyzer": ["dialyzer --no-check"],
      # Live Dialyzer slice proof: the one `:live_dialyzer` test, which shells out to
      # bin/verify-dialyzer-slice and fails closed when Dialyzer did not complete. It
      # needs the dev PLT that `verify.dialyzer` builds, so it is excluded from default
      # `mix test` (test/test_helper.exs) and runs only in the `verify-dialyzer` CI job
      # and in ci.all right after `verify.dialyzer`.
      "verify.dialyzer_slice": [
        "test test/threadline/dialyzer_slice_contract_test.exs --only live_dialyzer"
      ],
      "verify.test": ["test"],
      # CI's partitioned test step (SUITE-02): runs bin/ci-test-partitions, N
      # concurrent `mix test <files>` processes (files split by measured weight,
      # test/partition_weights.txt), each on its own database.
      # The local default stays whole and unpartitioned (`mix verify.test` above).
      "verify.test_partitioned": &verify_test_partitioned/1,
      "verify.threadline": ["threadline.verify_coverage"],
      "verify.release": &verify_release/1,
      # Simulate the NEXT MINOR release commit and run the release gates against
      # it. A whole class of release defect — a doc pin, a marked prose line, a
      # contract assertion that hardcodes the current version — is green at the
      # current version by construction and only becomes observable once the
      # version has moved, so a gate that only ever measures the current version
      # cannot see it. Not folded into `ci.all`: it is a release-lane check and
      # follows `verify.release`'s precedent of staying out of the per-change
      # gate. The CI topology contract asserts both halves of that placement.
      "verify.bump_rehearsal": &verify_bump_rehearsal/1,
      "verify.topology": ["threadline.verify_topology"],
      "verify.example": &verify_example/1,
      "verify.example_browser": &verify_example_browser/1,
      "verify.example_browser_light": &verify_example_browser_light/1,
      "verify.operator_stress": &verify_operator_stress/1,
      # Deterministic mechanical gate. Pure-Elixir arithmetic over
      # the committed Tier A scorecard JSON — NO browser, NO network, NO LLM. A MODE-A
      # violation or MODE-B ratchet regression blocks the change. A focused maintainer
      # command: its test file already runs in `verify.test` (and so in ci.all and in
      # the verify-test CI job), which keeps ci.all from running it twice.
      "verify.mechanical": ["test test/threadline/operator_surface/mechanical_checker_test.exs"],
      # Critic trust gate. Pure-Elixir guard over the committed
      # design-system-ledger.json critic_trust block and golden-set.json — NO browser,
      # NO network, NO LLM. Asserts validated lenses meet the bar; seeds validated:false
      # until the committed oracle cohort satisfies the rank-based trust bar. A focused
      # maintainer command: its test file already runs in `verify.test` (and so in ci.all).
      "verify.critic_trust": ["test test/threadline/operator_surface/critic_trust_test.exs"],
      # Local-only adversarial critic runner. Requires ANTHROPIC_API_KEY
      # (maintainer-local only — never committed, never in CI). Excluded from ci.all (same
      # precedent as verify.flake). When ANTHROPIC_API_KEY is absent, exits 0 with a skip
      # message so contributors without a key are unaffected. See CONTRIBUTING.md.
      "verify.ui_critique": &verify_ui_critique/1,
      "verify.capture": &verify_capture/1,
      "verify.operator_component_contracts": &verify_operator_component_contracts/1,
      "verify.hex_evaluator": &verify_hex_evaluator/1,
      "verify.bench": &verify_bench/1,
      # Per-PR proof that bench compiles bare (SUITE-05): unlike verify.bench above it
      # runs no benchmark and is wired into ci.all right after verify.compile_no_optional.
      "verify.bench_compile": &verify_bench_compile/1,
      # Per-PR supply-chain gate (SUP-02): asserts Hex >= 2.5.1, then runs
      # `deps.unlock --check-unused` + `hex.audit` over root, bench and the
      # example app's lockfiles via bin/verify-deps-audit. CLI args are
      # ignored on purpose so the gate cannot be narrowed from the command
      # line; see bin/verify-deps-audit for the full contract.
      "verify.deps_audit": &verify_deps_audit/1,
      # HYG-02 per-PR gate: scans tracked text for machine-local paths via
      # bin/verify-repo-hygiene. CLI args are ignored on purpose so the gate
      # cannot be narrowed from the command line; see bin/verify-repo-hygiene
      # for the full contract.
      "verify.repo_hygiene": &verify_repo_hygiene/1,
      "verify.compile_no_optional": ["compile --no-optional-deps --warnings-as-errors"],
      # GATE-04: zero compile-connected module cycles (runtime association edges are allowed).
      "verify.xref_cycles": [
        "xref graph --format cycles --label compile-connected --fail-above 0"
      ],
      # Flake detection: re-run the suite until a failure surfaces (each repeat
      # uses a fresh seed): 8 repeats, 9 suite runs (re-derived from dispatch
      # run 36888506162 after a 5x property-run scale raised the per-run
      # cost on this lane). Runs weekly plus on dispatch in the Flake
      # Detection workflow, under a time budget; not part of `ci.all` so
      # per-PR CI stays fast. Longer local soaks use
      # `mix test --repeat-until-failure N`. See the "Deterministic tests"
      # section in CONTRIBUTING.md.
      "verify.flake": ["test --repeat-until-failure 8"],
      # HYG-03: proves a full `mix test` run leaves nothing behind in the system
      # temp dir, via bin/verify-temp-leaks (private TMPDIR + leftover scan).
      # Opt-in / not in `ci.all`, same rationale as verify.flake above — it
      # re-runs the whole suite, so per-PR CI stays fast. CLI args pass through
      # to `mix test` (e.g. `mix verify.temp_leaks test/some_test.exs`).
      "verify.temp_leaks": &verify_temp_leaks/1,
      # Prepare a fresh clone's test environment, then run the suite.
      # The example app's deps are fetched because the library suite itself shells into
      # examples/threadline_phoenix (test/threadline/operator_surface/stress_router_test.exs)
      # and cannot boot that router without them. Everything else the suite needs —
      # creating and migrating the test database — test/test_helper.exs already does.
      "test.setup": ["cmd --cd examples/threadline_phoenix mix deps.get", "test"],
      # Restore a recreate-by-default posture. Threadline's effective
      # default is --keepdb with NO staleness check, which is exactly how a database
      # predating the storage-schema migration produced ~81 misleading failures. Only the
      # drop is needed here: test/test_helper.exs already calls storage_up/1 and runs the
      # migrator on the next run, and the storage-schema tripwire catches the stale case.
      "test.reset": ["ecto.drop --quiet -r Threadline.Test.Repo", "test.setup"],
      "ci.all": [
        "verify.format",
        "verify.credo",
        # Per-PR required gate (SUP-02), unlike the release-lane verify.bench /
        # verify.release / verify.bump_rehearsal above and below, which stay out
        # of ci.all on purpose. Fast and network-bound, so it runs early.
        "verify.deps_audit",
        "verify.repo_hygiene",
        "compile --warnings-as-errors",
        "verify.xref_cycles",
        "verify.compile_no_optional",
        "verify.bench_compile",
        "verify.test",
        "verify.threadline",
        "verify.example",
        # Strict full-build Dialyzer gate. The dedicated CI job runs the same command
        # on the exact current toolchain and owns the PLT cache lifecycle.
        # `ci.all` itself runs in :test so the test database and support modules are
        # available to the surrounding gates. Dialyzer is intentionally a dev-only
        # analysis, matching the dedicated CI job and preventing test/support from
        # silently expanding the warning surface in a fresh checkout.
        "cmd env MIX_ENV=dev mix verify.dialyzer",
        # The live Dialyzer slice proof needs the dev PLT the line above just built.
        # The `cmd` form is required: `verify.test` already ran the `test` task, and a
        # second bare `test` entry in the same alias chain is a Mix no-op.
        "cmd env MIX_ENV=test mix verify.dialyzer_slice",
        # The critic trust and mechanical gates are not listed here: their test files
        # already ran in `verify.test` above, which still precedes the browser lane, so
        # a ratchet or token violation fails fast without running the same tests twice.
        # Browser e2e last (slowest; needs Node + Playwright). Reproduce the
        # committed CI lane exactly: only its two voting projects run and CI's
        # explicitly platform-local screenshot guards remain outside the gate.
        "cmd env CI=true mix verify.example_browser --project=desktop-chromium --project=mobile-chromium"
      ]
    ]
  end

  defp verify_bench(_args) do
    cmd =
      "bash -lc 'set -euo pipefail && cd bench && mix deps.get && MIX_ENV=test mix run scripts/seed_audit_changes.exs && MIX_ENV=test mix run audit_capture_bench.exs && MIX_ENV=test mix run timeline_query_bench.exs && MIX_ENV=test mix run redaction_and_changed_from_bench.exs && MIX_ENV=test mix run pk_capture_bench.exs'"

    case Mix.shell().cmd(cmd) do
      0 -> :ok
      status -> Mix.raise("verify.bench failed (#{status})")
    end
  end

  defp verify_bench_compile(_args) do
    # `unset MIX_ENV` keeps this proof bare even when the maintainer runs
    # `MIX_ENV=test mix ci.all` — the point is proving bare `mix compile` works via
    # bench/mix.exs's own `preferred_envs`, not whatever the parent shell exports.
    cmd =
      "bash -lc 'set -euo pipefail && unset MIX_ENV && cd bench && mix deps.get && mix compile --warnings-as-errors'"

    case Mix.shell().cmd(cmd) do
      0 -> :ok
      status -> Mix.raise("verify.bench_compile failed (#{status})")
    end
  end

  defp verify_deps_audit(_args) do
    case Mix.shell().cmd("bin/verify-deps-audit") do
      0 -> :ok
      status -> Mix.raise("verify.deps_audit failed (#{status})")
    end
  end

  defp verify_test_partitioned(_args) do
    case Mix.shell().cmd("bin/ci-test-partitions") do
      0 -> :ok
      status -> Mix.raise("verify.test_partitioned failed (#{status})")
    end
  end

  defp verify_repo_hygiene(_args) do
    case Mix.shell().cmd("bin/verify-repo-hygiene") do
      0 -> :ok
      status -> Mix.raise("verify.repo_hygiene failed (#{status})")
    end
  end

  defp verify_temp_leaks(args) do
    script = Path.expand("bin/verify-temp-leaks")

    case System.cmd(script, args, into: IO.stream(:stdio, :line)) do
      {_output, 0} -> :ok
      {_output, status} -> Mix.raise("verify.temp_leaks failed (#{status})")
    end
  end

  defp verify_bump_rehearsal(_args) do
    case Mix.shell().cmd("bin/verify-bump-rehearsal") do
      0 -> :ok
      status -> Mix.raise("verify.bump_rehearsal failed (#{status})")
    end
  end

  defp verify_release(_args) do
    ensure_clean_tree!()

    [
      "bin/verify-release-shape",
      "mix test test/threadline/release_artifact_contract_test.exs test/threadline/ci_topology_contract_test.exs",
      "MIX_ENV=dev mix docs --warnings-as-errors",
      "mix hex.build"
    ]
    |> Enum.each(&run_release_step!/1)
  end

  defp verify_example(_args) do
    # Decline interactive Hex re-auth when nested `mix deps.get` runs without cached Hex token.
    cmd =
      "bash -lc 'set -euo pipefail && cd examples/threadline_phoenix && printf \"n\\n\" | mix deps.get && mix compile --warnings-as-errors && mix ecto.create --quiet -r ThreadlinePhoenix.Repo && mix test'"

    case Mix.shell().cmd(cmd, env: [{"MIX_ENV", "test"}]) do
      0 -> :ok
      status -> Mix.raise("verify.example failed (#{status})")
    end
  end

  defp verify_example_browser(args) do
    script = Path.expand("examples/threadline_phoenix/e2e/run-e2e.sh")

    env =
      System.get_env()
      |> Enum.map(fn {k, v} -> {k, v} end)
      |> Kernel.++([
        {"DB_HOST", System.get_env("DB_HOST") || "localhost"},
        {"DB_PORT", System.get_env("DB_PORT") || "5432"},
        {"THREADLINE_E2E", "1"}
      ])

    case System.cmd("bash", [script | args], env: env, into: IO.stream(:stdio, :line)) do
      {_output, 0} -> :ok
      {_output, status} -> Mix.raise("verify.example_browser failed (#{status})")
    end
  end

  # Light-lane affordance proof. Sets
  # THREADLINE_E2E_THEME=system so run-e2e.sh recompiles the example operator
  # mount to the :system lane and runs ONLY the colorScheme:"light" project
  # (scoped to operator-accessibility.spec.ts) — proving the affordances are
  # mode-independent under the light branch. The default (dark) browser lane
  # stays `verify.example_browser`.
  defp verify_example_browser_light(args) do
    script = Path.expand("examples/threadline_phoenix/e2e/run-e2e.sh")

    env =
      System.get_env()
      |> Enum.map(fn {k, v} -> {k, v} end)
      |> Kernel.++([
        {"DB_HOST", System.get_env("DB_HOST") || "localhost"},
        {"DB_PORT", System.get_env("DB_PORT") || "5432"},
        {"THREADLINE_E2E", "1"},
        {"THREADLINE_E2E_THEME", "system"}
      ])

    case System.cmd("bash", [script | args], env: env, into: IO.stream(:stdio, :line)) do
      {_output, 0} -> :ok
      {_output, status} -> Mix.raise("verify.example_browser_light failed (#{status})")
    end
  end

  # Source contract: defp verify_operator_stress(args), do: verify_example_browser(["operator-stress.spec.ts" | args])
  defp verify_operator_stress(args),
    do: verify_example_browser(["operator-stress.spec.ts" | args])

  # Tier A deterministic capture-lane regeneration. Runs BOTH
  # theme projects so a single `mix verify.capture` reproduces all 120 committed
  # scorecards (66 Band-1 + 54 Band-2). Local-only regeneration — deliberately NOT
  # in ci.all (CI gates the committed scorecard JSON through verify-test, because
  # mechanical_checker_test.exs is in the default suite).
  defp verify_capture(args),
    do:
      verify_example_browser([
        "--project=tier-a-capture",
        "--project=tier-a-capture-light",
        "operator-tier-a-capture.spec.ts" | args
      ])

  # Targeted runner for the operator component browser contract (viewport reflow + motion +
  # reconnect CSS contract). Convenience for local runs; CI runs it as part of the
  # full verify.example_browser suite (and verify.example_browser_light for the
  # light/system theme lane). Mirrors verify.operator_stress.
  defp verify_operator_component_contracts(args),
    do: verify_example_browser(["operator-component-contracts.spec.ts" | args])

  # Local-only adversarial critic runner.
  # Requires ANTHROPIC_API_KEY (maintainer-local; never CI).
  # When the key is absent (empty or unset), prints a skip notice and returns :ok
  # so `mix verify.ui_critique` exits 0 on any contributor machine. See CONTRIBUTING.md.
  # NOT in ci.all — the same local-only precedent as verify.flake.
  defp verify_ui_critique(args) do
    case System.get_env("ANTHROPIC_API_KEY") do
      key when is_nil(key) or key == "" ->
        IO.puts(
          "mix verify.ui_critique: ANTHROPIC_API_KEY not set — skipping (local-only, requires maintainer key)"
        )

        :ok

      _key ->
        e2e_dir = Path.expand("examples/threadline_phoenix/e2e")

        env =
          System.get_env()
          |> Enum.map(fn {k, v} -> {k, v} end)

        # `--` separates npm's own args from the ones forwarded to run.ts. Without it
        # npm v7+ swallows leading `--flags` (e.g. --dry-run, --page, --golden) as its
        # own config instead of forwarding them to the score subcommand.
        case System.cmd("npm", ["run", "critic:score", "--" | args],
               cd: e2e_dir,
               env: env,
               into: IO.stream(:stdio, :line)
             ) do
          {_output, 0} -> :ok
          {_output, status} -> Mix.raise("verify.ui_critique failed (#{status})")
        end
    end
  end

  defp verify_hex_evaluator(_args) do
    # The fixture resolves `:threadline` in one of two modes.
    #
    #   rehearsal (default) — wrap the run in `bin/with-rehearsal-registry`,
    #     which packages THIS tree with `mix hex.build`, serves it from a
    #     throwaway signed local registry, and tears the registry down on every
    #     exit path. This is what makes the evaluator a usable PRE-publish gate
    #     instead of a re-test of the last published release.
    #
    #   published — set only by release.yml AFTER `mix hex.publish`. No local
    #     registry; the fixture resolves the exact published version from
    #     hexpm, so the run must NOT be wrapped.
    #
    # `mix verify.hex_evaluator` stays the single named entrypoint either way.
    steps =
      "printf \"n\\n\" | mix deps.get && mix compile --warnings-as-errors && mix ecto.create --quiet -r HexEvaluator.Repo && mix ecto.migrate --quiet && mix test"

    # Rehearsal mode must re-resolve `:threadline` on EVERY run. The rehearsal
    # tarball's version does not change when the tree does, so a lock entry
    # left over from a previous run records a checksum for a DIFFERENT tarball
    # at the same version — and Hex then aborts with "Registry checksum
    # mismatch against lock" instead of picking up the current tree. Unlocking
    # just this one dep keeps every other dep's cache intact, which is why this
    # is not a blanket `rm mix.lock`. Only `:threadline` is unlocked; the lock
    # itself is deliberately untracked and gitignored, because this fixture's
    # job is resolvability rather than reproducibility.
    unlock = "(mix deps.unlock threadline || true)"

    cmd =
      case System.get_env("THREADLINE_HEX_EVALUATOR_MODE", "rehearsal") do
        "published" ->
          "bash -lc 'set -euo pipefail && cd priv/ci/hex_evaluator && #{steps}'"

        _ ->
          "bin/with-rehearsal-registry bash -lc 'set -euo pipefail && cd priv/ci/hex_evaluator && #{unlock} && #{steps}'"
      end

    case Mix.shell().cmd(cmd, env: [{"MIX_ENV", "test"}]) do
      0 -> :ok
      status -> Mix.raise("verify.hex_evaluator failed (#{status})")
    end
  end

  defp ensure_clean_tree! do
    case Mix.shell().cmd("git diff --quiet HEAD --") do
      0 ->
        :ok

      _ ->
        Mix.raise(
          "verify.release requires a clean working tree so the validated artifact matches the taggable tree"
        )
    end
  end

  defp run_release_step!(command) do
    cmd = "bash -lc 'set -euo pipefail && #{command}'"

    case Mix.shell().cmd(cmd) do
      0 -> :ok
      status -> Mix.raise("verify.release failed while running #{command} (#{status})")
    end
  end

  defp doc_source_ref do
    case Version.parse(@version) do
      {:ok, %Version{pre: []}} -> "v#{@version}"
      _ -> "main"
    end
  end

  defp package do
    [
      licenses: ["MIT"],
      keywords: ["audit", "phoenix", "ecto", "postgres", "history", "security", "telemetry"],
      links: %{
        "GitHub" => @source_url,
        "Changelog" => "#{@source_url}/blob/#{doc_source_ref()}/CHANGELOG.md"
      },
      files:
        ~w(lib priv/fonts guides brandbook/favicon.svg brandbook/logo-primary.svg brandbook/logo-primary-light.svg .formatter.exs mix.exs README.md LICENSE CHANGELOG.md CONTRIBUTING.md),
      # Maintainer-only tooling must never cross into the published package.
      # Two of the critic tasks carry a `@shortdoc`, so once shipped they would
      # appear in every adopter's `mix help` under a namespace that is not this
      # library's; the stress and mechanical harness and the release pin
      # rewriter are repository instruments with no meaning inside a host
      # application. Expressed as patterns rather than by converting `files:`
      # into a file-granular enumeration of `lib/`, because an allowlist of
      # individual modules would silently omit any legitimate new one. The
      # exclusion is proven against the UNPACKED tarball in
      # test/threadline/release_artifact_contract_test.exs, not against this
      # configuration — a gate that asserts its own inputs proves nothing.
      exclude_patterns: [
        ~r{^lib/mix/tasks/critic\.},
        ~r{^lib/mix/tasks/release\.pins\.ex$},
        ~r{^lib/threadline/critic_trust/},
        ~r{^lib/threadline/operator_surface/live/stress_live(\.ex$|/)},
        ~r{^lib/threadline/operator_surface/mechanical_checker(\.ex$|/)},
        ~r{^lib/threadline/operator_surface/stress_fixtures\.ex$},
        ~r{^lib/threadline/operator_surface/stress_router\.ex$}
      ]
    ]
  end

  defp docs do
    reference_app_url =
      "#{@source_url}/blob/#{doc_source_ref()}/examples/threadline_phoenix/README.md"

    design_system_url = "#{@source_url}/blob/#{doc_source_ref()}/DESIGN-SYSTEM.md"

    [
      main: "readme",
      extra_section: "Guides",
      source_ref: doc_source_ref(),
      source_url: @source_url,
      favicon: "brandbook/favicon.svg",
      # Preserve the README's theme-aware picture in generated docs. The Hex
      # archive carries only the two referenced logos plus the favicon, so
      # repository-only brand guidance does not become consumer payload.
      assets: %{"brandbook" => "brandbook"},
      before_closing_head_tag: &before_closing_head_tag/1,
      before_closing_body_tag: &before_closing_body_tag/1,
      extras: [
        "README.md",
        "guides/how-threadline-works.md",
        "guides/code-walkthrough.md",
        "guides/integration-contracts.md",
        "guides/performance.md",
        "guides/domain-reference.md",
        "guides/operator-surface.md",
        "guides/upgrade-path.md",
        "guides/redaction.md",
        "guides/stability.md",
        "guides/supported-tables.md",
        "guides/upgrading-to-0.11.md",
        "guides/upgrading-to-1.0.md",
        "guides/brownfield-continuity.md",
        "guides/production-checklist.md",
        "guides/incident-playbook.md",
        "guides/getting-started-saas.md",
        "guides/adoption-pilot-backlog.md",
        "guides/adoption-evidence-playbook.md",
        "guides/evaluating-threadline.md",
        "guides/local-docker-dx.md",
        "guides/audit-indexing.md",
        "guides/telemetry.md",
        "guides/integrations/sigra.md",
        "guides/integrations/phx-gen-auth.md",
        "guides/configuration-and-commands.md",
        {reference_app_url, title: "Phoenix reference application", url: reference_app_url},
        "CONTRIBUTING.md",
        {design_system_url, title: "Operator surface design system", url: design_system_url},
        "CHANGELOG.md"
      ],
      # Intent routing sidebar. The lane names equal the README `## Start here`
      # intent columns. Order is
      # load-bearing: ExDoc groups each extra by FIRST matching regex, so
      # Overview (README) and Integrations (guides/integrations/**) precede the
      # verb lanes to keep the two integration guides out of a verb lane. Each
      # lane uses an explicit per-file regex (not a greedy `^guides/`); every one
      # of the extras lands in exactly one lane.
      groups_for_extras: [
        Overview: ~r/README/,
        Integrations: ~r{^guides/integrations/},
        Evaluate:
          ~r{^guides/(evaluating-threadline|how-threadline-works|code-walkthrough|domain-reference)\.md$},
        Adopt:
          ~r{^guides/(getting-started-saas|production-checklist|brownfield-continuity|integration-contracts|local-docker-dx|upgrade-path|redaction|stability|supported-tables|upgrading-to-0\.11|upgrading-to-1\.0|configuration-and-commands)\.md$|/examples/threadline_phoenix/README\.md$},
        Operate:
          ~r{^guides/(operator-surface|incident-playbook|performance|audit-indexing|adoption-evidence-playbook|telemetry)\.md$},
        Contribute:
          ~r{^(CONTRIBUTING|CHANGELOG)\.md$|^guides/adoption-pilot-backlog\.md$|/DESIGN-SYSTEM\.md$}
      ],
      # ExDoc's documented mechanism for mentioning a function living in a
      # @moduledoc false module without emitting a "documentation references a
      # hidden module" warning. Kept to exactly this one entry — the escape
      # hatch named in the Threadline moduledoc for composing a custom query.
      skip_code_autolink_to: ["Threadline.Query.timeline_query/1"],
      # Released CHANGELOG history names Threadline.Query / Threadline.Investigation
      # directly; that history is never rewritten, so only this one extra's
      # undefined-reference warnings are suppressed.
      skip_undefined_reference_warnings_on: ["CHANGELOG.md"],
      groups_for_modules: [
        "Core API": [
          Threadline,
          Threadline.Audit,
          Threadline.ChangeDiff,
          Threadline.Continuity,
          Threadline.Evidence,
          Threadline.Export,
          Threadline.Health,
          Threadline.Job,
          Threadline.NotFoundError,
          Threadline.Plug,
          Threadline.Retention,
          Threadline.Telemetry
        ],
        "Data Types": [
          Threadline.Capture.AuditChange,
          Threadline.Capture.AuditTransaction,
          Threadline.Evidence.Proof,
          Threadline.Evidence.Subject,
          Threadline.Governance.EvidenceRecord,
          Threadline.Health.Finding,
          Threadline.Investigation.IncidentBundle,
          Threadline.Investigation.IncidentChange,
          Threadline.Investigation.LinkedChange,
          Threadline.Investigation.LinkedTransaction,
          Threadline.Page,
          Threadline.Semantics.ActorRef,
          Threadline.Semantics.AuditAction,
          Threadline.Semantics.AuditContext
        ],
        "Configuration & Extension Points": [
          Threadline.Storage,
          Threadline.Storage.Local,
          Threadline.Storage.S3,
          Threadline.ExportQueue,
          Threadline.ExportQueue.TaskAdapter,
          Threadline.ExportQueue.Oban,
          Threadline.Export.Orchestrator,
          Threadline.Retention.Policy,
          Threadline.Health.Policy,
          Threadline.Verify.CoveragePolicy,
          Threadline.StorageSchema
        ],
        Integrations: [
          Threadline.Integrations.Sigra
        ],
        "Operator Surface": [
          Threadline.OperatorSurface,
          Threadline.OperatorSurface.Router,
          Threadline.OperatorSurface.Auth
        ],
        "Mix Tasks": [
          Mix.Tasks.Threadline.Install,
          Mix.Tasks.Threadline.Gen.Triggers,
          Mix.Tasks.Threadline.Gen.RowHistoryIndex,
          Mix.Tasks.Threadline.VerifyCoverage,
          Mix.Tasks.Threadline.Continuity,
          Mix.Tasks.Threadline.Retention.Purge,
          Mix.Tasks.Threadline.Export,
          Mix.Tasks.Threadline.Incident,
          Mix.Tasks.Threadline.Evidence.Show,
          Mix.Tasks.Threadline.Health.Coverage,
          Mix.Tasks.Threadline.Policy.Show
        ]
      ]
    ]
  end

  defp before_closing_head_tag(:html) do
    """
    <style>
      .threadline-mermaid {
        margin: 1.5rem 0;
        max-width: 100%;
        overflow-x: auto;
        text-align: center;
      }

      .threadline-mermaid svg {
        display: inline-block;
        height: auto;
        max-width: 100%;
      }

      body.dark .threadline-mermaid {
        color-scheme: dark;
      }

      #top-content h1,
      #top-content h2,
      #top-content h3 {
        text-wrap: balance;
      }

      #top-content p,
      #top-content li {
        text-wrap: pretty;
      }

      @media (max-width: 640px) {
        #start-here + p + table {
          border: 0;
          display: block;
          overflow: visible;
        }

        #start-here + p + table thead {
          clip: rect(0 0 0 0);
          clip-path: inset(50%);
          height: 1px;
          overflow: hidden;
          position: absolute;
          white-space: nowrap;
          width: 1px;
        }

        #start-here + p + table tbody {
          display: grid;
          gap: 1rem;
        }

        #start-here + p + table tr {
          display: grid;
          gap: 0.75rem;
          padding: 1rem 0;
        }

        #start-here + p + table td {
          border: 0;
          display: block;
          padding: 0;
          width: auto;
        }

        #start-here + p + table td + td {
          border-top: 1px solid color-mix(in srgb, currentColor 16%, transparent);
          padding-top: 0.75rem;
        }

        #start-here + p + table td:nth-child(2)::before,
        #start-here + p + table td:nth-child(3)::before {
          display: block;
          font-size: var(--text-xs);
          font-weight: 600;
          margin-bottom: 0.25rem;
          opacity: 0.72;
          text-transform: uppercase;
        }

        #start-here + p + table td:nth-child(2)::before {
          content: "Start here";
        }

        #start-here + p + table td:nth-child(3)::before {
          content: "Then read";
        }
      }
    </style>
    """
  end

  defp before_closing_head_tag(:epub), do: ""

  defp before_closing_body_tag(:html) do
    """
    <script id="threadline-mermaid-script"
            defer
            src="https://cdn.jsdelivr.net/npm/mermaid@11.16.0/dist/mermaid.min.js"
            integrity="sha384-T/0lMUdJpd2S1ZHtRiofG3htU3xPCrFVeAQ1UUE2TJwlEJSV5NUwn30kP28n238E"
            crossorigin="anonymous"></script>
    <script>
      (() => {
        let graphSequence = 0;
        let renderQueue = Promise.resolve();

        const currentTheme = () =>
          document.body.classList.contains("dark") ? "dark" : "default";

        const renderDiagrams = async () => {
          if (!window.mermaid) return;

          const theme = currentTheme();

          window.mermaid.initialize({
            startOnLoad: false,
            securityLevel: "strict",
            theme,
            darkMode: theme === "dark"
          });

          const diagrams = document.querySelectorAll("pre > code.mermaid");

          for (const code of diagrams) {
            const sourceBlock = code.parentElement;
            const source = sourceBlock.dataset.threadlineMermaidSource || code.textContent;
            const rendered = sourceBlock.nextElementSibling;

            sourceBlock.dataset.threadlineMermaidSource = source;

            if (
              sourceBlock.dataset.threadlineMermaidTheme === theme &&
              rendered?.classList.contains("threadline-mermaid")
            ) {
              continue;
            }

            if (rendered?.classList.contains("threadline-mermaid")) rendered.remove();
            sourceBlock.hidden = false;

            try {
              const id = `threadline-mermaid-${++graphSequence}`;
              const {svg, bindFunctions} = await window.mermaid.render(id, source);
              const container = document.createElement("div");

              container.className = "threadline-mermaid";
              container.dataset.mermaidTheme = theme;
              container.innerHTML = svg;
              sourceBlock.after(container);
              if (bindFunctions) bindFunctions(container);

              sourceBlock.dataset.threadlineMermaidTheme = theme;
              sourceBlock.hidden = true;
            } catch (error) {
              sourceBlock.hidden = false;
              console.warn("Threadline docs could not render a Mermaid diagram", error);
            }
          }
        };

        const scheduleRender = () => {
          renderQueue = renderQueue.then(renderDiagrams, renderDiagrams);
        };

        window.addEventListener("exdoc:loaded", scheduleRender);

        const mermaidScript = document.getElementById("threadline-mermaid-script");
        mermaidScript.addEventListener("load", scheduleRender);

        if (document.readyState === "loading") {
          document.addEventListener("DOMContentLoaded", scheduleRender, {once: true});
        } else {
          scheduleRender();
        }

        new MutationObserver((mutations) => {
          if (mutations.some((mutation) => mutation.attributeName === "class")) {
            scheduleRender();
          }
        }).observe(document.body, {attributes: true, attributeFilter: ["class"]});
      })();
    </script>
    """
  end

  defp before_closing_body_tag(:epub), do: ""
end