diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f437aa8a1..ee4b2af4de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@
### Linting
- accept `process_low_memory` as a standard module label ([#4264](https://github.com/nf-core/tools/pull/4264))
+- `pipeline_if_empty_null`, `system_exit` and `pipeline_todos` (incl. module/subworkflow todos) now match structurally with ast-grep and the tree-sitter-nextflow grammar when available (skips comments/strings), falling back to regex otherwise — per file, whenever a file has parse errors, so recall never drops below regex level. Match logic lives in ast-grep rule files under `nf_core/pipelines/lint/rules/` with snippet tests in `tests/pipelines/lint/rules/`
- improve linting for `modules.json` to add support for only installing subworkflows from a repository and provide more explicit error messages ([#4287](https://github.com/nf-core/tools/pull/4287))
- improve singularity_tag linting check (add skip, handle exceptions correctly, check against oras) ([#4358](https://github.com/nf-core/tools/pull/4358))
diff --git a/MANIFEST.in b/MANIFEST.in
index 66fc1afdb4..61eee928e8 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -8,5 +8,6 @@ include nf_core/assets/logo/nf-core-repo-logo-base-lightbg.png
include nf_core/assets/logo/nf-core-repo-logo-base-darkbg.png
include nf_core/assets/logo/placeholder_logo.svg
include nf_core/assets/logo/MavenPro-Bold.ttf
+graft nf_core/pipelines/lint/rules
include nf_core/pipelines/create/create.tcss
include nf_core/pipelines/create/template_features.yml
diff --git a/nf_core/astgrep.py b/nf_core/astgrep.py
new file mode 100644
index 0000000000..607bb2e29c
--- /dev/null
+++ b/nf_core/astgrep.py
@@ -0,0 +1,185 @@
+"""Structural (AST-based) matching of Nextflow code via ast-grep.
+
+Uses the tree-sitter-nextflow grammar (https://github.com/nextflow-io/tree-sitter-nextflow)
+registered as an ast-grep custom language. The compiled parser library is discovered from:
+
+1. The ``NFCORE_TREE_SITTER_NEXTFLOW_LIB`` environment variable (path to
+ ``libnextflow.so`` / ``libnextflow.dylib``),
+2. An installed ``tree-sitter-nextflow`` Python package (its compiled binding
+ exports the ``tree_sitter_nextflow`` symbol ast-grep needs); until it is on
+ PyPI, install with
+ ``pip install git+https://github.com/nextflow-io/tree-sitter-nextflow.git``, or
+3. A global ast-grep install (``~/.config/ast-grep/sgconfig.yml``), as set up by
+ tree-sitter-nextflow's ``install-ast-grep.sh --global``.
+
+If none is available, :func:`nextflow_available` returns ``False`` and callers
+should fall back to text-based matching.
+"""
+
+import logging
+import os
+import platform
+import re
+import sys
+from collections.abc import Iterable
+from functools import cache
+from pathlib import Path
+
+import yaml
+
+log = logging.getLogger(__name__)
+
+#: File extensions that the Nextflow grammar can parse (error recovery makes
+#: it usable for plain Groovy lib files too)
+NEXTFLOW_EXTENSIONS = {".nf", ".config", ".groovy"}
+
+GLOBAL_SGCONFIG = Path.home() / ".config" / "ast-grep" / "sgconfig.yml"
+
+
+def _platform_triple() -> str | None:
+ """Rust-style target triple used as libraryPath key in sgconfig.yml."""
+ arch = {"arm64": "aarch64", "aarch64": "aarch64", "x86_64": "x86_64", "amd64": "x86_64"}.get(
+ platform.machine().lower()
+ )
+ os_part = {"darwin": "apple-darwin", "linux": "unknown-linux-gnu", "win32": "pc-windows-msvc"}.get(sys.platform)
+ if arch and os_part:
+ return f"{arch}-{os_part}"
+ return None
+
+
+def _find_library() -> Path | None:
+ """Locate the compiled tree-sitter-nextflow parser library."""
+ env_path = os.environ.get("NFCORE_TREE_SITTER_NEXTFLOW_LIB")
+ if env_path:
+ if Path(env_path).is_file():
+ return Path(env_path)
+ log.debug(f"NFCORE_TREE_SITTER_NEXTFLOW_LIB set but not a file: {env_path}")
+
+ try:
+ import tree_sitter_nextflow
+
+ lib = next(Path(tree_sitter_nextflow.__file__).parent.glob("_binding*"), None)
+ if lib is not None:
+ return lib
+ except ImportError:
+ pass
+
+ if GLOBAL_SGCONFIG.is_file():
+ try:
+ with open(GLOBAL_SGCONFIG) as fh:
+ config = yaml.safe_load(fh)
+ library_path = config["customLanguages"]["nextflow"]["libraryPath"]
+ if isinstance(library_path, dict):
+ library_path = library_path.get(_platform_triple())
+ if library_path:
+ lib = GLOBAL_SGCONFIG.parent / library_path
+ if lib.is_file():
+ return lib
+ except (KeyError, TypeError, yaml.YAMLError) as e:
+ log.debug(f"Could not read nextflow language from {GLOBAL_SGCONFIG}: {e}")
+
+ return None
+
+
+@cache
+def nextflow_available() -> bool:
+ """Register the Nextflow custom language with ast-grep (once per process).
+
+ Returns False if ast-grep-py or the compiled parser library is unavailable.
+ """
+ try:
+ from ast_grep_py import register_dynamic_language
+ except ImportError:
+ log.debug("ast-grep-py not installed, structural Nextflow linting unavailable")
+ return False
+
+ lib = _find_library()
+ if lib is None:
+ log.debug("tree-sitter-nextflow parser library not found, structural Nextflow linting unavailable")
+ return False
+
+ try:
+ register_dynamic_language(
+ {
+ "nextflow": {
+ "library_path": str(lib),
+ "language_symbol": "tree_sitter_nextflow",
+ "expando_char": "_",
+ # required at runtime but missing from the CustomLang type stub
+ "extensions": ["nf", "config"], # type: ignore[typeddict-unknown-key]
+ }
+ }
+ )
+ except RuntimeError as e:
+ # e.g. an outdated library that does not export the parser symbol
+ log.debug(f"Could not register Nextflow ast-grep language from {lib}: {e}")
+ return False
+ log.debug(f"Registered Nextflow ast-grep language from {lib}")
+ return True
+
+
+def find_all(source: str, rule: dict):
+ """Find all nodes in Nextflow source matching an ast-grep rule.
+
+ Callers must check :func:`nextflow_available` first.
+ """
+ from ast_grep_py import SgRoot
+
+ return SgRoot(source, "nextflow").root().find_all(**rule)
+
+
+@cache
+def load_rule(path: Path) -> dict:
+ """Load an ast-grep lint rule file (id, severity, message, rule, ...)."""
+ with open(path) as fh:
+ return yaml.safe_load(fh)
+
+
+def scan(source: str, rule_config: dict):
+ """Find all nodes matching a full ast-grep rule config (rule/constraints/utils)."""
+ from ast_grep_py import SgRoot
+
+ config = {key: rule_config[key] for key in ("rule", "constraints", "utils") if key in rule_config}
+ return SgRoot(source, "nextflow").root().find_all(config=config)
+
+
+def find_matches(rule_config: dict, files: Iterable[Path | str], regex_unparseable: bool = False):
+ """Yield ``(file, line_number, snippet)`` for every rule match across files.
+
+ A Nextflow/Groovy file is matched structurally only when the parser is
+ available AND the file parses without ERROR nodes. A broken parse can
+ swallow the rest of a file (e.g. process directives the grammar does not
+ know yet), so any parse error means that file falls back to line-by-line
+ matching with the rule's ``metadata.fallback-regex`` — recall never drops
+ below regex level, and precision upgrades as the grammar matures.
+
+ ``regex_unparseable`` controls files the Nextflow grammar cannot parse at
+ all (e.g. markdown, yaml) when the parser is available: skipped by
+ default, regex-scanned if True (for rules that target all file types,
+ like TODOs).
+ """
+ structural = nextflow_available()
+ pattern = re.compile(rule_config["metadata"]["fallback-regex"])
+ for file in map(Path, files):
+ try:
+ text = file.read_text(encoding="latin1")
+ except (FileNotFoundError, IsADirectoryError, PermissionError) as e:
+ log.debug(f"Could not open file {file} while scanning for {rule_config.get('id')}: {e}")
+ continue
+
+ if structural and file.suffix in NEXTFLOW_EXTENSIONS:
+ from ast_grep_py import SgRoot
+
+ root = SgRoot(text, "nextflow").root()
+ if root.find(kind="ERROR") is None:
+ config = {key: rule_config[key] for key in ("rule", "constraints", "utils") if key in rule_config}
+ for match in root.find_all(config=config):
+ yield file, match.range().start.line + 1, match.text()
+ continue
+ log.debug(f"{file} has parse errors, falling back to regex for {rule_config.get('id')}")
+ elif structural and not regex_unparseable:
+ continue
+
+ for line_number, line in enumerate(text.splitlines(), start=1):
+ if pattern.search(line):
+ yield file, line_number, line.strip()
diff --git a/nf_core/pipelines/lint/pipeline_if_empty_null.py b/nf_core/pipelines/lint/pipeline_if_empty_null.py
index d56dc047f0..9a3619216f 100644
--- a/nf_core/pipelines/lint/pipeline_if_empty_null.py
+++ b/nf_core/pipelines/lint/pipeline_if_empty_null.py
@@ -1,11 +1,13 @@
import logging
-import re
from pathlib import Path
+from nf_core import astgrep
from nf_core.utils import get_wf_files
log = logging.getLogger(__name__)
+RULE_FILE = Path(__file__).parent / "rules" / "pipeline_if_empty_null.yml"
+
def pipeline_if_empty_null(self, root_dir=None):
"""Check for ifEmpty(null)
@@ -16,29 +18,23 @@ def pipeline_if_empty_null(self, root_dir=None):
There are multiple examples of workflows that inject null objects into channels using `ifEmpty(null)`, which can cause unhandled null pointer exceptions.
This lint test throws warnings for those instances.
- """
- passed = []
- warned = []
- file_paths = []
- pattern = re.compile(r"ifEmpty\s*\(\s*null\s*\)")
+ The match logic lives in the ast-grep rule file ``rules/pipeline_if_empty_null.yml``.
+ See ``nf_core.astgrep.find_matches`` for the structural vs regex-fallback behaviour.
+ """
# Pipelines don't provide a path, so use the workflow path.
# Modules run this function twice and provide a string path
if root_dir is None:
root_dir = self.wf_path
- for file in get_wf_files(root_dir):
- try:
- with open(Path(file), encoding="latin1") as fh:
- for line in fh:
- if re.findall(pattern, line):
- warned.append(f"`ifEmpty(null)` found in `{file}`: _{line}_")
- file_paths.append(Path(file))
- except FileNotFoundError:
- log.debug(f"Could not open file {file} in pipeline_if_empty_null lint test")
-
- if len(warned) == 0:
- passed.append("No `ifEmpty(null)` strings found")
+ rule = astgrep.load_rule(RULE_FILE)
+ warned = []
+ file_paths = []
+ for file, line_num, text in astgrep.find_matches(rule, get_wf_files(root_dir)):
+ warned.append(f"{rule['message']} in `{file}` [line {line_num}]: _{text}_")
+ file_paths.append(file)
+
+ passed = [] if warned else ["No `ifEmpty(null)` strings found"]
# return file_paths for use in subworkflow lint
return {"passed": passed, "warned": warned, "file_paths": file_paths}
diff --git a/nf_core/pipelines/lint/pipeline_todos.py b/nf_core/pipelines/lint/pipeline_todos.py
index a5f66a8fc8..24bb8898f7 100644
--- a/nf_core/pipelines/lint/pipeline_todos.py
+++ b/nf_core/pipelines/lint/pipeline_todos.py
@@ -3,8 +3,31 @@
import os
from pathlib import Path
+from nf_core import astgrep
+
log = logging.getLogger(__name__)
+RULE_FILE = Path(__file__).parent / "rules" / "pipeline_todos.yml"
+
+
+def _clean_todo(text: str) -> str:
+ """Reduce a matched comment to the TODO text itself."""
+ for line in text.splitlines():
+ if "TODO nf-core" in line:
+ text = line
+ break
+ return (
+ text.replace("", "")
+ .replace("/*", "")
+ .replace("*/", "")
+ .replace("*", "")
+ .replace("# TODO nf-core: ", "")
+ .replace("// TODO nf-core: ", "")
+ .replace("TODO nf-core: ", "")
+ .strip()
+ )
+
def pipeline_todos(self, root_dir=None):
"""Check for nf-core *TODO* lines.
@@ -26,6 +49,10 @@ def pipeline_todos(self, root_dir=None):
This lint test runs through all files in the pipeline and searches for these lines.
If any are found they will throw a warning.
+ Nextflow/Groovy files are matched structurally via the ast-grep rule file
+ ``rules/pipeline_todos.yml`` when the parser is available (a "TODO nf-core"
+ inside a string is not flagged); all other files are searched line by line.
+
.. tip:: Note that many GUI code editors have plugins to list all instances of *TODO*
in a given project directory. This is a very quick and convenient way to get
started on your pipeline!
@@ -45,33 +72,22 @@ def pipeline_todos(self, root_dir=None):
with open(Path(root_dir, ".gitignore"), encoding="latin1") as fh:
for line in fh:
ignore.append(Path(line.strip().rstrip("/")).name)
+
+ to_check = []
for root, dirs, files in os.walk(root_dir, topdown=True):
# Ignore files
for i_base in ignore:
i = str(Path(root, i_base))
dirs[:] = [d for d in dirs if not fnmatch.fnmatch(str(Path(root, d)), i)]
files[:] = [f for f in files if not fnmatch.fnmatch(str(Path(root, f)), i)]
- for fname in files:
- try:
- with open(Path(root, fname), encoding="latin1") as fh:
- for line in fh:
- if "TODO nf-core" in line:
- line = (
- line.replace("", "")
- .replace("# TODO nf-core: ", "")
- .replace("// TODO nf-core: ", "")
- .replace("TODO nf-core: ", "")
- .strip()
- )
- warned.append(f"TODO string in `{fname}`: _{line}_")
- file_paths.append(Path(root, fname))
- except FileNotFoundError:
- log.debug(f"Could not open file {fname} in pipeline_todos lint test")
+ to_check.extend(Path(root, fname) for fname in files)
+
+ rule = astgrep.load_rule(RULE_FILE)
+ for file, _line_num, text in astgrep.find_matches(rule, to_check, regex_unparseable=True):
+ warned.append(f"TODO string in `{file.name}`: _{_clean_todo(text)}_")
+ file_paths.append(file)
if len(warned) == 0:
passed.append("No TODO strings found")
- # HACK file paths are returned to allow usage of this function in modules/lint.py
- # Needs to be refactored!
return {"passed": passed, "warned": warned, "file_paths": file_paths}
diff --git a/nf_core/pipelines/lint/rules/pipeline_if_empty_null.yml b/nf_core/pipelines/lint/rules/pipeline_if_empty_null.yml
new file mode 100644
index 0000000000..8b29d79094
--- /dev/null
+++ b/nf_core/pipelines/lint/rules/pipeline_if_empty_null.yml
@@ -0,0 +1,28 @@
+# ast-grep lint rule (https://ast-grep.github.io/guide/project/lint-rule.html)
+# Also usable directly with `ast-grep scan` via a sgconfig.yml ruleDirs entry.
+id: pipeline_if_empty_null
+language: nextflow
+severity: warning
+message: "`ifEmpty(null)` found"
+note: |
+ Instead of `ifEmpty(null)`, use `ifEmpty([])` to keep a process executing on
+ optional input, or `ifEmpty { error ... }` when the channel must not be empty.
+ Injecting null objects into channels causes unhandled null pointer exceptions.
+metadata:
+ fallback-regex: ifEmpty\s*\(\s*null\s*\)
+rule:
+ any:
+ # method call form: ch.ifEmpty(null)
+ - pattern: $CH.ifEmpty(null)
+ # bare / piped form: ch | ifEmpty(null)
+ - pattern: ifEmpty(null)
+ # method calls whose receiver the grammar could not fully parse; only
+ # requires direct children `ifEmpty` and `null`
+ - all:
+ - kind: method_call
+ - has:
+ kind: identifier
+ regex: ^ifEmpty$
+ - has:
+ kind: simple_expression
+ regex: ^null$
diff --git a/nf_core/pipelines/lint/rules/pipeline_todos.yml b/nf_core/pipelines/lint/rules/pipeline_todos.yml
new file mode 100644
index 0000000000..2babef11b6
--- /dev/null
+++ b/nf_core/pipelines/lint/rules/pipeline_todos.yml
@@ -0,0 +1,16 @@
+# ast-grep lint rule (https://ast-grep.github.io/guide/project/lint-rule.html)
+id: pipeline_todos
+language: nextflow
+severity: warning
+message: "TODO string"
+note: |
+ The nf-core templates contain `TODO nf-core:` comments marking places that
+ developers need to edit. They should be removed once addressed.
+metadata:
+ fallback-regex: TODO nf-core
+# a comment node containing "TODO nf-core" (strings/code are not flagged)
+rule:
+ regex: TODO nf-core
+ any:
+ - kind: line_comment
+ - kind: block_comment
diff --git a/nf_core/pipelines/lint/rules/system_exit.yml b/nf_core/pipelines/lint/rules/system_exit.yml
new file mode 100644
index 0000000000..72e275f200
--- /dev/null
+++ b/nf_core/pipelines/lint/rules/system_exit.yml
@@ -0,0 +1,16 @@
+# ast-grep lint rule (https://ast-grep.github.io/guide/project/lint-rule.html)
+id: system_exit
+language: nextflow
+severity: warning
+message: "`System.exit` call found"
+note: |
+ Calls to `System.exit(1)` should be replaced by throwing errors, e.g.
+ `error "Something went wrong"`. `System.exit(0)` is allowed.
+metadata:
+ fallback-regex: System\.exit\s*\((?!\s*0\s*\))
+rule:
+ pattern: System.exit($CODE)
+constraints:
+ CODE:
+ not:
+ regex: ^0$
diff --git a/nf_core/pipelines/lint/system_exit.py b/nf_core/pipelines/lint/system_exit.py
index b891fea7a0..ea3bd4e46b 100644
--- a/nf_core/pipelines/lint/system_exit.py
+++ b/nf_core/pipelines/lint/system_exit.py
@@ -1,8 +1,12 @@
import logging
from pathlib import Path
+from nf_core import astgrep
+
log = logging.getLogger(__name__)
+RULE_FILE = Path(__file__).parent / "rules" / "system_exit.yml"
+
def system_exit(self):
"""Check for System.exit calls in groovy/nextflow code
@@ -11,27 +15,18 @@ def system_exit(self):
This lint test looks for all calls to `System.exit`
in any file with the `.nf` or `.groovy` extension
- """
- passed = []
- warned = []
+ The match logic lives in the ast-grep rule file ``rules/system_exit.yml``.
+ See ``nf_core.astgrep.find_matches`` for the structural vs regex-fallback behaviour.
+ """
root_dir = Path(self.wf_path)
-
- # Get all groovy and nf files
- groovy_files = list(root_dir.rglob("*.groovy"))
- nf_files = list(root_dir.rglob("*.nf"))
- to_check = nf_files + groovy_files
-
- for file in to_check:
- try:
- with file.open() as fh:
- for i, line in enumerate(fh.readlines(), start=1):
- if "System.exit" in line and "System.exit(0)" not in line:
- warned.append(f"`System.exit` in {file.name}: _{line.strip()}_ [line {i}]")
- except FileNotFoundError:
- log.debug(f"Could not open file {file.name} in system_exit lint test")
-
- if len(warned) == 0:
- passed.append("No `System.exit` calls found")
+ files = list(root_dir.rglob("*.nf")) + list(root_dir.rglob("*.groovy"))
+
+ rule = astgrep.load_rule(RULE_FILE)
+ warned = [
+ f"{rule['message']} in {file.name}: _{text}_ [line {line_num}]"
+ for file, line_num, text in astgrep.find_matches(rule, files)
+ ]
+ passed = [] if warned else ["No `System.exit` calls found"]
return {"passed": passed, "warned": warned}
diff --git a/pyproject.toml b/pyproject.toml
index b5f2bb9544..310c5408e4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,7 @@ authors = [
]
requires-python = ">=3.10, <4"
dependencies = [
+ "ast-grep-py>=0.44.0",
"click",
"filetype",
"GitPython",
@@ -86,6 +87,17 @@ dev = [
"pytest>=8.0.0",
]
+# PEP 735 dependency group (installed by `uv sync` / `pip install --group dev`).
+# Direct git URLs are not allowed in [project] dependencies on PyPI, so the
+# Nextflow tree-sitter parser lives here until it is published as a wheel.
+# Without it, nf_core.astgrep falls back to regex-based linting.
+[dependency-groups]
+dev = [
+ # pinned to the PR branch SHA until nextflow-io/tree-sitter-nextflow#22
+ # (symbol export + grammar fix) merges; then drop the pin
+ "tree-sitter-nextflow @ git+https://github.com/nextflow-io/tree-sitter-nextflow.git@1416dd3743fd4ae7e76057f04e1e89432dfe5669",
+]
+
[project.urls]
Homepage = "https://github.com/nf-core/tools"
Documentation = "https://nf-co.re/docs/nf-core-tools"
diff --git a/scripts/astgrep_migration_status.py b/scripts/astgrep_migration_status.py
new file mode 100644
index 0000000000..12765b22ce
--- /dev/null
+++ b/scripts/astgrep_migration_status.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+"""Report how many nf-core lint checks use ast-grep YAML rules vs Python/regex.
+
+A check counts as migrated if its own module uses `nf_core.astgrep`, or if it
+delegates (via `from nf_core..lint. import ...`) to a module that does.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+AREAS = {
+ "pipelines": ROOT / "nf_core" / "pipelines" / "lint",
+ "modules": ROOT / "nf_core" / "modules" / "lint",
+ "subworkflows": ROOT / "nf_core" / "subworkflows" / "lint",
+}
+RULES_DIR = ROOT / "nf_core" / "pipelines" / "lint" / "rules"
+IMPORT_RE = re.compile(r"from nf_core\.\w+\.lint\.(\w+) import")
+
+
+def is_check(path: Path) -> bool:
+ stem = path.stem
+ return path.suffix == ".py" and stem != "__init__" and stem != "lint_utils" and not stem.startswith("_")
+
+
+def scan() -> tuple[dict[str, bool], dict[str, set[str]]]:
+ """Return (direct: stem->astgrep in own source, imports: stem->imported lint stems)."""
+ direct: dict[str, bool] = {}
+ imports: dict[str, set[str]] = {}
+ for area_dir in AREAS.values():
+ for path in area_dir.glob("*.py"):
+ if not is_check(path):
+ continue
+ text = path.read_text()
+ direct[path.stem] = "astgrep" in text
+ imports[path.stem] = set(IMPORT_RE.findall(text))
+ return direct, imports
+
+
+def is_migrated(stem: str, direct: dict[str, bool], imports: dict[str, set[str]], seen: set[str] | None = None) -> bool:
+ seen = seen if seen is not None else set()
+ if stem in seen:
+ return False
+ seen.add(stem)
+ if direct.get(stem):
+ return True
+ return any(is_migrated(dep, direct, imports, seen) for dep in imports.get(stem, ()))
+
+
+def build() -> dict:
+ direct, imports = scan()
+ result: dict = {}
+ for area, area_dir in AREAS.items():
+ checks = sorted(p.stem for p in area_dir.glob("*.py") if is_check(p))
+ migrated = [c for c in checks if is_migrated(c, direct, imports)]
+ result[area] = {
+ "total": len(checks),
+ "migrated": migrated,
+ "not_migrated": [c for c in checks if c not in migrated],
+ }
+ result["rules"] = sorted(p.name for p in RULES_DIR.glob("*.yml")) if RULES_DIR.is_dir() else []
+ return result
+
+
+def print_table(data: dict) -> None:
+ total = migrated = 0
+ for area in AREAS:
+ d = data[area]
+ total += d["total"]
+ migrated += len(d["migrated"])
+ print(f"\n{area}: {len(d['migrated'])}/{d['total']} migrated")
+ print(f" migrated: {', '.join(d['migrated']) or '-'}")
+ print(f" not migrated: {', '.join(d['not_migrated']) or '-'}")
+ pct = round(100 * migrated / total) if total else 0
+ print(f"\n{migrated}/{total} lint modules use ast-grep rules ({pct}%)")
+ print(f"\nrule files in nf_core/pipelines/lint/rules/ ({len(data['rules'])}):")
+ for name in data["rules"]:
+ print(f" {name}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--json", action="store_true", help="emit JSON instead of a table")
+ args = parser.parse_args()
+ data = build()
+ if args.json:
+ json.dump(data, sys.stdout, indent=2)
+ print()
+ else:
+ print_table(data)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/pipelines/lint/rules/pipeline_if_empty_null-test.yml b/tests/pipelines/lint/rules/pipeline_if_empty_null-test.yml
new file mode 100644
index 0000000000..c6d362e15e
--- /dev/null
+++ b/tests/pipelines/lint/rules/pipeline_if_empty_null-test.yml
@@ -0,0 +1,21 @@
+# ast-grep rule test (https://ast-grep.github.io/guide/test-rule.html)
+# Run by tests/pipelines/lint/test_astgrep_rules.py: every `valid` snippet
+# must produce no matches, every `invalid` snippet at least one.
+id: pipeline_if_empty_null
+valid:
+ - ch.ifEmpty([])
+ - ch.ifEmpty { error "empty samplesheet" }
+ - |
+ workflow {
+ // ifEmpty(null) in a comment is fine
+ ch_ok = ch.toList()
+ }
+invalid:
+ - ch.ifEmpty(null)
+ - ch.ifEmpty( null )
+ - Channel.fromPath(params.input).ifEmpty(null)
+ - |
+ ch.ifEmpty(
+ null
+ )
+ - ch | ifEmpty(null)
diff --git a/tests/pipelines/lint/rules/pipeline_todos-test.yml b/tests/pipelines/lint/rules/pipeline_todos-test.yml
new file mode 100644
index 0000000000..2680715667
--- /dev/null
+++ b/tests/pipelines/lint/rules/pipeline_todos-test.yml
@@ -0,0 +1,19 @@
+# ast-grep rule test (https://ast-grep.github.io/guide/test-rule.html)
+id: pipeline_todos
+valid:
+ - "// a regular comment"
+ - 'println "TODO nf-core: inside a string is not a template TODO"'
+invalid:
+ - "// TODO nf-core: edit this section"
+ - |
+ /*
+ * TODO nf-core: a block comment TODO
+ */
+ - |
+ process FOO {
+ script:
+ // TODO nf-core: replace the command below
+ """
+ echo hi
+ """
+ }
diff --git a/tests/pipelines/lint/rules/system_exit-test.yml b/tests/pipelines/lint/rules/system_exit-test.yml
new file mode 100644
index 0000000000..beeaf9108f
--- /dev/null
+++ b/tests/pipelines/lint/rules/system_exit-test.yml
@@ -0,0 +1,16 @@
+# ast-grep rule test (https://ast-grep.github.io/guide/test-rule.html)
+id: system_exit
+valid:
+ - System.exit(0)
+ - "// System.exit(1) in a comment is fine"
+ - error "Something went wrong"
+invalid:
+ - System.exit(1)
+ - System.exit(status)
+ - |
+ class Utils {
+ public static void fail(msg) {
+ log.error(msg)
+ System.exit(1)
+ }
+ }
diff --git a/tests/pipelines/lint/test_astgrep_rules.py b/tests/pipelines/lint/test_astgrep_rules.py
new file mode 100644
index 0000000000..4dd62e7bcb
--- /dev/null
+++ b/tests/pipelines/lint/test_astgrep_rules.py
@@ -0,0 +1,48 @@
+"""Generic harness for ast-grep lint rule tests.
+
+Each rule in nf_core/pipelines/lint/rules/.yml can ship a test file
+tests/pipelines/lint/rules/-test.yml in ast-grep's test-rule format
+(https://ast-grep.github.io/guide/test-rule.html): `valid` snippets must not
+match the rule, `invalid` snippets must. Adding a new rule needs no new
+Python — just the rule file and a test file.
+"""
+
+from pathlib import Path
+
+import pytest
+import yaml
+
+import nf_core.pipelines.lint
+from nf_core import astgrep
+
+RULES_DIR = Path(nf_core.pipelines.lint.__file__).parent / "rules"
+TESTS_DIR = Path(__file__).parent / "rules"
+
+pytestmark = pytest.mark.skipif(not astgrep.nextflow_available(), reason="tree-sitter-nextflow parser not available")
+
+
+def rule_test_files():
+ return sorted(TESTS_DIR.glob("*-test.yml"))
+
+
+def test_every_rule_has_a_test_file():
+ tested = {f.name.removesuffix("-test.yml") for f in rule_test_files()}
+ rules = {f.stem for f in RULES_DIR.glob("*.yml")}
+ assert rules == tested, f"rules without tests: {rules - tested}; tests without rules: {tested - rules}"
+
+
+@pytest.mark.parametrize("test_file", rule_test_files(), ids=lambda p: p.name.removesuffix("-test.yml"))
+def test_rule(test_file):
+ with open(test_file) as fh:
+ spec = yaml.safe_load(fh)
+ rule_config = astgrep.load_rule(RULES_DIR / f"{spec['id']}.yml")
+
+ snippets = [(s, True) for s in spec.get("valid", [])] + [(s, False) for s in spec.get("invalid", [])]
+ for snippet, should_be_clean in snippets:
+ # a snippet with an unquoted `foo: bar` parses as a YAML map, not a string
+ assert isinstance(snippet, str), f"snippet in {test_file.name} is not a string (quote it?): {snippet!r}"
+ matches = astgrep.scan(snippet, rule_config)
+ if should_be_clean:
+ assert not matches, f"valid snippet matched {spec['id']}: {snippet!r}"
+ else:
+ assert matches, f"invalid snippet did not match {spec['id']}: {snippet!r}"
diff --git a/tests/pipelines/lint/test_if_empty_null.py b/tests/pipelines/lint/test_if_empty_null.py
index 3a324fcba8..d79af88c8d 100644
--- a/tests/pipelines/lint/test_if_empty_null.py
+++ b/tests/pipelines/lint/test_if_empty_null.py
@@ -1,8 +1,11 @@
from pathlib import Path
+from unittest import mock
+import pytest
import yaml
import nf_core.pipelines.lint
+from nf_core import astgrep
from ..test_lint import TestLint
@@ -15,8 +18,9 @@ def setUp(self) -> None:
with open(self.nf_core_yml_path) as f:
self.nf_core_yml = yaml.safe_load(f)
- def test_if_empty_null_throws_warn(self):
- """Tests finding ifEmpty(null) in file throws warn in linting"""
+ @mock.patch("nf_core.astgrep.nextflow_available", return_value=False)
+ def test_if_empty_null_throws_warn(self, _mock_astgrep):
+ """Tests finding ifEmpty(null) in file throws warn in linting (regex fallback)"""
# Create a file and add examples that should fail linting
txt_file = Path(self.new_pipeline) / "docs" / "test.txt"
with open(txt_file, "w") as f:
@@ -36,3 +40,24 @@ def test_if_empty_null_throws_warn(self):
lint_obj._load()
result = lint_obj.pipeline_if_empty_null()
assert len(result["warned"]) == 8
+
+ @pytest.mark.skipif(not astgrep.nextflow_available(), reason="tree-sitter-nextflow parser not available")
+ def test_if_empty_null_astgrep(self):
+ """Structural matching flags real ifEmpty(null) calls but not comments"""
+ nf_file = Path(self.new_pipeline) / "if_empty_test.nf"
+ nf_file.write_text(
+ "workflow {\n"
+ " ch_a = Channel.fromPath(params.input).ifEmpty(null)\n"
+ " ch_b = ch_a.ifEmpty( null )\n"
+ " ch_c = ch_b.ifEmpty(\n"
+ " null\n"
+ " )\n"
+ " ch_ok = ch_c.ifEmpty([])\n"
+ " // ifEmpty(null) in a comment should not be flagged\n"
+ "}\n"
+ )
+ lint_obj = nf_core.pipelines.lint.PipelineLint(self.new_pipeline)
+ lint_obj._load()
+ result = lint_obj.pipeline_if_empty_null()
+ warned = [w for w in result["warned"] if "if_empty_test.nf" in w]
+ assert len(warned) == 3
diff --git a/tests/test_astgrep.py b/tests/test_astgrep.py
new file mode 100644
index 0000000000..f0b41c607e
--- /dev/null
+++ b/tests/test_astgrep.py
@@ -0,0 +1,22 @@
+"""Tests for nf_core.astgrep parser discovery and registration."""
+
+from pathlib import Path
+from unittest import mock
+
+from nf_core import astgrep
+
+
+def test_nextflow_available_bad_library_falls_back():
+ """A discovered library without the parser symbol must disable astgrep, not crash.
+
+ Regression: an outdated tree-sitter-nextflow wheel (without the exported
+ tree_sitter_nextflow symbol) made nextflow_available() raise RuntimeError,
+ crashing lint instead of falling back to regex matching.
+ """
+ bogus_lib = Path(astgrep.__file__) # a real file that is not a parser library
+ astgrep.nextflow_available.cache_clear()
+ try:
+ with mock.patch("nf_core.astgrep._find_library", return_value=bogus_lib):
+ assert astgrep.nextflow_available() is False
+ finally:
+ astgrep.nextflow_available.cache_clear()
diff --git a/uv.lock b/uv.lock
index 47efc64a9b..e606dec4fb 100644
--- a/uv.lock
+++ b/uv.lock
@@ -199,6 +199,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/66/df/32574bc8f1d440d40f4aaf3b455316b2b1536c7243c985a90f8516cf3074/arcp-0.2.1-py2.py3-none-any.whl", hash = "sha256:4e09b2d8a9fc3fda7ec112b553498ff032ea7de354e27dbeb1acc53667122444", size = 15838, upload-time = "2020-02-12T12:05:05.769Z" },
]
+[[package]]
+name = "ast-grep-py"
+version = "0.44.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/6e/ce10143ff48cdf1d8b52bc43baeed2f88ac19e0499a04336900d2b047b19/ast_grep_py-0.44.0.tar.gz", hash = "sha256:8f3b734d0ab0b1e7692cde4eca3f47c440e0398caee7420d07b3645f53123b77", size = 154735, upload-time = "2026-06-22T02:05:33.598Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/7b/ff91e80d6bd6475e62befed833acbe4720474028ed0756d0fae35eb2b554/ast_grep_py-0.44.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:358e5a68ca4a3abc30699d70a76c0413d56b2f7d61289f8aa1cdf9cb6bba58df", size = 5346271, upload-time = "2026-06-22T02:04:35.317Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/b7/a0215c2ccd91268928b8a45cc50e2af9039f73ac19aa02c89d7b366d82d6/ast_grep_py-0.44.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ed9820be1c6fb455970401540b38d160a00ea8d789d12dc752b1e125872adde6", size = 5461973, upload-time = "2026-06-22T02:04:37.924Z" },
+ { url = "https://files.pythonhosted.org/packages/27/57/29ef47d3af8af3418a3410a956bed02421c5deb39bde1732ba584fb849fb/ast_grep_py-0.44.0-cp310-cp310-win32.whl", hash = "sha256:89fd5ad0382fa2b09c05ea75c51501f4a048993299e972f5241804e4b3941a63", size = 5066296, upload-time = "2026-06-22T02:04:39.891Z" },
+ { url = "https://files.pythonhosted.org/packages/52/f5/2be3f1a58fc56a854cf7d4faa078542a7ddba82434fe76a7584c16be7218/ast_grep_py-0.44.0-cp310-cp310-win_amd64.whl", hash = "sha256:6c7bc50fa24e049b8001fdac97ed686c2e34bfdff85907030877ec21bbe7e0c5", size = 5203228, upload-time = "2026-06-22T02:04:42.137Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/f9/5c953797784f077c8367b0b092e12f83d40bd2057ee46052f9c6ec665882/ast_grep_py-0.44.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0baf89b16c1d16aeef8371a2b77c9dbb69aa4b5169d285ef2781e93804b6dd8f", size = 5371727, upload-time = "2026-06-22T02:04:44.33Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/7d/1e98e5962eedc5c8aad9e80eaece650c332982bbbfa4563b6fb155356c15/ast_grep_py-0.44.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e6e482537ccbeb247f146ba85c93790d512eb9494db1a9fd80e4a85f23e56931", size = 5542256, upload-time = "2026-06-22T02:04:46.35Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/30/55ec282ba646b6e11e920d93cf9f04a063fef9266ec807e3bbaeb625f420/ast_grep_py-0.44.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:11f90fb62f197de40cdf7eb36820b44ddb91c0ae4ff4dc4a0e34a991ded5eded", size = 5345927, upload-time = "2026-06-22T02:04:48.392Z" },
+ { url = "https://files.pythonhosted.org/packages/30/0a/f76c66afdad9e12a1357b34f219aa63b5ac7ec6b96ff7d934c3ca7c88856/ast_grep_py-0.44.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c18b48328f5fdd97d9e063f3c0ca8febe8619c7d3a833a5a2aeca5a75c68b72f", size = 5461811, upload-time = "2026-06-22T02:04:50.55Z" },
+ { url = "https://files.pythonhosted.org/packages/62/9b/c324f3aa3e74f3ac2e3111872d66c0bf9ed8b4a2e8fa3a07092edd2c5a69/ast_grep_py-0.44.0-cp311-cp311-win32.whl", hash = "sha256:aeac8668c4cbdddc9d6af1131499db7f56456294db9b2e62ce5d4022cbf4f97b", size = 5066660, upload-time = "2026-06-22T02:04:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/9e/08c21da68c3ff2f521e34439011b7cc63c4ff375c4c08e91d1f67b6a26cd/ast_grep_py-0.44.0-cp311-cp311-win_amd64.whl", hash = "sha256:5930f21e49909a857f1feae886400dd064ef1f8591eec2803361d77e8623f74b", size = 5202852, upload-time = "2026-06-22T02:04:54.428Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/0c/31b9360618ff86ab300a97644b4924dd4ad0feeef7f5adbd5bed4fcab036/ast_grep_py-0.44.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:44a43e0290a8a16a200c219a500ba0b35fdc118e49ce56b179caec548adac8df", size = 5369994, upload-time = "2026-06-22T02:04:56.448Z" },
+ { url = "https://files.pythonhosted.org/packages/10/80/41650e7f2c82a539597dea0d32bb106f646affa0f919969f08e2b650b4eb/ast_grep_py-0.44.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7040f658c8385d1269f1f18771dd00b9fc61898dc919e5f6ecf9aab91edd2515", size = 5541629, upload-time = "2026-06-22T02:04:58.395Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/42/9dcea1ed09be4ca7b024569dc62e650ad7e737b05b9d98a2df51efe18fc0/ast_grep_py-0.44.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1212e8b3ec3bb00b32682229a3f1f3e313d3974508836ccb4edb4fb1fde8c90a", size = 5347659, upload-time = "2026-06-22T02:05:00.8Z" },
+ { url = "https://files.pythonhosted.org/packages/33/28/a38eea926dcf2563a93d1a3b4a4c8556a4578f68cf92cda5bb4b97f05bce/ast_grep_py-0.44.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:af57de4ad3474fcb1fb894aeded8dd9eca00cebfc03f374b58102044a860ef93", size = 5463240, upload-time = "2026-06-22T02:05:02.723Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/30/e30583ccbde8291a70c70181f34e685814579233ae36f9bb967c9a461f6f/ast_grep_py-0.44.0-cp312-cp312-win32.whl", hash = "sha256:541563443fdfa5d0abaf415997f40a834377373cf63be908be13213222d5c85d", size = 5066326, upload-time = "2026-06-22T02:05:04.665Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/43/81466ad4bf4d52b62fb5c5fc890c7f6e206722677232e37c0ff1eee2154f/ast_grep_py-0.44.0-cp312-cp312-win_amd64.whl", hash = "sha256:7064c0b162bb62a2e5e23962d358edb6fca398b05e670a32c3b280b23a21a098", size = 5200253, upload-time = "2026-06-22T02:05:06.877Z" },
+ { url = "https://files.pythonhosted.org/packages/17/b6/f12df11c3993d7bace5871b8e3e51ca8958a566eb16150adaee53ea59b24/ast_grep_py-0.44.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:17197824467ee05eee2b1ca7bda3ed7906c7e6c705d09a9061b47a2c7a6a07f1", size = 5371041, upload-time = "2026-06-22T02:05:08.976Z" },
+ { url = "https://files.pythonhosted.org/packages/53/ee/77d4546d92adb0b84aaa60cd47b73cfa9bcf9228324f177b65e74b572569/ast_grep_py-0.44.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8efb5f047b74d447f0cd6ade41040bcba965df66d7a349845b61a6be73148b99", size = 5541815, upload-time = "2026-06-22T02:05:11.133Z" },
+ { url = "https://files.pythonhosted.org/packages/07/8a/71ec5d93833225c65c127b91e512a2cbcddb00e6c970a048d5efdcc6a79d/ast_grep_py-0.44.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:42b37f442489c1d474117de6cbf28fd26085886f9687c46e84717aed1bfd7517", size = 5346972, upload-time = "2026-06-22T02:05:13.238Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/9e/4f8cb53482f38a1a9809d78f1d83deac6a1d1e5e398aaeb88aa1594fe319/ast_grep_py-0.44.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:d950210e334d60a97e0332016e81981e94bd72f3ba1b8a7e96d52006cdb22bba", size = 5462711, upload-time = "2026-06-22T02:05:15.285Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/5b/01f97c9e864af461647721f27bf2801f090eda726b3a595ea97cc9d3d8d4/ast_grep_py-0.44.0-cp313-cp313-win32.whl", hash = "sha256:ee7d12e26eccbbc663f7ba67a61470df70c5558370df29574f3c6ccfe5b37e36", size = 5067044, upload-time = "2026-06-22T02:05:17.701Z" },
+ { url = "https://files.pythonhosted.org/packages/22/36/c6189c3d411840159246404638671619218cd169e1d97c3650ff0b98cf25/ast_grep_py-0.44.0-cp313-cp313-win_amd64.whl", hash = "sha256:d08435649095a1d9a0d9f02d94edff94893d205324132d107195e2be9cbbd3f5", size = 5199777, upload-time = "2026-06-22T02:05:19.743Z" },
+ { url = "https://files.pythonhosted.org/packages/80/9d/03fa2531beddd07aee37eb34be4e27bf3bcf29c74a9fca31380f0b71cc77/ast_grep_py-0.44.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:aeb08e6653045104c12cae6ad9b31ab8b5e1988cd1767ea0a09ae97b9329628e", size = 5370192, upload-time = "2026-06-22T02:05:21.745Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/73/0e9861282f24d4bef0123221b02ba0a3de31019965d9f0ac2c6fdc5d06ea/ast_grep_py-0.44.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:007db74cd85dda2614292829c1fc9c478aff66cc64cc24ec6eefbc02c4ab444f", size = 5541688, upload-time = "2026-06-22T02:05:23.667Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/02/10d63d7033ad2c4c806855ba025a6bd01bbc8ac88f42481c9acc70321ff8/ast_grep_py-0.44.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:b269615172d5fcf40caed90c6fb04d84820bd2f9aa31d39ed07d0d8ed14e3fed", size = 5343467, upload-time = "2026-06-22T02:05:25.725Z" },
+ { url = "https://files.pythonhosted.org/packages/78/3c/6b875cf00ec0381c0b9e143349ccef6e50a76cecc24ded11e6625f594d7c/ast_grep_py-0.44.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:1e46ef7eb98d650bcb9d384bdee15f656c42dcecb44e655b3269eb512d2683e3", size = 5461873, upload-time = "2026-06-22T02:05:27.925Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/22/bf8a4ea5d60111e2cfeead14c4b95c2062afff1409c74ba27d497afafcba/ast_grep_py-0.44.0-cp314-cp314-win32.whl", hash = "sha256:1139ca5b196ef121af2ba131c4337ed22ca35feeebeb58420842e6cfe9e7a329", size = 5066432, upload-time = "2026-06-22T02:05:29.944Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/16/9a5859450eb9e6412477ec010aeebceefba4bd91a7ec0a4cfd0ebd2a9017/ast_grep_py-0.44.0-cp314-cp314-win_amd64.whl", hash = "sha256:6b0354ee5610a43d756aebab4acc5bc65a890bc7be681d30b72b84dd6d6011b6", size = 5202462, upload-time = "2026-06-22T02:05:32.086Z" },
+]
+
[[package]]
name = "ast-serialize"
version = "0.3.0"
@@ -1615,9 +1651,10 @@ wheels = [
[[package]]
name = "nf-core"
-version = "4.0.3dev"
+version = "4.0.3.dev0"
source = { editable = "." }
dependencies = [
+ { name = "ast-grep-py" },
{ name = "click" },
{ name = "filetype" },
{ name = "gitpython" },
@@ -1676,8 +1713,14 @@ dev = [
{ name = "typing-extensions" },
]
+[package.dev-dependencies]
+dev = [
+ { name = "tree-sitter-nextflow" },
+]
+
[package.metadata]
requires-dist = [
+ { name = "ast-grep-py", specifier = ">=0.44.0" },
{ name = "click" },
{ name = "filetype" },
{ name = "gitpython" },
@@ -1730,6 +1773,9 @@ requires-dist = [
]
provides-extras = ["dev"]
+[package.metadata.requires-dev]
+dev = [{ name = "tree-sitter-nextflow", git = "https://github.com/nextflow-io/tree-sitter-nextflow.git?rev=1416dd3743fd4ae7e76057f04e1e89432dfe5669" }]
+
[[package]]
name = "numpy"
version = "2.2.6"
@@ -3497,6 +3543,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
+[[package]]
+name = "tree-sitter-nextflow"
+version = "0.1.0"
+source = { git = "https://github.com/nextflow-io/tree-sitter-nextflow.git?rev=1416dd3743fd4ae7e76057f04e1e89432dfe5669#1416dd3743fd4ae7e76057f04e1e89432dfe5669" }
+
[[package]]
name = "trogon"
version = "0.6.0"