Skip to content

Add dialyzer to project, CI, and clean up type-spec issues#535

Merged
brainlid merged 2 commits intomainfrom
me-add-dialyzer
May 5, 2026
Merged

Add dialyzer to project, CI, and clean up type-spec issues#535
brainlid merged 2 commits intomainfrom
me-add-dialyzer

Conversation

@brainlid
Copy link
Copy Markdown
Owner

@brainlid brainlid commented May 5, 2026

Problem

The project had no static type analysis. Many @specs had drifted from the implementations they describe, hiding real-but-silent issues: success-typing mismatches in chat-model response handling, schemas with non-nullable types whose default-nil structs couldn't satisfy them, an Ecto.Changeset \\ default-arg pattern that broke its own contract, and stream-decoder return shapes that didn't match what the implementations actually returned. Without dialyzer running in CI, these would keep accumulating.

Solution

Added :dialyxir ~> 1.4 to the project, fixed every reachable warning, suppressed the unavoidable ones in a documented ignore file, and wired a cached dialyzer step into the GitHub Actions workflow so future drift surfaces on PRs.

The bulk of the fixes fall into a few patterns:

  • Spec / implementation drift in chat models. do_process_response/2 claimed it returned {:error, String.t()} but actually returned {:error, %LangChainError{}}, and missing TokenUsage.t() and tagged-error returns from list paths. Each provider's decode_stream/2 claimed a one-tuple return but actually returned {[map()], String.t()}. Both specs were corrected across chat_open_ai, chat_open_ai_responses, chat_orq, chat_mistral_ai, and chat_perplexity. Once the truthful return types were in place, dialyzer's cascading "this clause can never match" warnings on the case-arms in do_api_request/4 resolved themselves.
  • do_api_request/4 retry_count default. Main recently changed retry_count \\ 3 to retry_count \\ nil across all chat-model and image providers, but the @spec still declared integer() (or non_neg_integer()). Updated each spec to integer() | nil so the auto-generated do_api_request/3 arity satisfies the contract.
  • Schema type drift in DeepResearch. @type t() declared fields like id: String.t() but the structs default to nil, so the auto-generated changeset/1 (from result \\ %__MODULE__{}) couldn't satisfy its own contract. Relaxed the field types to String.t() | nil to match the actual struct shape, and added an explicit @spec changeset(map()) for the auto-generated /1 arity.
  • Shared util specs that rejected real callers. Utils.handle_stream_fn, Utils.fire_streamed_callback, TokenUsage.set_wrapped, and TokenUsage.get all had specs narrower than what their callers actually pass (struct vs structural-map mismatches). Broadened to accept the values that real call sites use.
  • Function.execute lost the :interrupt variant. normalize_execution_result and execute_with_error_handling specs omitted {:interrupt, msg, data} even though both functions handle and return it. Added it, which un-stuck the case-clause analysis in LLMChain.execute_tool_call.
  • Missing LangChainError alias in lib/chains/chain_callbacks.ex, which was using LangChainError.t/0 in two @type declarations without aliasing the module — caused unknown_type warnings.
  • Genuinely unreachable clauses removed in chat_deepseek (map_size(metadata) > 0 was always true after the implementation always added :logprobs) and deep_research (defensive fallback clauses preceding clauses already covered them).
  • False-positive families suppressed via .dialyzer_ignore.exs with explanatory comments:
    • ReqLLM's upstream spec for generate_text/3 and stream_text/3 declares String.t() | list() for the messages argument, but the actual API accepts %ReqLLM.Context{} (which is what we pass).
    • chat_anthropic's streaming do_api_request/4 has defensive {:ok, {:error, _}} and other -> clauses that are statically unreachable but load-bearing for tests using Mimic mocks that inject arbitrary shapes.
    • MessageDelta.to_message's defensive {:error, _reason} catchall after {:error, %Ecto.Changeset{}}. Message.new currently only returns the changeset error, but the catchall future-proofs against spec changes.
    • lib/web_socket.ex has eight pattern_match warnings against Mint.WebSocket return-shape mismatches (3-tuple {:error, conn, exception} vs 2-tuple {:error, reason}). These are pre-existing on main and look like real bugs in the web_socket module — flagged for a follow-up PR rather than fixed here, since this PR's scope is the dialyzer setup itself.

Related to #396 - Previous PR for adding Dialyzer

Changes

  • mix.exs — Added :dialyxir, configured dialyzer: with ignore_warnings, plt_file, and plt_core_path to land both PLTs in priv/plts/ for predictable cache contents.
  • .github/workflows/elixir.yml — Added an id: beam to the existing pinned setup-beam step, then a four-step dialyzer flow gated on matrix.lint: restore PLT cache → build PLTs on miss → save cache as a separate step (so a dialyzer failure doesn't discard a freshly-built PLT) → run with --format github --format dialyxir for both inline PR annotations and readable log output. New actions/cache/restore@… and actions/cache/save@… SHAs are pinned to match the existing zizmor-hardened pattern.
  • .dialyzer_ignore.exs (new) — Documents the unavoidable warning families above.
  • .gitignore — Excludes /priv/plts/.
  • lib/chat_models/chat_open_ai.ex, chat_open_ai_responses.ex, chat_orq.ex, chat_mistral_ai.ex, chat_perplexity.ex — Corrected do_process_response/2 and decode_stream/2 specs.
  • lib/chat_models/chat_open_ai.ex, chat_open_ai_responses.ex, chat_orq.ex, chat_mistral_ai.ex, chat_perplexity.ex, chat_anthropic.ex, chat_ollama_ai.ex, chat_deepseek.ex, lib/images/open_ai_image.ex, lib/images/modelslab_image.ex — Updated do_api_request specs to allow nil for retry_count.
  • lib/chat_models/chat_google_ai.ex, chat_vertex_ai.exget_message_contents/1 spec now admits nil (matches the content: nil clause).
  • lib/chat_models/chat_anthropic.ex — Tightened for_api/1 spec to ToolCall.t() | ToolResult.t() (commented-out clauses were misleading dialyzer).
  • lib/chat_models/chat_deepseek.ex — Removed always-false else branch in merge_response_metadata/2.
  • lib/chat_models/chat_perplexity.ex — Broadened do_api_request/4 return spec to include {:ok, Req.Response.t()} from the streaming branch.
  • lib/chains/llm_chain.ex — Removed redundant if chain.verbose, do: IO.inspect(...) guards inside the verbose: true clause head (the head pattern already filtered).
  • lib/chains/chain_callbacks.ex — Added alias LangChain.LangChainError so @type declarations resolve.
  • lib/function.ex — Added :interrupt variant to normalize_execution_result/2 and execute_with_error_handling/4 return specs.
  • lib/utils.ex — Broadened handle_stream_fn/3 first arg to map() and fire_streamed_callback/2 to map() so chat-model structs match.
  • lib/token_usage.exset_wrapped/2 spec now reflects the catchall fallback clause; get/1 widened to any() to match its def get(_) fallthrough.
  • lib/tools/deep_research/research_request.ex, research_result.ex, research_status.ex — Field types relaxed to T | nil; added @spec changeset(map()) for the auto-generated /1 arity.
  • lib/tools/deep_research.ex — Removed two genuinely unreachable format_research_result/1 and format_sources/1 fallback clauses.

Testing

Full test suite passes locally (mix test — 27 doctests + 1724 tests, 0 failures, 141 excluded live-API tests). Dialyzer runs clean: Total errors: 15, Skipped: 15, Unnecessary Skips: 0, done (passed successfully). The PLT-cache flow was verified locally by deleting priv/plts/ and rerunning to confirm both core and project PLTs land in the cached directory.

No new tests added — the changes are spec corrections and dead-code removals, both verified by the existing test suite. One round of test failures during development caught me wrongly deleting two chat_anthropic.ex clauses that looked unreachable to dialyzer but are exercised by Mimic-mocked Req responses; those clauses were restored and added to .dialyzer_ignore.exs instead.

brainlid added 2 commits May 4, 2026 19:46
- clean up issues
- updated CI for running and caching
@brainlid brainlid force-pushed the me-add-dialyzer branch from d37dbfd to d49c555 Compare May 5, 2026 01:46
@brainlid brainlid merged commit ba0e420 into main May 5, 2026
3 checks passed
@brainlid brainlid deleted the me-add-dialyzer branch May 5, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant