Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions nf_core/modules/lint/main_nf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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('<tool>'), 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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions nf_core/modules/lint/rules/main_nf_versions_yml_topic.yml
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions tests/modules/lint/test_main_nf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
[
Expand Down Expand Up @@ -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"""

Expand Down
Loading