diff --git a/lib/galaxy/tool_util/linters/required_files.py b/lib/galaxy/tool_util/linters/required_files.py
new file mode 100644
index 00000000000..cf4426a4e99
--- /dev/null
+++ b/lib/galaxy/tool_util/linters/required_files.py
@@ -0,0 +1,43 @@
+import os
+from typing import TYPE_CHECKING
+
+from galaxy.tool_util.lint import Linter
+from galaxy.tool_util.parser.interface import RequiredFiles
+
+if TYPE_CHECKING:
+ from galaxy.tool_util.lint import LintContext
+ from galaxy.tool_util.parser import ToolSource
+
+
+class RequiredFilesExist(Linter):
+ """Check that required_files include patterns match existing files."""
+
+ @classmethod
+ def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"):
+ if not tool_source.source_path:
+ return
+ tool_dir = os.path.dirname(tool_source.source_path)
+ required_files = tool_source.parse_required_files()
+ if required_files is None:
+ return
+ for include in required_files.includes:
+ per_include = RequiredFiles.from_dict(
+ {
+ "includes": [include],
+ "excludes": [],
+ "extend_default_excludes": False,
+ }
+ )
+ if not per_include.find_required_files(tool_dir):
+ path = include["path"]
+ path_type = include.get("path_type", "literal")
+ if path_type == "literal":
+ lint_ctx.error(
+ f"Required file [{path}] does not exist",
+ linter=cls.name(),
+ )
+ else:
+ lint_ctx.error(
+ f"Required files pattern [{path}] (type {path_type}) does not match any files",
+ linter=cls.name(),
+ )
diff --git a/test/unit/tool_util/test_tool_linters.py b/test/unit/tool_util/test_tool_linters.py
index 20b82c6567e..d14234b70f3 100644
--- a/test/unit/tool_util/test_tool_linters.py
+++ b/test/unit/tool_util/test_tool_linters.py
@@ -21,6 +21,7 @@ from galaxy.tool_util.linters import (
help,
inputs,
output,
+ required_files,
stdio,
tests,
xml_order,
@@ -2424,7 +2425,7 @@ def test_skip_by_module(lint_ctx):
def test_list_linters():
linter_names = Linter.list_listers()
# make sure to add/remove a test for new/removed linters if this number changes
- assert len(linter_names) == 142
+ assert len(linter_names) == 143
assert "Linter" not in linter_names
# make sure that linters from all modules are available
for prefix in [
@@ -2500,3 +2501,84 @@ def test_linter_module_list():
elif inspect.isclass(value) and issubclass(value, Linter) and not inspect.isabstract(value):
linter_cnt += 1
assert linter_cnt >= old_linters[module_name]
+
+
+REQUIRED_FILES_LITERAL = """
+
+
+
+
+
+"""
+
+REQUIRED_FILES_LITERAL_MISSING = """
+
+
+
+
+
+"""
+
+REQUIRED_FILES_GLOB = """
+
+
+
+
+
+"""
+
+REQUIRED_FILES_GLOB_NO_MATCH = """
+
+
+
+
+
+"""
+
+
+def _write_file(directory, filename, content):
+ """Write content to a file in directory, return full path."""
+ path = os.path.join(directory, filename)
+ with open(path, "w") as f:
+ f.write(content)
+ return path
+
+
+def _load_and_run_lint(lint_ctx, tool_path, lint_module):
+ """Load a tool XML from disk and run a lint module on it."""
+ tool_xml, _ = load_with_references(tool_path)
+ tool_source = XmlToolSource(tool_xml, source_path=tool_path)
+ run_lint_module(lint_ctx, lint_module, tool_source)
+
+
+def test_required_files_literal_exist(lint_ctx):
+ with tempfile.TemporaryDirectory() as tool_dir:
+ tool_path = _write_file(tool_dir, "tool.xml", REQUIRED_FILES_LITERAL)
+ _write_file(tool_dir, "my_script.R", "# R script")
+ _load_and_run_lint(lint_ctx, tool_path, required_files)
+ assert not lint_ctx.error_messages
+
+
+def test_required_files_literal_missing(lint_ctx):
+ with tempfile.TemporaryDirectory() as tool_dir:
+ tool_path = _write_file(tool_dir, "tool.xml", REQUIRED_FILES_LITERAL_MISSING)
+ _load_and_run_lint(lint_ctx, tool_path, required_files)
+ assert "Required file [nonexistent.py] does not exist" in lint_ctx.error_messages
+ assert len(lint_ctx.error_messages) == 1
+
+
+def test_required_files_glob_match(lint_ctx):
+ with tempfile.TemporaryDirectory() as tool_dir:
+ tool_path = _write_file(tool_dir, "tool.xml", REQUIRED_FILES_GLOB)
+ _write_file(tool_dir, "my_script.R", "# R script")
+ _load_and_run_lint(lint_ctx, tool_path, required_files)
+ assert not lint_ctx.error_messages
+
+
+def test_required_files_glob_no_match(lint_ctx):
+ with tempfile.TemporaryDirectory() as tool_dir:
+ tool_path = _write_file(tool_dir, "tool.xml", REQUIRED_FILES_GLOB_NO_MATCH)
+ _write_file(tool_dir, "my_script.R", "# R script")
+ _load_and_run_lint(lint_ctx, tool_path, required_files)
+ assert "Required files pattern [*.py] (type glob) does not match any files" in lint_ctx.error_messages
+ assert len(lint_ctx.error_messages) == 1