From 0d8a211065172b25d937278275086e7d70bd95c5 Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Mon, 6 Jul 2026 22:51:18 -0500 Subject: [PATCH] feat(modules): flag legacy versions topic output --- nf_core/modules/lint/main_nf.py | 35 ++++++++ .../lint/rules/main_nf_versions_yml_topic.yml | 24 ++++++ tests/modules/lint/test_main_nf.py | 83 +++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 nf_core/modules/lint/rules/main_nf_versions_yml_topic.yml diff --git a/nf_core/modules/lint/main_nf.py b/nf_core/modules/lint/main_nf.py index 04b669a275..5caa70cfcc 100644 --- a/nf_core/modules/lint/main_nf.py +++ b/nf_core/modules/lint/main_nf.py @@ -8,11 +8,13 @@ from rich.progress import Progress +from nf_core import astgrep from nf_core.components.components_differ import ComponentsDiffer from nf_core.components.nfcore_component import NFCoreComponent from nf_core.modules.lint.meta_yml import _load_skip_nf_test_sets log = logging.getLogger(__name__) +MAIN_NF_VERSIONS_YML_TOPIC_RULE = Path(__file__).parent / "rules" / "main_nf_versions_yml_topic.yml" def main_nf( @@ -106,6 +108,12 @@ def main_nf( A warning is issued if a legacy YAML-based ``versions`` emit is used instead of a topic output. + main_nf_versions_yml_topic + ^^^^^^^^^^^^^^^^^^^^^^^^^^ + + A ``versions.yml`` topic output is only valid for template-backed modules. + Modules with inline version commands should emit version tuples directly. + """ inputs: list[str] = [] @@ -263,6 +271,17 @@ def main_nf( ("main_nf", "main_nf_shell_template", "No `template` found in `shell` block", module.main_nf) ) + if _has_inline_versions_yml_topic_output(lines_j, lines, script_lines + shell_lines + exec_lines): + module.warned.append( + ( + "main_nf", + "main_nf_versions_yml_topic", + "`versions.yml` topic outputs are only for template scripts; inline version commands should " + "emit `tuple val(\"${task.process}\"), val(''), eval(...), topic: versions`.", + module.main_nf, + ) + ) + # Check whether 'meta' is emitted when given as input if inputs and "meta" in inputs: module.has_meta = True @@ -953,6 +972,22 @@ def _parse_output_topics(self, line: str) -> list[str]: return output +def _has_inline_versions_yml_topic_output(source: str, output_lines: list[str], command_lines: list[str]) -> bool: + rule = astgrep.load_rule(MAIN_NF_VERSIONS_YML_TOPIC_RULE) + if astgrep.nextflow_available(): + from ast_grep_py import SgRoot + + root = SgRoot(source, "nextflow").root() + if root.find(kind="ERROR") is None: + config = {key: rule[key] for key in ("rule", "constraints", "utils") if key in rule} + return bool(root.find_all(config=config)) + + return any( + re.search(r"path\s*\(?[\"']versions\.yml[\"']\)?", line) and re.search(r"topic:\s*versions\b", line) + for line in output_lines + ) and any("> versions.yml" in line or "END_VERSIONS" in line for line in command_lines) + + def _is_empty(line): """Check whether a line is empty or a comment""" empty = False diff --git a/nf_core/modules/lint/rules/main_nf_versions_yml_topic.yml b/nf_core/modules/lint/rules/main_nf_versions_yml_topic.yml new file mode 100644 index 0000000000..1600c8e234 --- /dev/null +++ b/nf_core/modules/lint/rules/main_nf_versions_yml_topic.yml @@ -0,0 +1,24 @@ +# ast-grep lint rule (https://ast-grep.github.io/guide/project/lint-rule.html) +id: main_nf_versions_yml_topic +language: nextflow +severity: warning +message: "`versions.yml` topic output uses inline legacy YAML" +note: | + `path "versions.yml", emit: versions, topic: versions` is only valid for + template-backed modules. Inline version commands should emit version tuples + directly with `topic: versions`. +metadata: + fallback-regex: a^ +rule: + kind: process_definition + all: + - has: + kind: output_declaration + regex: 'path\s*\(?["'']versions\.yml["'']\)?.*topic:\s*versions|topic:\s*versions.*path\s*\(?["'']versions\.yml["'']\)?' + stopBy: end + - has: + any: + - kind: triple_quoted_string + - kind: interpolated_triple_quoted_string + regex: '(>\s*versions\.yml|END_VERSIONS)' + stopBy: end diff --git a/tests/modules/lint/test_main_nf.py b/tests/modules/lint/test_main_nf.py index 0893987d67..04a6e4e981 100644 --- a/tests/modules/lint/test_main_nf.py +++ b/tests/modules/lint/test_main_nf.py @@ -4,8 +4,10 @@ import pytest import nf_core.modules.lint +from nf_core import astgrep from nf_core.components.nfcore_component import NFCoreComponent from nf_core.modules.lint.main_nf import ( + _has_inline_versions_yml_topic_output, _parse_output_topics, check_container_link_line, check_nf_module_name, @@ -20,6 +22,27 @@ from .test_lint_utils import MockModuleLint +@pytest.mark.skipif(not astgrep.nextflow_available(), reason="tree-sitter-nextflow parser not available") +def test_inline_versions_yml_topic_output_matches_full_source_with_astgrep(): + """Full-source structural matching catches inline legacy versions.yml topic output.""" + source = """ +process MOCK_TOOL { + output: + path "versions.yml", emit: versions, topic: versions + + script: + \"\"\" + cat <<-END_VERSIONS > versions.yml + "${task.process}": + mock_tool: "1.0" + END_VERSIONS + \"\"\" +} +""" + + assert _has_inline_versions_yml_topic_output(source, [], []) + + @pytest.mark.parametrize( "content,passed,warned,failed", [ @@ -368,6 +391,66 @@ def test_additional_registry_from_nf_core_yml_passes_container_link(self): f"warned={[r.message for r in container_link_warns2]}" ) + def test_inline_versions_yml_topic_output_warns(self): + """A migrated topic output must not keep writing legacy versions.yml inline.""" + local_mod_dir = Path(self.pipeline_dir) / "modules" / "local" / "mock_tool" + local_mod_dir.mkdir(parents=True, exist_ok=True) + (local_mod_dir / "main.nf").write_text( + """ +process MOCK_TOOL { + input: + val(meta) + + output: + tuple val(meta), path("out.txt"), emit: results + path "versions.yml", emit: versions, topic: versions + + script: + \"\"\" + touch out.txt + cat <<-END_VERSIONS > versions.yml + "${task.process}": + mock_tool: "1.0" + END_VERSIONS + \"\"\" +} +""" + ) + + module_lint = nf_core.modules.lint.ModuleLint(directory=self.pipeline_dir) + module_lint.lint(local=True, print_results=False) + + assert any(r.lint_test == "main_nf_versions_yml_topic" for r in module_lint.warned), ( + f"Expected warning for inline versions.yml topic output, got {[r.lint_test for r in module_lint.warned]}" + ) + + def test_template_versions_yml_topic_output_does_not_warn(self): + """A template-backed versions.yml topic output is not an inline legacy YAML write.""" + local_mod_dir = Path(self.pipeline_dir) / "modules" / "local" / "mock_tool" + local_mod_dir.mkdir(parents=True, exist_ok=True) + (local_mod_dir / "main.nf").write_text( + """ +process MOCK_TOOL { + input: + val(meta) + + output: + tuple val(meta), path("out.txt"), emit: results + path "versions.yml", emit: versions, topic: versions + + shell: + template 'mock_tool.sh' +} +""" + ) + + module_lint = nf_core.modules.lint.ModuleLint(directory=self.pipeline_dir) + module_lint.lint(local=True, print_results=False) + + assert not any(r.lint_test == "main_nf_versions_yml_topic" for r in module_lint.warned), ( + f"Expected no inline versions.yml topic warning, got {[r.lint_test for r in module_lint.warned]}" + ) + def test_topics_and_emits_version_check(self): """Test that main_nf version emit and topics check works correctly"""