defmodule Bourse.MixProject do use Mix.Project @version "0.8.0" @source_url "https://github.com/ZenHive/bourse" @runtime_manifest "priv/venues/runtime_support.json" @capability_surface "priv/venues/capability_surface.json" @runtime_venues @runtime_manifest |> File.read!() |> :json.decode() |> Map.fetch!("venues") def project do [ app: :bourse, version: @version, elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), aliases: aliases(), description: "Elixir client for eleven provider-authored cryptocurrency exchange integrations.", package: package(), source_url: @source_url, homepage_url: @source_url, dialyzer: dialyzer(), docs: docs() ] end defp docs do [ main: "readme", # `docs/authored-specs.md` is deliberately absent: it is the maintainer # authoring loop, and every one of its links points at something hexdocs # withholds — the eleven carve registers under `docs/authored-spec-carves/` # and the repo-internal `mix bourse.*` tasks. Rendered here it produced ten # broken links; README links it on GitHub instead, where they resolve. extras: ["README.md", "CHANGELOG.md", "CONTRIBUTING.md"], source_url_pattern: "#{@source_url}/blob/main/%{path}#L%{line}", description: "Unified and raw REST APIs for eleven complete provider-authored exchange integrations.", # Docs mirror the package: only `bourse.build_lighter_signer` ships, so the # repo-internal tasks must not appear on hexdocs as tasks consumers can run. # `filter_modules` is a KEEP predicate — true documents the module. filter_modules: &__MODULE__.document_module?/2, # Maintainer-facing extras name deliberately filtered tasks and modules. # Render them as code without manufacturing links to pages HexDocs omits. skip_code_autolink_to: [ "Bourse.LiveLane.Bootstrap", "Bourse.LiveLane.FirstFrame" ], groups_for_modules: [ Exchanges: ~r/^Elixir\.Bourse\.[A-Z][a-zA-Z0-9]+$/, Signing: ~r/^Elixir\.Bourse\.Signing\./, WebSocket: ~r/^Elixir\.Bourse\.WS\./, "Core API": [ Bourse, Bourse.Exchange, Bourse.Registry, Bourse.Spec, Bourse.Unified, Bourse.UnifiedMethod, Bourse.Symbol, Bourse.Parser, Bourse.RawResponse, Bourse.Dispatch, Bourse.HTTP, Bourse.Credentials, Bourse.Error, Bourse.MCP, Bourse.Multi, Bourse.Exchanges ] ] ] end # Repo-internal verification tooling: the WebSocket first-frame prober drives # live venue sockets from the test lane and reads `priv/venues/*/authority/**`, which is # not packaged. Shipping it would drag this repo's `:dev`/`:test` toolchain # into a consumer's compile for a module they can never run. Every entry here # is reached solely from tests and from the `lib/mix/tasks` tooling that # `package/0` already withholds. @unpackaged_prefixes ~w( live_lane lighter_provision ) @doc """ ExDoc `:filter_modules` predicate — true keeps the module in the docs. Drops everything `package/0` deliberately leaves out of the tarball, so hexdocs never advertises a module or task a consumer does not receive: the `mix bourse.*` tasks (all but `bourse.build_lighter_signer`, the one consumer-facing build step) and the repo-internal tooling named by `@unpackaged_prefixes`. """ @spec document_module?(module(), map()) :: boolean() def document_module?(module, _metadata) do name = inspect(module) documented_task?(name) and not unpackaged_module?(name) end defp documented_task?(name) do not String.starts_with?(name, "Mix.Tasks.Bourse.") or name == "Mix.Tasks.Bourse.BuildLighterSigner" end defp unpackaged_module?(name) do Enum.any?(@unpackaged_prefixes, fn prefix -> root = "Bourse." <> Macro.camelize(prefix) name == root or String.starts_with?(name, root <> ".") end) end defp package do runtime_specs = Enum.flat_map(@runtime_venues, fn venue -> Enum.map( ~w(venue.json markets.json errors.json normalization.json endpoints.json raw.json), &"priv/venues/#{venue}/authored/#{&1}" ) end) shared_descriptors = ["priv/venues/_shared/binance_family/descriptors.json"] [ licenses: ["MIT"], links: %{"GitHub" => @source_url}, # `lib/mix/tasks` is repo-internal verification tooling: every task but the # Lighter build reads paths that are deliberately unpackaged # (`priv/venues/*/authority/**`, the roadmap and docs roots), so shipping them would # put `mix help` entries in consumer projects that fail on a missing file. # `bourse.build_lighter_signer` is the one consumer-facing task — README # documents it as the prerequisite for private Lighter calls. files: client_lib_files() ++ ~w(lib/bourse.ex lib/mix/tasks/bourse.build_lighter_signer.ex) ++ ~w(native/lighter_signer mix.exs README.md LICENSE NOTICE) ++ [@runtime_manifest, @capability_surface] ++ runtime_specs ++ shared_descriptors ] end # Hex packages a listed *directory* recursively, so any directory entry that # survives here re-ships an excluded subtree whatever the prefix list says: a # bare `lib/bourse/option_saga` did it at depth 1, and `lib/bourse/spec` — whose # relative path is "spec", matching no prefix — did it again at depth 2 for # `spec/promotion/**`. Dropping directories outright removes the class instead # of naming one more prefix; `unpackaged_path?/1` then only has to judge files. defp client_lib_files do "lib/bourse/**" |> Path.wildcard() |> Enum.reject(&(File.dir?(&1) or unpackaged_path?(&1))) end defp unpackaged_path?(path) do rest = Path.relative_to(path, "lib/bourse") Enum.any?( @unpackaged_prefixes, &(rest == "#{&1}.ex" or String.starts_with?(rest, "#{&1}/")) ) end defp dialyzer do [ plt_add_apps: [:mix, :yaml_elixir] ] end def cli do [ preferred_envs: [ "test.json": :test, "dialyzer.json": :dev, "bourse.verify_rest_read_contracts": :test ] ] end def application do [ mod: {Bourse.Application, []}, extra_applications: [:logger] ] end defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] defp deps do [ # Dev/test tooling {:ex_unit_json, "~> 0.6", only: [:dev, :test], runtime: false}, {:dialyzer_json, "~> 0.2.1", only: [:dev, :test], runtime: false}, {:styler, "~> 1.12.2", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7.19", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.15.0", only: [:dev, :test], runtime: false}, # CVE tripwire: `mix deps.audit` checks mix.lock against the advisory DB # (hex.audit only catches retired packages). Wired into precommit.full + ci. {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:yaml_elixir, "~> 2.12", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4.7", only: [:dev, :test], runtime: false}, {:ex_doc, "~> 0.40.3", only: :dev, runtime: false}, {:doctor, "~> 0.23.0", only: [:dev, :test], runtime: false}, # Tidewave for Claude Code MCP integration (non-Phoenix needs bandit) {:tidewave, "~> 0.9.0", only: :dev}, {:bandit, "~> 1.12.0", only: :dev}, # Code analysis tools {:ex_dna, "~> 1.5.3", only: [:dev, :test], runtime: false}, # reach 2.8.1 pins ex_ast ~> 0.12.0; override verified against mix reach.check {:ex_ast, "~> 0.13.0", only: [:dev, :test], runtime: false, override: true}, {:ex_slop, "~> 0.4.2", only: [:dev, :test], runtime: false}, # Mutation testing, hand-run audit only — never a gate. hex carries every # fix our fork held, and the fork branch is deleted, so the old git pin no # longer resolves. The @behaviour misread is gone: measured against this # lib/ on 0.9.0, "Behaviour definition" skips 28 -> 1, and the one left # (ws/auth/behaviour.ex) is a real behaviour — the signing and WS-auth # modules are measurable again. 0.9.1 stops visiting the `|` node of # `%{s | k: v}`, which used to be emitted with its arguments swapped into # an AST no parser produces; those mutants landed silently as :invalid. # Still run `--no-filter --no-optimize`: a module with >= 3 @callback plus # its own logic is filtered out regardless, and the optimizer can reduce # the mutation set to 0 and still exit 0. # 🚨 A single run is NOT evidence — verdicts flicker even at # --concurrency 1, always toward false green. Repeat a measurement before # quoting a score, and re-baseline rather than reading a drop against the # pre-0.9.0 numbers as a regression: those were taken with the broken # filter and with StatementDeletion never applied. Under a narrow # --test-paths the sandbox fixture path is a symlink into this checkout, # so a test writing inside test/ would mutate real files — today every # writing test targets System.tmp_dir!(); keep it that way. {:muex, "~> 0.9.1", only: [:dev, :test], runtime: false}, {:reach, "~> 2.8.1", only: [:dev, :test], runtime: false}, # reach references Boxart.Render.* unconditionally; boxart is # `optional: true` in reach's hex manifest, so we pull it in here. {:boxart, "~> 0.3.3", only: [:dev, :test], runtime: false}, # Required by tidewave in :dev; no first-party code references Plug. {:plug, "~> 1.20", only: [:dev, :test]}, # Runtime dependencies # decimal — arbitrary-precision decimal for money-exact response normalization # (Bourse `Precise.stringMul`/`stringDiv` parity: e.g. trade cost = amount × price # without float drift). Previously only transitively present via dev/test `doctor`. {:decimal, "~> 3.1"}, {:jason, "~> 1.4.5"}, {:req, "~> 0.6.1 or ~> 0.7.0"}, {:fuse, "~> 2.5.0"}, {:telemetry, "~> 1.4.2"}, # Self-describing APIs — emit_api/3 (for-comprehension api declarations, # used in lib/bourse.ex) ships in descripex 0.11.0; vendor fork retired. {:descripex, "~> 1.0.0"}, # Custom DEX signing (hyperliquid + derive): EIP-712 / msgpack / secp256k1. # msgpax — canonical MessagePack for Hyperliquid action hashing. # ex_keccak — Keccak-256 NIF (EIP-712 struct/domain hashing). # ex_secp256k1 — RFC-6979 deterministic ECDSA with recovery id (signature r/s/v). {:msgpax, "~> 2.4"}, {:ex_keccak, "~> 0.7.8"}, {:ex_secp256k1, "~> 0.8.0"}, # WebSocket client (Gun-based, 5-function API + Deribit heartbeat + reconnection) {:zen_websocket, "~> 0.9.0"} ] end defp aliases do [ tidewave: [ "run --no-halt -e 'Agent.start(fn -> Bandit.start_link(plug: Tidewave, port: 4029) end)'" ], precommit: [ "format --check-formatted", "compile --warnings-as-errors", # TODO(Task N) markers are first-class here — don't fail credo on them. "credo --strict --ignore TagTODO,TagFIXME", "doctor --raise", # Hash-based `.sobelow-skips` (this repo's hook ignores inline comments). "sobelow --skip", # `preferred_envs` (cli/0) is ignored for alias steps — set MIX_ENV # explicitly so test.json runs in :test (a bare alias step inherits the # alias's env, not :test). `cmd env VAR=val ...` so the assignment reaches # the subprocess env, not System.cmd's executable slot. # # No `--exclude`: this suite is provider-live and `test_helper.exs` carries # no default exclusion either. A venue we cannot reach is a RED here. The # one gate is `:dangerous` (mutating probes), which must be asked for. "cmd env MIX_ENV=test mix test.json --quiet" ], # Dispatch-scale reviewer hint (the registered harness `check_command`). # `precommit` (the provider-live suite) plus the clone + architecture/smell # analyzers a cold reviewer worktree would otherwise never run (they're only # host-PostToolUse-hook-run locally). No dialyzer (a cold harness worktree # cold-builds the PLT for minutes → `review_stuck`). "check.dispatch": [ # Owns `priv/native/lighter_signer/`, which the `:native` tests inside # `precommit` load. The artifact is gitignored, so a source change leaves # a stale binary behind and the suite reds on operations it predates — # a red with no defect. This step rebuilds it, so it runs first. "bourse.check_lighter_signer", "precommit", # Cheap, network-free provider-authority structure/hash and exact-error # consistency checks. Remote freshness is `--online`, run by hand. "bourse.authority_check", "bourse.error_authority", # CLAUDE.md's mechanical claims (modules, mix tasks, repo paths, and the # Signing / Application rows of the Key modules table) vs the tree, plus # AGENTS.md freshness — the reviewer reads AGENTS.md, not CLAUDE.md, # so a stale render makes it grade against rules we already changed. "bourse.claude_check", "bourse.agents_md --check", "ex_dna --max-clones 0", # `--strict` fails the gate on smell findings — no baseline/suppression; # findings are fixed, not grandfathered. Reach derives architecture # sources from the active Mix environment, while `--path` pins smell # sources; pin both so callers always grade lib/. "cmd env MIX_ENV=dev mix reach.check --arch --smells --strict --path lib" ], # GHSA-w4f7-4cxr-rv3c pairs cowboy + gun; its gun rows carry cowboy's # numbers ("first patched 2.16.0" — gun has no 2.16.x), so mix_audit # flags gun 2.5.0 although gun's own vulnerable range is < 2.4.0. # Verified against the advisory 2026-07-30; drop when the DB row is fixed. "deps.audit": "deps.audit --ignore-advisory-ids GHSA-w4f7-4cxr-rv3c", # Local pre-PR / post-merge-audit gate — adds dialyzer. SPLIT out because a # cold harness worktree cold-builds the PLT (minutes) → `review_stuck`. # `--cover` stays out of `precommit` (`:cover` instruments every loaded beam # — multi-GB spike on a cold tree); `ci` below enforces the tiers instead. "precommit.full": ["precommit", "deps.audit", "dialyzer.json --quiet"], # Comprehensive pre-PR / CI gate: the dispatch gate, the full provider-live # REST-read contract lane, the coverage tiers, and dialyzer. ci: [ "check.dispatch", # The complete provider-live REST-read lane. It reports # denominator/executed/failures and fails when executed < denominator, so # a shrinking live surface cannot pass as green. Alias steps ignore # `preferred_envs`, and the inventory module lives in `test/support`. "cmd env MIX_ENV=test mix bourse.verify_rest_read_contracts", # `critical-rules.md` § RAISE COVERAGE BEFORE MUTATING sets the floor at # 80% standard. The critical tier (95% — money, signing, crypto, low-level # encoders) is judged per module against this run, not by a global number. "cmd env MIX_ENV=test mix test.json --quiet --cover --cover-threshold 80 --output /tmp/bourse-ci-cover.json", "deps.audit", "dialyzer.json --quiet" ] ] end end