From 0fadaebc8afad532de688af66e54a0cc3b86af78 Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Mon, 6 Jul 2026 22:49:38 -0500 Subject: [PATCH 1/3] feat: add AST-grep lint foundations --- nf_core/astgrep.py | 185 ++++++++++++++++++++++++++++ pyproject.toml | 12 ++ scripts/astgrep_migration_status.py | 99 +++++++++++++++ tests/test_astgrep.py | 22 ++++ uv.lock | 53 +++++++- 5 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 nf_core/astgrep.py create mode 100644 scripts/astgrep_migration_status.py create mode 100644 tests/test_astgrep.py 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/pyproject.toml b/pyproject.toml index b5f2bb9544..390384ef99 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@dc5bda3", +] + [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/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..d0a7dfbab7 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=dc5bda3" }] + [[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=dc5bda3#dc5bda3dae6b28b17f6b9797b32318bb1a8c65a0" } + [[package]] name = "trogon" version = "0.6.0" From d90cc756324d01c9c4a38c7ef2a4be369a7e4fd1 Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Mon, 6 Jul 2026 22:50:11 -0500 Subject: [PATCH 2/3] feat(pipelines): migrate lint rules to AST-grep --- CHANGELOG.md | 1 + MANIFEST.in | 1 + .../pipelines/lint/pipeline_if_empty_null.py | 32 +++++------ nf_core/pipelines/lint/pipeline_todos.py | 54 ++++++++++++------- .../lint/rules/pipeline_if_empty_null.yml | 28 ++++++++++ .../pipelines/lint/rules/pipeline_todos.yml | 16 ++++++ nf_core/pipelines/lint/rules/system_exit.yml | 16 ++++++ nf_core/pipelines/lint/system_exit.py | 35 ++++++------ .../rules/pipeline_if_empty_null-test.yml | 21 ++++++++ .../lint/rules/pipeline_todos-test.yml | 19 +++++++ .../pipelines/lint/rules/system_exit-test.yml | 16 ++++++ tests/pipelines/lint/test_astgrep_rules.py | 48 +++++++++++++++++ tests/pipelines/lint/test_if_empty_null.py | 29 +++++++++- 13 files changed, 257 insertions(+), 59 deletions(-) create mode 100644 nf_core/pipelines/lint/rules/pipeline_if_empty_null.yml create mode 100644 nf_core/pipelines/lint/rules/pipeline_todos.yml create mode 100644 nf_core/pipelines/lint/rules/system_exit.yml create mode 100644 tests/pipelines/lint/rules/pipeline_if_empty_null-test.yml create mode 100644 tests/pipelines/lint/rules/pipeline_todos-test.yml create mode 100644 tests/pipelines/lint/rules/system_exit-test.yml create mode 100644 tests/pipelines/lint/test_astgrep_rules.py 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/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/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 From f9b709f550c07cb8764b813c5b80f900ef53c679 Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Mon, 6 Jul 2026 22:50:59 -0500 Subject: [PATCH 3/3] deps: bump tree-sitter-nextflow pin --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 390384ef99..310c5408e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ dev = [ 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@dc5bda3", + "tree-sitter-nextflow @ git+https://github.com/nextflow-io/tree-sitter-nextflow.git@1416dd3743fd4ae7e76057f04e1e89432dfe5669", ] [project.urls] diff --git a/uv.lock b/uv.lock index d0a7dfbab7..e606dec4fb 100644 --- a/uv.lock +++ b/uv.lock @@ -1774,7 +1774,7 @@ 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=dc5bda3" }] +dev = [{ name = "tree-sitter-nextflow", git = "https://github.com/nextflow-io/tree-sitter-nextflow.git?rev=1416dd3743fd4ae7e76057f04e1e89432dfe5669" }] [[package]] name = "numpy" @@ -3546,7 +3546,7 @@ wheels = [ [[package]] name = "tree-sitter-nextflow" version = "0.1.0" -source = { git = "https://github.com/nextflow-io/tree-sitter-nextflow.git?rev=dc5bda3#dc5bda3dae6b28b17f6b9797b32318bb1a8c65a0" } +source = { git = "https://github.com/nextflow-io/tree-sitter-nextflow.git?rev=1416dd3743fd4ae7e76057f04e1e89432dfe5669#1416dd3743fd4ae7e76057f04e1e89432dfe5669" } [[package]] name = "trogon"