Writing a codemod
A codemod is a structural fix: it rewrites the tool tree (reorder attributes, drop a
redundant one, rewrite an output element) while preserving behaviour. Codemods live in
tier 2, galaxy-tool-codemod, and this page is the short version of how to add one. The
authoritative, step-by-step workflow is the add-codemod skill in the repository
(.claude/skills/add-codemod/); the architecture context is
ARCHITECTURE.md.
The two principles
TDD first. Write the failing test, then the minimum code to pass. One test module per source module.
Detect is the primitive. A codemod reports exactly what it will change as a list of
Changeobjects; the base class derivesapplyfrom that. You do not write a separate mutation pass for an ordinary codemod, so detect and fix can never disagree.
The public surface a test drives:
from galaxy_tool_codemod.parse import parse_module
module = parse_module(b"<tool>...</tool>") # or a Path
changes = MyCodemod().detect(module) # list[Change], non-mutating
MyCodemod().apply(module) # mutates the tree in place
A minimal codemod
This is the real shape of DropRedundantParamName (GTR037), which drops a <param>
name that its argument already implies:
from typing import ClassVar
from galaxy_tool_refactor_rules.meta import RuleMeta
from galaxy_tool_codemod.change import Change
from galaxy_tool_codemod.codemod import CodemodCommand
class DropRedundantParamName(CodemodCommand):
meta: ClassVar[RuleMeta] = RuleMeta(
code="GTR037", # the next free code (the registry asserts no collision)
summary="Drop a <param> 'name' that equals the name its 'argument' implies.",
since="0.0.1",
rulesets=frozenset({"default", "iuc", "strict"}),
)
def detect_Param(self, cursor): # dispatch by tag: <param> -> detect_Param
argument = cursor.get_attribute("argument")
name = cursor.get_attribute("name")
if argument is None or name is None or name != argument.lstrip("-").replace("-", "_"):
return
yield Change(
code=self.meta.code,
sourceline=cursor.sourceline,
xpath=cursor.xpath,
message=f"drop redundant name='{name}' (argument '{argument}' implies it)",
mutate=lambda: cursor.delete_attribute("name"),
)
The pieces:
Subclass
CodemodCommandand setmeta: ClassVar[RuleMeta]with the next free GTR code, asummary, andsince.applies_todefaults to{"tool"}; opt into"macro"only for a rule that is generic across both.detect_<TagPascalCase>methods are dispatched by element tag (<param>→detect_Param,<change_format>→detect_ChangeFormat). Eachyields aChangewhosemutateis a zero-argument closure overCursorprimitives:set_attribute/delete_attribute/rename_attribute/rename_tag/reorder_attributes/reorder_children/remove/add_child/set_text.The fix must hold by construction for novel tools, with a Galaxy-source or schema citation, never “no corpus tool does this.” That argument is recorded in a proof doc (see the rule proofs); a guard test fails CI if a fixable rule ships without one.
Registering it
Always add the class to
catalog.py::coded_codemods(). This is what the cross-tier registry enumerates, and it asserts GTR codes do not collide.Add it to
canonical.py::canonical_codemods()only if it is safe, idempotent, andprofile=-preserving. That is what makes it run underformatand theiucruleset. Mind the order:FixTyposruns first, attribute reorders before child reorders.No registry edits are needed:
galaxy-tool-refactor-registryderives its handles from these two functions.
Proving it on the corpus
Sweep the new codemod over the tool corpus to check idempotence and post-fix validity, and to retain any real-world failure as a regression fixture:
$ uv run python -m scripts.corpus_check codemod \
galaxy_tool_codemod.codemods.drop_redundant_param_name:DropRedundantParamName
Any tool that breaks is copied into tests/data/regressions/ and picked up
automatically by tests/test_regressions.py. Investigate and fix every retained
failure, then run the pre-PR audit before opening a pull request.
A codemod that must branch on re-validation (like FixTypos or UpgradeToLatest) is the
exception: it overrides apply with bespoke logic and supplies a coarse detect. The
add-codemod skill covers that variant and the profile-upgrade (upgrade_vN) case.