# `GgenIgniter.DoctorFixes`
[🔗](https://github.com/seanchatmangpt/ggen_igniter/blob/v26.9.8/lib/ggen_igniter/doctor_fixes.ex#L1)

Real, reusable implementations of the project-hygiene fixes that
`mix ggen_igniter.doctor --fix` applies to the CURRENT project (the real
consumer app `doctor` is invoked inside -- not a scaffolded test harness).

Four of these (dep `:only` relaxation x2, `config :dcatr, env: ...`,
`ash_domains:` registration) started life as hand-rolled helpers in
`test/e2e/support/e2e_case.ex` (`relax_scaffolded_igniter_dep!/1`,
`relax_scaffolded_sourceror_dep!/1`, `add_dcatr_env_config!/1`,
`add_ash_domains_config!/3`), written for one specific scaffolded-app
shape (an `igniter.new --install ash,ash_phoenix --with phx.new`
fixture with exact, hard-coded version strings). This module extracts the
same real fix classes into general-purpose, real production code that
works against ANY real consumer project's `mix.exs`/`config/config.exs`/
`lib/` tree, not just that one fixture shape -- version requirements and
`:only` values are detected from the file's real content rather than
assumed.

## Declarative rule engine

Each of those four real fixes is data: a `GgenIgniter.DoctorFixes.Rule`
struct of `%Rule{name:, predicate:, transform:, verify:}`, where every
field is a real function over a real `project_dir` (never `File.cwd!()`
read internally, so every rule is trivially testable against a real temp
directory fixture):

## Structural codemods, not text/regex splices (`transform`s only)

Every `transform` in this module (`dep_only_transform!/2`,
`apply_ash_domains_fix!/4`, `package_description_transform!/1`,
`package_licenses_transform!/1`, and `fix_version_policy!/1`'s write
branch) parses the target file with `Sourceror.parse_string!/1`,
walks/edits the real AST via a `Sourceror.Zipper`, and re-serializes
with `Sourceror.to_string/1` -- using Igniter's own pure-zipper codemod
primitives (`Igniter.Code.Module`, `Igniter.Code.Function`,
`Igniter.Code.List`, `Igniter.Code.Tuple`, `Igniter.Code.Keyword`, and
`Igniter.Project.Config.modify_config_code/4,5`) rather than hand-rolled
zipper traversal. Every one of those operates on a plain
`Sourceror.Zipper.t()` and needs no `%Igniter{}` -- confirmed by reading
`Igniter.new/0`, `Igniter.Project.Deps`, `Igniter.Project.Config`, and
`Igniter.Code.Common` before writing any of this. `Igniter.new/0` builds
a `Rewrite` project that resolves file paths like `"mix.exs"` against
the running process's own cwd, which is incompatible with this module's
contract (`project_dir` is an explicit argument, never `File.cwd!()`, so
every rule stays testable against an arbitrary temp-dir fixture
regardless of the real process's cwd); building a throwaway `%Igniter{}`
scoped to `project_dir` would require `File.cd!/2`-ing the whole BEAM
process into it for the duration of the call, which is unsafe under
`async: true` tests that may run concurrently in other directories. The
lower-level `Igniter.Code.*` modules and
`Igniter.Project.Config.modify_config_code/5` sidestep that entirely:
they take a `Sourceror.Zipper.t()` built directly from a source string
and return an updated one, no `%Igniter{}`/`Rewrite`/cwd involved -- an
exact fit for `run_rule/3`'s real contract (read a real file for a real
`project_dir`, write a real fixed file, no implicit process-global
state).

Only `transform`s were migrated this way -- `predicate`s stay
regex-based read-only detection heuristics (unchanged, and still the
thing that decides `:ok`/`:fixable`/`:unrecognized`); a `predicate`
finding `:fixable` is what licenses a `transform` to run at all, and the
structural rewrite is expected to always find what the predicate found
for any shape the predicate recognizes -- a mismatch (predicate says
fixable, the structural rewrite can't locate the same node) raises a
`RuntimeError` rather than silently reporting false success or guessing
a different edit, same discipline as everywhere else in this module.

  * `predicate` -- read-only. Inspects real files and returns
    `{:ok, message}` (nothing wrong), `{:fixable, message}` (a real,
    recognized gap `transform` can safely repair), or
    `{:unrecognized, message}` (a real problem exists, but its exact
    shape isn't one this rule can safely rewrite without guessing).
  * `transform` -- called only when `predicate` returned `:fixable`.
    Applies the real fix (writes the real file) and returns
    `{:fixed, message}` describing exactly what changed.
  * `verify` -- called after `transform` runs. Re-reads the real file and
    confirms the gap is actually gone (re-runs `predicate` and requires
    `:ok`) -- a transform that writes a file but doesn't actually close
    the gap it claimed to fix is a real bug, not a `{:fixed, ...}`
    result.

`run_rule/3` is the one generic dispatcher every rule goes through: read
real file -> apply predicate -> if asked to fix, apply transform ->
verify -> return a real, structured result. A newly-discovered
Igniter/Ash wiring-gap class is a new `Rule` (data: a
predicate/transform/verify triple) appended to `default_rules/0`, never a
new hand-written `check_*`/`fix_*!` function pair, and never a new
hand-written check function in `Mix.Tasks.GgenIgniter.Doctor` either --
that task iterates `default_rules/0` generically (see its `igniter/1`).

`default_rules/0` returns the four rules `mix ggen_igniter.doctor`
actually runs through the generic engine, in the same order it has
always run them.

The six functions below (`check_dep_only/2`, `fix_dep_only!/2`,
`check_dcatr_env_config/1`, `fix_dcatr_env_config!/1`,
`check_ash_domains/1`, `fix_ash_domains!/1`) are the same public API this
module has always exposed -- now thin wrappers over `run_rule/3` and the
matching `Rule` -- kept for direct callers (including this module's own
test suite). Reach for `run_rule/3` + `default_rules/0` for any NEW rule;
the six wrappers exist only to keep the existing call sites unchanged.

## Contract shared by every `check_*/fix_*!` pair

  * `check_*` is read-only: it inspects real files and returns
    `{:ok, message}` (nothing wrong), `{:fixable, message}` (a real,
    recognized problem `fix_*!` can safely repair), or
    `{:unrecognized, message}` (a real problem exists, but its exact
    shape isn't one this module can safely rewrite without guessing).
  * `fix_*!` re-runs the same detection, and:
      - no-ops with `{:ok, message}` if there was nothing to fix,
      - applies the fix and returns `{:fixed, message}` if it was
        `:fixable`,
      - `raise`s a `RuntimeError` with the same discipline as the
        original `e2e_case.ex` helpers ("raise a clear error rather than
        guess") if the real shape was `:unrecognized` -- it NEVER
        silently no-ops on a real problem, and never regex-rewrites a
        shape it doesn't precisely recognize.

## `check_version_policy/1` / `fix_version_policy!/1`

A fifth real check/fix pair (`mix.exs`'s `version:` vs. `CHANGELOG.md`'s
topmost `## vX` heading) lives at the bottom of this module. It predates
this rule-engine pass, is not one of the four fixes the engine above was
built to generalize, and is left as a plain function pair here (not
folded into `default_rules/0`/`hex_publish_rules/0`) -- it fits the same
`(Predicate, Transformation, Verification)` shape and is a natural future
`Rule`, but wiring it into the declarative engine (a new public entry
point, a new `default_rules/0` slot, `Mix.Tasks.GgenIgniter.Doctor`
wiring) is a separate, larger change than the structural-rewrite pass
this fix's own `fix_version_policy!/1` already received (see
`rewrite_version_literal/2`).

# `ash_domains_rule`

```elixir
@spec ash_domains_rule() :: GgenIgniter.DoctorFixes.Rule.t()
```

Builds the `Rule` that scans `project_dir`'s `lib/` tree for modules that
`use Ash.Domain` (a real textual scan -- these fixture/consumer trees are
not necessarily compiled, so this deliberately does not require
`Code.ensure_loaded?/1`) and registers any that are missing from
`config :OTP_APP, ash_domains: [...]` in `config/config.exs`, where
`OTP_APP` is `project_dir`'s own `mix.exs` `app:` value.

The predicate returns `{:unrecognized, message}` (never guesses) if
`config :OTP_APP, ...ash_domains: ...` is present but its value isn't a
simple literal list this rule can safely merge into.

# `check_ash_domains`

```elixir
@spec check_ash_domains(Path.t()) ::
  {:ok, String.t()} | {:fixable, String.t()} | {:unrecognized, String.t()}
```

Read-only check for `ash_domains_rule/0`. See `GgenIgniter.DoctorFixes.Rule`.

# `check_dcatr_env_config`

```elixir
@spec check_dcatr_env_config(Path.t()) :: {:ok, String.t()} | {:fixable, String.t()}
```

Read-only check for `dcatr_env_rule/0`. See `GgenIgniter.DoctorFixes.Rule`.

# `check_dep_only`

```elixir
@spec check_dep_only(Path.t(), atom()) ::
  {:ok, String.t()} | {:fixable, String.t()} | {:unrecognized, String.t()}
```

Read-only check for `dep_only_rule/1`. See `GgenIgniter.DoctorFixes.Rule`.

# `check_version_policy`

```elixir
@spec check_version_policy(Path.t()) ::
  {:ok, String.t()} | {:fixable, String.t()} | {:unrecognized, String.t()}
```

Checks whether `project_dir`'s `mix.exs` `version:` literal matches the
version this project's REAL, observed convention says it should be.

The real convention, confirmed empirically from this project's own git
history and `CHANGELOG.md` (not assumed): every release version is a
calendar-ish `YY.M.D` string (`26.8.27` = 2026-08-27, no leading zeros,
no `v` prefix in `mix.exs` itself), and `CHANGELOG.md`'s topmost `## vX`
entry header is the single source of truth for "what the current release
version is" -- `mix.exs`'s `version:` is a manually-reconciled projection
of that header today, not the other way around, and there is no separate
authority (no `git tag` exists in this repo's history at all -- confirmed
via `git tag --list` returning empty -- so CHANGELOG.md's own top heading
is the only real, standing record of "the current version").

This derivation is unambiguous (a single topmost `## vX` heading, matched
verbatim against `mix.exs`'s `version:` string) precisely because
CHANGELOG.md always has exactly one topmost heading. This convention is
specific to `ggen_igniter`'s own release process, not a universal
requirement of every consuming project, so a project with no
`CHANGELOG.md` at all (the common case: `ggen_igniter.doctor` also runs
inside arbitrary CONSUMER apps that never opted into this convention) is
`{:ok, message}` -- not applicable, not a problem. It only becomes
`{:unrecognized, message}` when `CHANGELOG.md` DOES exist but its shape
defeats the derivation (no `## v` heading found, or `mix.exs`'s
`version:` isn't a simple string literal) -- a real, ambiguous state this
module refuses to guess a fallback rule for.

# `dcatr_env_rule`

```elixir
@spec dcatr_env_rule() :: GgenIgniter.DoctorFixes.Rule.t()
```

Builds the `Rule` that detects and adds a missing `config :dcatr, env:
...` entry in `project_dir`'s `config/config.exs`.

`:gno`'s own `Gno.Store.Adapters.Fuseki` calls `DCATR.Manifest.env/1` at
compile time, which raises unless `config :dcatr, env: ...` (or the
`DCATR_ENV`/`MIX_ENV` OS environment variable) is set. Only relevant if
`:gno` or `:dcatr` is actually present in the CURRENT project's own
dependency tree (checked the same way `ggen_igniter.doctor`'s existing
`check_deps/1` checks required deps: `Application.ensure_loaded/1` +
`Application.spec/2`).

# `default_rules`

```elixir
@spec default_rules() :: [GgenIgniter.DoctorFixes.Rule.t()]
```

The four real rules `mix ggen_igniter.doctor` runs through `run_rule/3`,
in the same order it has always run them. Adding a new Igniter/Ash
wiring-gap class means appending one more `%Rule{}` here (data) -- never
writing a new `check_*`/`fix_*!` function pair, and never touching
`Mix.Tasks.GgenIgniter.Doctor` (it iterates this list generically).

# `dep_only_rule`

```elixir
@spec dep_only_rule(atom()) :: GgenIgniter.DoctorFixes.Rule.t()
```

Builds the `Rule` that detects and relaxes an `:only`-restricted `dep`
(e.g. `:igniter` or `:sourceror`) in `project_dir`'s own `mix.exs` -- the
same real conflict class this session hit repeatedly: `ggen_igniter`
itself needs `:igniter`/`:sourceror` unconditionally (every consuming
app, every `Mix.env/0`), so a consumer's own `:only`-restricted direct
declaration of the same dependency causes Mix's resolver to refuse
("Dependencies have diverged").

Only recognizes a single-line dependency tuple of the shape
`{:dep, "VERSION_REQ", only: ONLY_VALUE}` (the real shape generated by
`igniter.new`/`phx.new`, and the common hand-written shape); returns
`:unrecognized` rather than guessing for any other shape.

# `fix_ash_domains!`

```elixir
@spec fix_ash_domains!(Path.t()) :: {:ok, String.t()} | {:fixed, String.t()}
```

Applies `ash_domains_rule/0`'s fix for real. See `GgenIgniter.DoctorFixes.Rule`.

# `fix_dcatr_env_config!`

```elixir
@spec fix_dcatr_env_config!(Path.t()) :: {:ok, String.t()} | {:fixed, String.t()}
```

Applies `dcatr_env_rule/0`'s fix for real. See `GgenIgniter.DoctorFixes.Rule`.

# `fix_dep_only!`

```elixir
@spec fix_dep_only!(Path.t(), atom()) :: {:ok, String.t()} | {:fixed, String.t()}
```

Applies `dep_only_rule/1`'s fix for real. See `GgenIgniter.DoctorFixes.Rule`.

# `fix_version_policy!`

```elixir
@spec fix_version_policy!(Path.t()) :: {:ok, String.t()} | {:fixed, String.t()}
```

Applies the fix `check_version_policy/1` detects: rewrites `project_dir`'s
`mix.exs` `version:` literal to match CHANGELOG.md's topmost `## vX`
entry header, via a structural `Sourceror.Zipper` rewrite of `project/0`'s
real keyword-list AST node (see `rewrite_version_literal/2`) -- never a
full-file text/regex rewrite.

Raises a `RuntimeError` instead of guessing if the real shape isn't one
`check_version_policy/1` recognizes (no CHANGELOG.md, no `## v` heading,
or `mix.exs`'s `version:` isn't a simple string literal).

# `hex_publish_rules`

```elixir
@spec hex_publish_rules() :: [GgenIgniter.DoctorFixes.Rule.t()]
```

The two `package[...]` metadata rules `mix ggen_igniter.doctor`'s check 16
(`--hex-check`) runs through `run_rule/3`, applied to the CURRENT
project's `mix.exs` -- see `package_description_rule/0` and
`package_licenses_rule/0`. Kept separate from `default_rules/0` because
they only matter when `--hex-check` is passed (check 16 is off by
default; see `Mix.Tasks.GgenIgniter.Doctor`'s moduledoc).

# `package_description_rule`

```elixir
@spec package_description_rule() :: GgenIgniter.DoctorFixes.Rule.t()
```

Builds the `Rule` that detects a `package/0` function in `project_dir`'s
`mix.exs` missing a `description:` entry, when the file already defines a
`description/0` function elsewhere (the common shape: a project-level
`description: description()` in `project/0`, with `package/0` simply
forgetting to also reference it -- this repo's own `mix.exs` shows the
intended pattern). Only fixable in that exact, unambiguous case: this
never invents description text, it only wires up a function this project
already declared. Any other shape (no `package/0` found, no
`description/0` function defined anywhere in the file) is
`{:unrecognized, ...}` -- refuses to guess prose.

# `package_licenses_rule`

```elixir
@spec package_licenses_rule() :: GgenIgniter.DoctorFixes.Rule.t()
```

Builds the `Rule` that detects a `package/0` function in `project_dir`'s
`mix.exs` missing a `licenses:` entry, when a real `LICENSE`/
`LICENSE.md`/`LICENSE.txt` file exists at the project root whose first
non-empty line is an EXACT, recognized SPDX license header (today: "MIT
License" -> `["MIT"]`). Never guesses a license from anything less than
an exact known header match -- an unrecognized or missing LICENSE file
text is `{:unrecognized, ...}`, not a guessed default.

# `package_metadata_keys_present`

```elixir
@spec package_metadata_keys_present(Path.t()) :: %{
  description: boolean(),
  licenses: boolean()
}
```

Reads `project_dir`'s real, CURRENT `mix.exs` source text directly (never
the possibly-stale in-process `Mix.Project.config()`, which is loaded
once and does not reflect a `mix.exs` write made later in the same BEAM
process) and reports whether `package/0`'s body textually contains a
`description:`/`licenses:` key. Used by check 16's hex-publish-readiness
metadata check so a `--fix` applied earlier in the SAME `mix
ggen_igniter.doctor --hex-check --fix` invocation is reflected
immediately, not only on the next separate invocation.

# `run_rule`

```elixir
@spec run_rule(GgenIgniter.DoctorFixes.Rule.t(), Path.t(), boolean()) ::
  {:ok, String.t()}
  | {:fixed, String.t()}
  | {:fixable, String.t()}
  | {:unrecognized, String.t()}
```

The one generic engine every `Rule` runs through: read real project
state (`rule.predicate.(project_dir)`), and:

  * `{:ok, message}` -- nothing to do, passed through as-is.
  * `{:unrecognized, message}` -- a real gap exists but this rule refuses
    to guess how to close it. Returned as data when `fix?` is `false`
    (diagnostic mode); raised as a `RuntimeError` when `fix?` is `true`
    (matching every existing `fix_*!`'s "never silently no-op on a real
    problem" discipline).
  * `{:fixable, message}` -- a real, safely-automatable gap.
    When `fix?` is `false`, returned as-is (diagnostic mode). When `fix?`
    is `true`, `rule.transform.(project_dir)` is applied for real, then
    `rule.verify.(project_dir)` re-reads the real file and confirms the
    gap is actually gone -- raising a `RuntimeError` (never reporting a
    false `{:fixed, ...}`) if the transform ran but the real post-fix
    state still shows the same gap.

---

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