Skip to content

Instantly share code, notes, and snippets.

@mvdbeek
Created June 13, 2026 09:42
Show Gist options
  • Select an option

  • Save mvdbeek/b74def7147a8652f0ee86bdf7599f9c6 to your computer and use it in GitHub Desktop.

Select an option

Save mvdbeek/b74def7147a8652f0ee86bdf7599f9c6 to your computer and use it in GitHub Desktop.
Upstreaming plan: galaxy-tool-refactor → planemo

Upstreaming plan: galaxy-tool-refactor → planemo

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.


1. Current state of redundancy

1a. Detection logic is written twice

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.

1b. Format / normalize is implemented twice, weakly in planemo

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.

1c. XSD validation is stronger in galaxy-tool-source

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.

1d. No upgrade command in planemo

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.

1e. The fix seam already exists but is unused

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.


2. Goals

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

  2. Give planemo users auto-fix for the issues planemo currently only reports. Tool authors should not need a second tool for the mechanical fixes.

  3. Add planemo upgrade so profile migration is a first-class planemo workflow, not a separate install.

  4. Keep LLM / agent-facing capabilities in galaxy-tool-refactor. The MCP server and structured agent outputs stay here. Non-LLM capabilities (RST→Markdown, .lint-skip reconciliation) 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.


3. Phased plan

Phase 1 — Implement fix() on galaxy.tool_util linters

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 False

A 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/Notrue/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.


Phase 2 — Wire up planemo commands

Target repo: galaxyproject/planemo

planemo lint --fix

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.

planemo format — replace the six-line body

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 — forward to planemo format

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.


Phase 3 — planemo convert-help

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:

  1. Loads the tool XML.
  2. Checks profile >= 24.2 (the XSD gate — earlier profiles don't support format="markdown" on <help>). Reports a skip reason if not met.
  3. 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).
  4. Rewrites the <help> element: sets format="markdown", replaces the body.
  5. Writes the file (or diffs under --check, or keeps a .bak under --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.


Phase 4 — planemo lint-skip

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.


Phase 5 — planemo upgrade

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:

What is ported

Profile-aware XSD oracle (~100 lines from galaxy_tool_source):

  • available_profiles() — list of vendored YYYY.MM strings
  • validate_at_profile(tree, profile) — validate the lxml tree against the correct XSD
  • oldest_valid_profile(tree, floor) — binary search for the minimum valid profile at or above floor (the minimal-bump oracle)
  • newest_valid_profile(tree) — the --modernize ceiling oracle

Profile-specific repair steps (from galaxy_tool_codemod):

  • repair_19_01(tree) — name output <data> elements
  • repair_21_09(tree) — normalise collection_type + has_size; repair stdio exit_code/regex
  • repair_24_0(tree) — hoist collection filters
  • repair_24_1(tree) — normalise format/ftype case
  • repair_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) — replace format="input" with format_source for the sole-data-input case
  • fix_from_work_dir_whitespace(tree) — strip whitespace from from_work_dir

What is NOT ported (planemo upgrade is minimal by default)

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.

Relationship to planemo autoupdate

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.


Phase 6 — Simplify galaxy-tool-lint

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.


4. What stays in galaxy-tool-refactor only

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

5. Sequencing and dependencies

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.


6. Trade-offs and risks

Fix ordering is load-bearing

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

galaxy.tool_util PR review cycle

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.

planemo format behaviour change

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.

Delegation precision in Phase 4

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 scope

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.

convert-help is opt-in by design

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.

lint-skip coverage gate conservatism

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment