Date: 2026-06-13
This document records the analysis, decisions, and phased plan for moving
galaxy-tool-refactor's auto-fix capabilities upstream into planemo (and its
underlying galaxy.tool_util library). It is the output of a cross-codebase
redundancy audit; the raw findings live in
planemo_linter_parity.md.
planemo lint delegates to galaxy.tool_util.linters, which contains 146
Linter subclasses across 14 modules. galaxy-tool-refactor's galaxy-tool-lint
tier (GTR021–GTR095) reimplements 117 of those 146 linters as CheckRule
subclasses operating on ToolDocument.
The two implementations enforce the same rules from the same upstream IUC
best-practice sources. Whenever galaxy.tool_util tightens a check, both
codebases need matching updates. The only thing that justifies the duplication
today is the Violation / RuleMeta wrapper needed for GTR codes and ruleset
membership — the underlying detection logic itself is redundant.
planemo format is six lines: etree.indent(). It re-indents XML and
nothing else. GTR001 (CanonicalIndent) does the same job with correct CDATA
preservation and macro-file awareness.
planemo normalize reorders <tool> children using TAG_ORDER from
galaxy.tool_util.linters.xml_order, then calls a hand-rolled _indent()
recursive function — and writes to stdout only. GTR013
(ReorderToolChildren) does the same reordering in-place, correctly, as part
of a full format pass.
Neither planemo command applies any structural fix beyond indentation and reordering. The 10 remaining canonical codemods (CDATA wrapping, boolean normalisation, attribute cleanup, deprecated-element repair, etc.) exist only in galaxy-tool-refactor.
planemo lint includes an xsd.py linter that validates a tool against a
single vendored schema. galaxy_tool_source.validate_tool() picks the correct
XSD for the tool's declared profile=, which means a profile 24.2 tool is
validated against the 24.2 schema rather than an older one. Planemo users
therefore see false XSD passes for tools that only validate against an older
schema.
Planemo has autoupdate, which queries conda for newer package versions and
rewrites <requirement version="…"> strings — a package-version concern, not a
Galaxy-profile concern. There is no planemo command that moves a tool's
profile= forward, applies the required structural repairs for that profile
boundary, and validates the result. Tool authors who want to upgrade their
tools must use galaxy-tool-refactor separately, which is friction.
The Linter base-class docstring in galaxy.tool_util.lint reads:
"optionally a fix method can be given"
No concrete linter implements fix(). The architecture anticipates fixes; the
implementation has never arrived.
-
Eliminate the detection duplication between galaxy-tool-lint and
galaxy.tool_util.linters. One authoritative detection implementation, consumed by both planemo and galaxy-tool-refactor. -
Give planemo users auto-fix for the issues planemo currently only reports. Tool authors should not need a second tool for the mechanical fixes.
-
Add
planemo upgradeso profile migration is a first-class planemo workflow, not a separate install. -
Keep LLM / agent-facing capabilities in galaxy-tool-refactor. The MCP server and structured agent outputs stay here. Non-LLM capabilities (RST→Markdown,
.lint-skipreconciliation) are planemo candidates and are planned accordingly. Cheetah mutation, version tokenisation, and macro-aware normalization stay here for now but are reassessed once the main chain (Phases 1–2) is stable.
Target repo: galaxyproject/galaxy
The Linter base class gains a concrete, optional fix() classmethod:
@classmethod
def fix(cls, tool_xml: ElementTree) -> bool:
"""Mutate *tool_xml* in-place to correct the issue this linter detects.
Return True if the tree was changed, False if the fix does not apply.
The default implementation is a no-op (returns False).
"""
return FalseA companion public API function is added to galaxy.tool_util.lint:
def lint_tool_source_and_fix(
tool_source: ToolSource,
*,
linters: list[type[Linter]] | None = None,
) -> tuple[bool, LintContext]:
"""Run all linters; for every finding whose linter has a fix(), apply it.
Returns (changed, lint_context). The caller is responsible for
serialising the mutated tree back to disk.
"""Fixes are then implemented on the linters listed below. Each fix is
independent, idempotent, and behaviour-preserving. The lxml manipulation is
ported directly from the corresponding galaxy-tool-codemod CodemodCommand;
only the wrapper changes.
| Linter class | Fix | Corresponds to |
|---|---|---|
RequirementVersionWhitespace |
trim version attribute |
GTR035.1 |
ToolNameWhitespace |
trim name attribute |
GTR035.2 |
XMLOrder |
reorder children in-place per TAG_ORDER |
GTR013 |
OutputsOutput |
<output type="data"> → <data>, collection likewise |
GTR036 |
InputsNameRedundantArgument |
drop name when argument implies it |
GTR037 |
new CommandCdata |
wrap pure-text <command> body in CDATA |
GTR018.1 |
new HelpCdata |
wrap pure-text <help> body in CDATA |
GTR019.1 |
new BooleanValues |
True/Yes/False/No → true/false |
GTR017 |
Fix ordering matters: XMLOrder before CDATA wrapping (reordering moves
elements; CDATA wrapping mutates text nodes), whitespace trimming before
boolean normalisation (both are attribute mutations, order is immaterial, but
consistency matters for idempotence testing). The ordering is declared in the
linter class via a new fix_order: int = 0 class attribute, analogous to
galaxy-tool-refactor's RuleMeta.order.
Validation gate: after applying all fixes, lint_tool_source_and_fix()
re-runs the linter suite. A fix is only written if the post-fix lint is clean
for that rule. This mirrors galaxy-tool-refactor's proof-by-execution pattern.
Test strategy: each new fix() implementation gets a pair of fixtures
(before / after) tested via the existing galaxy.tool_util test suite.
Target repo: galaxyproject/planemo
After running the full linter suite and reporting findings, call
lint_tool_source_and_fix() for every tool that has fixable findings.
Serialise the mutated tree back to disk. Re-run lint and report the final
state. Add a --fix flag to the existing planemo lint command — no new
command, just a mode.
planemo lint --fix tool.xml
Exit code behaviour is unchanged: exit non-zero if unfixed errors remain.
cmd_format.py's format_xml() function is replaced:
def format_xml(content: str, *, fix: bool = True) -> str:
tool_source = get_tool_source(StringIO(content))
changed, _ctx = lint_tool_source_and_fix(tool_source)
# serialise with CDATA preservation and canonical 4-space indent
return serialize_tool_source(tool_source)The --tab-size option is retired (4 spaces is the IUC standard; variability
here is not useful). --dry-run continues to work via the existing diff path.
planemo normalize is kept for backward compatibility but its body is replaced
with a call to the same path as planemo format. The --expand-macros flag
is preserved (load the expanded tree before fixing); --skip-reorder and
--skip-reindent become --skip XMLOrder and --skip CanonicalIndent linter
exclusions. The stdout-only behaviour is retained via --dry-run. A
deprecation notice points users to planemo format.
Target repo: galaxyproject/planemo
New command; thin wrapper over galaxy_tool_source.rst_markdown.
planemo convert-help [--check] [--backup] TOOL_PATHS...
The conversion is already implemented as a pure function
rst_to_commonmark(text) -> str | None with a render-equivalence gate
(conversion_is_render_equivalent(rst, md) -> bool) in
galaxy-tool-source. The planemo command:
- Loads the tool XML.
- Checks
profile >= 24.2(the XSD gate — earlier profiles don't supportformat="markdown"on<help>). Reports a skip reason if not met. - Calls the render-equivalence gate. Reports a skip reason if it fails (non-CommonMark RST nodes, or the docutils and markdown-it rendered HTML differs semantically).
- Rewrites the
<help>element: setsformat="markdown", replaces the body. - Writes the file (or diffs under
--check, or keeps a.bakunder--backup).
Dependencies added to planemo: markdown-it-py (new; docutils is
already pulled in via galaxy-tool-util). Both are pure Python; no
system-level requirements.
Relationship to galaxy-tool-refactor: galaxy-tool-source (which planemo
already uses indirectly via galaxy-tool-util) provides the conversion logic.
Planemo is the CLI front-end. Nothing from galaxy-tool-codemod is needed.
When to run: after planemo upgrade (profile must reach 24.2 first), then
planemo convert-help, then planemo lint to confirm.
Target repo: galaxyproject/planemo
The .lint_skip sidecar format is planemo's own invention. The
reconciliation command (lint-skip in galaxy-tool-refactor) is therefore more
naturally a planemo command than a galaxy-tool-refactor command.
planemo lint-skip [--check] [--backup] TOOL_PATHS...
For each tool directory with a .lint_skip file, the command removes a
suppression line only when it can prove the line is resolved: the
planemo linter it names must fire clean on every tool in the directory, and
that linter must have a fully faithful GTR port (the coverage gate).
Everything else is left untouched.
What needs to move: the removability gate logic (lint_skip.py in
galaxy-tool-refactor-registry) and the coverage mapping (which planemo linter
names map to which GTR codes). The coverage mapping is already declared in
RuleMeta.planemo_linters; once Phase 1 lands and the linter fix() interface
is in galaxy.tool_util, the coverage gate can be expressed purely in terms of
galaxy.tool_util linter names without a galaxy-tool-refactor dependency.
Timing: this phase depends on Phase 1 (the fix() interface) being stable
enough that the coverage mapping is trustworthy. It can land in planemo
without removing lint-skip from galaxy-tool-refactor immediately; the two
can coexist during a transition period.
Target repo: galaxyproject/planemo
New command; no dependency on galaxy-tool-refactor taken.
planemo upgrade [--modernize] [--target-profile VERSION] TOOL_PATHS...
The implementation is a port of the relevant logic from galaxy-tool-codemod
and galaxy-tool-source into a new planemo/tool_upgrade.py module:
Profile-aware XSD oracle (~100 lines from galaxy_tool_source):
available_profiles()— list of vendoredYYYY.MMstringsvalidate_at_profile(tree, profile)— validate the lxml tree against the correct XSDoldest_valid_profile(tree, floor)— binary search for the minimum valid profile at or abovefloor(the minimal-bump oracle)newest_valid_profile(tree)— the--modernizeceiling oracle
Profile-specific repair steps (from galaxy_tool_codemod):
repair_19_01(tree)— name output<data>elementsrepair_21_09(tree)— normalisecollection_type+has_size; repair stdio exit_code/regexrepair_24_0(tree)— hoist collection filtersrepair_24_1(tree)— normaliseformat/ftypecaserepair_25_1(tree)— drop<trackster_conf>
Each repair is a pure function (ElementTree) -> ElementTree; the lxml
manipulation is ported verbatim from the corresponding UpgradeNN codemod.
Runtime-gated fixes (applied only when the tool crosses their boundary):
fix_interpreter(tree)— inline deprecated<command interpreter=…>fix_output_format_input(tree)— replaceformat="input"withformat_sourcefor the sole-data-input casefix_from_work_dir_whitespace(tree)— strip whitespace fromfrom_work_dir
The behaviour gate (blocking on Galaxy must_fix codes that apply to the
tool) and the deployment ceiling (capping at the lowest profile across
major public Galaxy servers) are galaxy-tool-refactor concerns. planemo upgrade implements:
- Minimal-bump default: move
profile=only when strictly needed for validity after repairs. A tool that validates at its baseline is kept there. --modernize: walk toward the latest vendored profile, applying repairs at each boundary, stopping at the first profile the repaired tool does not validate at. No behaviour-code gate; no deployment ceiling. Simpler and appropriate for a first planemo implementation.--target-profile VERSION: walk to the specified profile explicitly.
The behaviour gate and deployment ceiling remain in galaxy-tool-refactor upgrade --modernize for users who need them. The two tools cover different
points on the "how far should I upgrade" spectrum.
planemo autoupdate updates conda package versions; planemo upgrade moves
the Galaxy profile version. They are complementary, not overlapping. The
recommended workflow is autoupdate then upgrade.
Target repo: this repo
Once Phase 1 lands and galaxy.tool_util.linters has the fix interface,
galaxy-tool-lint's detection logic can delegate to the upstream linters instead
of reimplementing them. Each CheckRule that maps to one or more planemo
linters becomes an adapter:
class RequirementsPresent(CheckRule):
meta = RuleMeta(code="GTR025", planemo_linters=["RequirementsMissing"], ...)
def detect(self, document):
ctx = LintContext("tool")
RequirementsMissing.lint(document.tool_source, ctx)
for msg in ctx.messages:
if msg.is_error or msg.is_warn:
yield Violation(rule=self.meta, ...)This eliminates ~4 000 lines of parallel detection logic while preserving the
GTR code, Violation type, and ruleset membership that the registry facade
and the check command depend on.
Rules whose detection is more precise in galaxy-tool-lint than in
galaxy.tool_util (because galaxy-tool-lint operates on ToolDocument's
typed view rather than a raw ToolSource) may keep their own detection; this
is assessed rule-by-rule. The four remaining DETECT items
(TestsAssertionValidation, TestsCaseValidation, ValidDatatypes,
DatatypesCustomConf) stay as-is since they need external infra not available
in either library.
The dividing line is: LLM / agent-facing capabilities stay here; everything else is a planemo candidate. The MCP server, the structured JSON outputs designed for agent consumption, and any future agent-authored-rules direction (registry Goal 2) have no natural home in planemo's CLI model. All other commands are evaluated on their merits.
| Capability | Decision | Reason |
|---|---|---|
| MCP server | stays | Agent-facing interface; planemo is a human CLI tool |
| Behaviour gate + deployment ceiling | stays | Domain-specific upgrade policy on top of planemo's simpler upgrade |
| Full upgrade walk with proof-by-execution | stays | The rigorous version; planemo upgrade is the convenient on-ramp |
| Cheetah rename-param / find-references | stays (for now) | Requires the full CDM lexer + bundle model; assess after Phase 6 stabilises |
| Version tokenisation (tokenize-version) | stays (for now) | Multi-element restructure + identity-changing --adopt-suffix; too specialised for a first pass |
| Macro-aware normalization (normalize-macros) | stays (for now) | Writes files other than the named tool; repo-scoped operation; assess alongside normalize-macros corpus work |
| RST → Markdown (convert-help) | → planemo Phase 3 | Pure docutils + markdown-it-py; no LLM component; natural developer workflow step |
.lint-skip reconciliation (lint-skip) |
→ planemo Phase 4 | Planemo's own sidecar format; belongs in planemo |
| Canonical codemods + format | → planemo Phase 2 | Already planned |
| Upgrade (profile migration) | → planemo Phase 5 | Already planned |
Phase 1 (galaxy.tool_util fix() interface)
↓
Phase 2 (planemo lint --fix + planemo format)
↓
Phase 6 (galaxy-tool-lint delegation) Phase 3 (planemo convert-help) ← independent
Phase 4 (planemo lint-skip) ← independent*
Phase 5 (planemo upgrade) ← independent
Phases 1 → 2 → 6 are the main chain. Phase 1 lands in
galaxyproject/galaxy; Phase 2 consumes it in planemo; Phase 6 simplifies
this repo once the upstream detection is authoritative. The two PRs for
Phases 1 and 2 can be drafted in parallel and merged in sequence.
Phases 3, 4, 5 are independent of the main chain and of each other, and
can be drafted and reviewed in any order. Phase 4 (lint-skip) benefits from
Phase 1 being stable — the coverage gate is more trustworthy once
galaxy.tool_util linters have the fix() interface — but it can land before
then with a conservative coverage definition.
Phase 6 is a simplification step; galaxy-tool-lint works unchanged if it is deferred indefinitely.
Applying fixes in the wrong order can produce invalid intermediate states
(e.g., reordering children after CDATA wrapping may move a CDATA-wrapped node
into an unexpected position). The fix_order class attribute on each linter
enforces a canonical sequence. Every fix combination must be tested for
idempotence (fix → fix = fix).
Phase 1 requires a PR to galaxyproject/galaxy, which has its own maintainers
and review timeline. The fixes proposed here are strictly additive (new
optional methods on existing classes, new linter classes), so review risk is
low, but the timeline is not in our control. Phases 3 and 4 can proceed
independently if Phase 1 stalls.
Replacing etree.indent() with the full fix pipeline changes planemo format
from "only indentation" to "indentation plus 8 canonical repairs". Existing
users whose tools have CDATA-free commands, Python-style booleans, or
deprecated <output type=…> elements will see more changes than before. This
is the correct behaviour, but it should be announced as a minor-version bump
with a clear changelog entry.
galaxy-tool-lint's ToolDocument view has access to the parsed xsdata model
and the tier-1 analyses (Cheetah spans, macro token definitions), which some
checks use to be more precise than the raw ToolSource view galaxy.tool_util
linters receive. For those checks, delegation would lower precision.
Concretely: GTR020.1/.2 (Cheetah var quoting) and GTR034 (unused param)
use the CDM; they keep their own detection regardless of Phase 4.
planemo upgrade with --modernize stops at the first boundary the repaired
tool does not validate at, with no behaviour-code gate. This means it can
cross Galaxy must_fix boundaries that the galaxy-tool-refactor upgrade would
block on. The trade-off is simplicity vs. safety. The recommended guidance:
use planemo upgrade --modernize for a first pass, then validate with
galaxy-tool-refactor upgrade --modernize for a rigorous behaviour-preserving
check before release.
planemo convert-help changes Galaxy's rendering engine for the <help>
block (from server-side docutils to client-side markdown-it). The
render-equivalence gate makes it behaviour-preserving in practice, but it is
a semantic swap, not a cosmetic fix — it must never be folded into
planemo format or planemo upgrade. The separate command makes the opt-in
nature explicit.
The removability gate for .lint_skip lines can only fire when the planemo
linter has a fully faithful GTR port. Early in Phase 4, the coverage mapping
may be conservative (more lines kept than necessary). This is the correct
failure mode — a false-negative (keeping a resolvable suppression) is safe;
a false-positive (removing a suppression for a check that still fires) would
create noise. The gate tightens as more linters get fix() implementations in
Phase 1.