# `Descripex`
[🔗](https://github.com/ZenHive/descripex/blob/v1.0.0/lib/descripex.ex#L1)

Single-source API declarations for self-describing Elixir functions.

The `api` macro is the sole source of truth for function documentation.
It generates `@doc` text, emits `@doc hints:` metadata for machine consumption,
validates param names at compile time, and produces `__api__/0` and `__api__/1`
introspection functions.

## Usage

    defmodule MyLib.Funding do
      use Descripex, namespace: "/funding"

      api(:annualize, "Annualize a per-period funding rate to APR.",
        params: [
          rate: [kind: :value, description: "Per-period funding rate as decimal"],
          period_hours: [kind: :value, default: 8, description: "Hours per period"]
        ],
        returns: %{type: :float, description: "Annualized percentage rate (APR)"}
      )

      @spec annualize(number(), pos_integer()) :: float()
      def annualize(rate, period_hours \\ 8), do: ...
    end

No separate `@doc` block needed — the macro generates it from the declaration.

## Introspection

    MyLib.Funding.__api__()
    # => [%{name: :annualize, arity: 2, ...}, ...]

    MyLib.Funding.__api__(:annualize)
    # => %{name: :annualize, arity: 2, param_order: [:rate, :period_hours], spec: "...", hints: %{...}}

The `param_order` field lists the positional parameter names in declaration
order (including defaulted params). Consumers that dispatch named arguments
positionally — e.g. mapping MCP/JSON tool arguments onto
`apply(module, fun, args)` — **must** order arguments by `param_order`, not by
`Map.keys(hints.params)`. The `hints[:params]` map discards declaration order,
so `Map.keys/1` returns hash order and silently swaps multi-parameter calls.

`param_order` lists every declared positional param, including those with
defaults. A consumer that omits an optional argument must dispatch on the
function's lower arity rather than blindly mapping all of `param_order` — the
defaulted tail can be dropped from the right.

### `__api__/0` vs the BEAM doc chunk

`__api__/0` is the **runtime-enriched** introspection surface: it fills
`hints.params.<name>.schema` / `hints.opts.<name>.schema` from the function's
`@spec` and declared `type:` via `enrich_with_specs/2`. The BEAM doc chunk
(`Code.fetch_docs/1` → `meta[:hints]`) is the **raw declared** surface — written
at compile time, before the module can read its own specs, so it is not enriched.

This asymmetry is intentional. The two surfaces therefore diverge on `:schema`
for any param/opt that gains a spec-derived schema. Consumers that assert the two
are equal (e.g. to verify each `@doc hints:` block is attached to the correctly
named function) **must not** compare them raw — normalize both with
`normalize_for_doc_compare/1`, which strips every `:schema` key:

    Descripex.normalize_for_doc_compare(Mod.__api__(:f).hints) ==
      Descripex.normalize_for_doc_compare(meta_hints)

# `__descripex_modules__`

```elixir
@spec __descripex_modules__() :: [module()]
```

Return the list of modules registered with this library.

# `build_hints`

```elixir
@spec build_hints(
  String.t(),
  keyword()
) :: map()
```

Build machine-readable hints map from an api declaration's description and options.

# `describe`

```elixir
@spec describe() :: [map()]
```

Return a Level 1 overview of all modules in this library.

# `describe`

```elixir
@spec describe(module() | atom() | String.t()) :: [map()]
```

Return Level 2 function list for a module (by full atom, or short name as string or atom).

# `describe`

```elixir
@spec describe(module() | atom() | String.t(), atom()) :: map() | nil
```

Return Level 3 function detail (or `nil` if not found).

# `emit_api`
*macro* 

```elixir
@spec emit_api(atom(), String.t(), Macro.t()) :: Macro.t()
```

Declare an api whose `opts` is a compile-time **variable**, not a literal keyword list.

`api/3` runs `preprocess_schemas/1` on the `opts` AST at macro-expansion time, which
only works when `opts` is a literal keyword-list AST. Callers that build `opts` inside
a `for`-comprehension or any other macro-time variable cannot use `api/3`. `emit_api/3`
emits the identical `@doc`, `@doc hints:`, and accumulator entry as `api/3`, but skips
schema preprocessing — so it accepts a variable `opts` AST.

Compile-time validation (`__before_compile__`) still fires for `emit_api/3` declarations,
identically to `api/3`, since both accumulate into `@descripex_api_declarations`.

## Schema keys are NOT preprocessed

Because preprocessing is skipped, the caller is responsible for pre-converting any
`schema:` keys to JSON Schema maps before passing them in. For-comprehension callers
typically declare no `schema:` keys. If you have a **literal** `opts` keyword list
(with or without `schema:`), use `api/3` instead — `emit_api/3` raises `ArgumentError`
on a literal keyword-list `opts` to steer you to the macro that runs preprocessing.

## Example

    for {name, opts} <- compile_time_method_defs() do
      emit_api(name, "Generated declaration", opts)
    end

# `enrich_with_specs`

```elixir
@spec enrich_with_specs(module(), [map()]) :: [map()]
```

Enrich compile-time api entries with specs fetched at runtime.

# `generate_doc`

```elixir
@spec generate_doc(
  String.t(),
  keyword()
) :: String.t()
```

Generate human-readable `@doc` text from an api declaration's description and options.

# `normalize_for_doc_compare`

```elixir
@spec normalize_for_doc_compare(map()) :: map()
```

Strip every `:schema` key from a `hints` map so the runtime-enriched `__api__/0`
surface can be compared for equality against the raw compile-time doc chunk
(`Code.fetch_docs/1` → `meta[:hints]`).

`__api__/0` fills `hints.params.<name>.schema` / `hints.opts.<name>.schema` from
`@spec`/`type:` at runtime (see `enrich_with_specs/2`), but the doc chunk is
written at compile time and is **not** enriched — a module can't read its own
specs at `__before_compile__`. So the two surfaces diverge on `:schema`, and a
consumer that asserts they are equal (e.g. to detect `api()` misattachment)
false-positives purely on the injected schema.

This drops **all** schema keys — author-declared and spec-injected alike, which
are indistinguishable once merged — from `:params`, `:opts`, and `:returns`.
Apply it to **both** surfaces before comparing:

    Descripex.normalize_for_doc_compare(Mod.__api__(:f).hints) ==
      Descripex.normalize_for_doc_compare(meta_hints)

# `typeless_params`

```elixir
@spec typeless_params([module()]) :: [map()]
```

List the `kind: :value` params that ship **without** a JSON Schema, and why.

Spec-derived schemas are best-effort: `enrich_with_specs/2` fills
`hints.params.<name>.schema` from the function's own `@spec`, but a type json_spec
cannot express leaves the param description-only. MCP clients then guess how to
serialize the argument — and the guess is usually the string form of the term.
This function makes that set queryable instead of silent, so a typeless param on
an `api()` surface is visible before a client trips over it at runtime.

Each entry is a map with `:module`, `:function`, `:arity`, `:param`, `:spec_type`
(the offending type as written, or `nil` when the function declares no `@spec`)
and `:reason`:

  * `:no_spec` — the function has no `@spec`, so there was no type to derive from.
  * `:no_type_info` — the type converts to the constraint-free `{}` (`term()`,
    `any()`). There is nothing to advertise; skipping is correct.
  * `:unconvertible` — json_spec could not express the type and no structural
    fold rescued it: tuples, bitstrings, non-`String` remote types, or a union
    whose members are themselves unconvertible. **This is the class worth acting
    on** — declare an explicit `schema:` on the param.

Params that declare an explicit `schema:` never appear here, and neither do
`kind: :exchange_data` params (the caller does not supply those).

    Descripex.typeless_params([MyLib.Orders, MyLib.Funding])
    #=> [
    #     %{
    #       module: MyLib.Orders,
    #       function: :store,
    #       arity: 2,
    #       param: :handle,
    #       spec_type: "{module(), keyword()}",
    #       reason: :unconvertible
    #     }
    #   ]

A CI check can gate on the actionable class:

    assert Enum.filter(Descripex.typeless_params(mods), &(&1.reason == :unconvertible)) == []

---

*Consult [api-reference.md](api-reference.md) for complete listing*
