GgenIgniter.DoctorFixes (ggen_igniter v26.9.8)

Copy Markdown View Source

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 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,
    • raises 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).

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.

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 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

ash_domains_rule()

@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(project_dir)

@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(project_dir)

@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(project_dir, dep)

@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(project_dir)

@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()

@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()

@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(dep)

@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!(project_dir)

@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!(project_dir)

@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!(project_dir, dep)

@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!(project_dir)

@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()

@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()

@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()

@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(project_dir)

@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(rule, project_dir, fix? \\ false)

@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.