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 (transforms 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 transforms were migrated this way -- predicates 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 gaptransformcan 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 whenpredicatereturned:fixable. Applies the real fix (writes the real file) and returns{:fixed, message}describing exactly what changed.verify-- called aftertransformruns. Re-reads the real file and confirms the gap is actually gone (re-runspredicateand 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 problemfix_*!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, raises aRuntimeErrorwith the same discipline as the originale2e_case.exhelpers ("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.
- no-ops with
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).
Summary
Functions
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.
Read-only check for ash_domains_rule/0. See GgenIgniter.DoctorFixes.Rule.
Read-only check for dcatr_env_rule/0. See GgenIgniter.DoctorFixes.Rule.
Read-only check for dep_only_rule/1. See GgenIgniter.DoctorFixes.Rule.
Checks whether project_dir's mix.exs version: literal matches the
version this project's REAL, observed convention says it should be.
Builds the Rule that detects and adds a missing config :dcatr, env: ... entry in project_dir's config/config.exs.
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).
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").
Applies ash_domains_rule/0's fix for real. See GgenIgniter.DoctorFixes.Rule.
Applies dcatr_env_rule/0's fix for real. See GgenIgniter.DoctorFixes.Rule.
Applies dep_only_rule/1's fix for real. See GgenIgniter.DoctorFixes.Rule.
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.
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).
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.
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.
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.
The one generic engine every Rule runs through: read real project
state (rule.predicate.(project_dir)), and
Functions
@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.
@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.
Read-only check for dcatr_env_rule/0. See GgenIgniter.DoctorFixes.Rule.
@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.
@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.
@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).
@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).
@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.
Applies ash_domains_rule/0's fix for real. See GgenIgniter.DoctorFixes.Rule.
Applies dcatr_env_rule/0's fix for real. See GgenIgniter.DoctorFixes.Rule.
Applies dep_only_rule/1's fix for real. See GgenIgniter.DoctorFixes.Rule.
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).
@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).
@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.
@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.
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.
@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 whenfix?isfalse(diagnostic mode); raised as aRuntimeErrorwhenfix?istrue(matching every existingfix_*!'s "never silently no-op on a real problem" discipline).{:fixable, message}-- a real, safely-automatable gap. Whenfix?isfalse, returned as-is (diagnostic mode). Whenfix?istrue,rule.transform.(project_dir)is applied for real, thenrule.verify.(project_dir)re-reads the real file and confirms the gap is actually gone -- raising aRuntimeError(never reporting a false{:fixed, ...}) if the transform ran but the real post-fix state still shows the same gap.