Repository: ansible/ansible-lint

Issue:
Add new rule to validate pattern directory structure
New rule for validating pattern directory structure.

PR: https://github.com/ansible/ansible-lint/pull/4662



Discussion:

Patch:
diff --git a/.config/constraints.txt b/.config/constraints.txt
index 1c6ab2e74b..e62d515c36 100644
--- a/.config/constraints.txt
+++ b/.config/constraints.txt
@@ -1,6 +1,7 @@
 # This file was autogenerated by uv via the following command:
 #    tox run -e deps
 ansible-compat==25.5.0    # via ansible-lint (pyproject.toml)
+ansible-creator==25.6.0   # via ansible-lint (pyproject.toml)
 astroid==3.3.10           # via pylint
 asttokens==3.0.0          # via stack-data
 attrs==25.3.0             # via jsonschema, referencing
@@ -47,7 +48,7 @@ ipdb==0.13.13             # via ansible-lint (pyproject.toml)
 ipython==8.36.0           # via ipdb, ansible-lint (pyproject.toml)
 isort==6.0.1              # via pylint
 jedi==0.19.2              # via ipython
-jinja2==3.1.6             # via ansible-core, mkdocs, mkdocs-macros-plugin, mkdocs-material, mkdocstrings
+jinja2==3.1.6             # via ansible-core, ansible-creator, mkdocs, mkdocs-macros-plugin, mkdocs-material, mkdocstrings
 jmespath==1.0.1           # via ansible-lint (pyproject.toml)
 jsmin==3.0.1              # via mkdocs-minify-plugin
 jsonschema==4.24.0        # via ansible-compat, ansible-lint (pyproject.toml)
@@ -102,7 +103,7 @@ pytest-plus==0.8.1        # via ansible-lint (pyproject.toml)
 pytest-sugar==1.0.0       # via ansible-lint (pyproject.toml)
 pytest-xdist==3.7.0       # via ansible-lint (pyproject.toml)
 python-dateutil==2.9.0.post0  # via ghp-import, mkdocs-macros-plugin
-pyyaml==6.0.2             # via ansible-compat, ansible-core, mkdocs, mkdocs-get-deps, mkdocs-macros-plugin, pymdown-extensions, pyyaml-env-tag, yamllint, ansible-lint (pyproject.toml)
+pyyaml==6.0.2             # via ansible-compat, ansible-core, ansible-creator, mkdocs, mkdocs-get-deps, mkdocs-macros-plugin, pymdown-extensions, pyyaml-env-tag, yamllint, ansible-lint (pyproject.toml)
 pyyaml-env-tag==1.1       # via mkdocs
 referencing==0.36.2       # via jsonschema, jsonschema-specifications, types-jsonschema, ansible-lint (pyproject.toml)
 requests==2.32.4          # via linkchecker, mkdocs-htmlproofer-plugin, mkdocs-material
diff --git a/docs/rules/index.md b/docs/rules/index.md
index 43ac967e10..5d6894b9df 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -38,6 +38,7 @@
 - [package-latest][]
 - [parser-error][]
 - [partial-become][]
+- [pattern][]
 - [playbook-extension][]
 - [risky-file-permissions][]
 - [risky-octal][]
diff --git a/docs/rules/pattern.md b/docs/rules/pattern.md
new file mode 120000
index 0000000000..f4f296e99c
--- /dev/null
+++ b/docs/rules/pattern.md
@@ -0,0 +1,1 @@
+../../src/ansiblelint/rules/pattern.md
\ No newline at end of file
diff --git a/examples/collections/extensions/patterns/invalid_pattern/pattern.json b/examples/collections/extensions/patterns/invalid_pattern/pattern.json
new file mode 100644
index 0000000000..bc7016101f
--- /dev/null
+++ b/examples/collections/extensions/patterns/invalid_pattern/pattern.json
@@ -0,0 +1,44 @@
+{
+  "schema_version": "1.0",
+  "name": "invalid_pattern",
+  "title": "Weather Forecasting",
+  "description": "This pattern is designed to help get the weather forecast for a given airport code. It creates a project, EE, and job templates in automation controller to get the weather forecast.",
+  "short_description": "This pattern is designed to help get the weather forecast for a given airport code.",
+  "tags": ["weather", "forecasting"],
+  "aap_resources": {
+    "controller_project": {
+      "name": "Weather Forecasting",
+      "description": "Project for the Weather Forecasting pattern"
+    },
+    "controller_execution_environment": {
+      "name": "Weather Forecasting",
+      "description": "EE for the Weather Forecasting pattern",
+      "image_name": "weather-demo-ee",
+      "pull": "missing"
+    },
+    "controller_labels": ["weather", "forecasting"],
+    "controller_job_templates": [
+      {
+        "name": "Get Weather Forecast",
+        "description": "This job template gets the weather at the location of a provided airport code.",
+        "execution_environment": "Weather Forecasting",
+        "playbook": "site.yml",
+        "primary": true,
+        "labels": ["weather", "forecasting"],
+        "survey": {
+          "name": "Weather Forecasting",
+          "description": "Survey to configure the weather forecasting pattern",
+          "spec": [
+            {
+              "type": "text",
+              "question_name": "Location",
+              "question_description": "Enter the airport code for which you want to get the weather forecast",
+              "variable": "location",
+              "required": true
+            }
+          ]
+        }
+      }
+    ]
+  }
+}
diff --git a/examples/collections/extensions/patterns/valid_pattern/README.md b/examples/collections/extensions/patterns/valid_pattern/README.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/examples/collections/extensions/patterns/valid_pattern/meta/pattern.json b/examples/collections/extensions/patterns/valid_pattern/meta/pattern.json
new file mode 100644
index 0000000000..f1966ff48d
--- /dev/null
+++ b/examples/collections/extensions/patterns/valid_pattern/meta/pattern.json
@@ -0,0 +1,44 @@
+{
+  "schema_version": "1.0",
+  "name": "valid_pattern",
+  "title": "Weather Forecasting",
+  "description": "This pattern is designed to help get the weather forecast for a given airport code. It creates a project, EE, and job templates in automation controller to get the weather forecast.",
+  "short_description": "This pattern is designed to help get the weather forecast for a given airport code.",
+  "tags": ["weather", "forecasting"],
+  "aap_resources": {
+    "controller_project": {
+      "name": "Weather Forecasting",
+      "description": "Project for the Weather Forecasting pattern"
+    },
+    "controller_execution_environment": {
+      "name": "Weather Forecasting",
+      "description": "EE for the Weather Forecasting pattern",
+      "image_name": "weather-demo-ee",
+      "pull": "missing"
+    },
+    "controller_labels": ["weather", "forecasting"],
+    "controller_job_templates": [
+      {
+        "name": "Get Weather Forecast",
+        "description": "This job template gets the weather at the location of a provided airport code.",
+        "execution_environment": "Weather Forecasting",
+        "playbook": "site.yml",
+        "primary": true,
+        "labels": ["weather", "forecasting"],
+        "survey": {
+          "name": "Weather Forecasting",
+          "description": "Survey to configure the weather forecasting pattern",
+          "spec": [
+            {
+              "type": "text",
+              "question_name": "Location",
+              "question_description": "Enter the airport code for which you want to get the weather forecast",
+              "variable": "location",
+              "required": true
+            }
+          ]
+        }
+      }
+    ]
+  }
+}
diff --git a/examples/collections/extensions/patterns/valid_pattern/playbooks/group_vars/all.yml b/examples/collections/extensions/patterns/valid_pattern/playbooks/group_vars/all.yml
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/examples/collections/extensions/patterns/valid_pattern/playbooks/site.yml b/examples/collections/extensions/patterns/valid_pattern/playbooks/site.yml
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/mkdocs.yml b/mkdocs.yml
index e8c4ce993d..94e120e0d9 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -112,6 +112,7 @@ nav:
       - rules/package-latest.md
       - rules/parser-error.md
       - rules/partial-become.md
+      - rules/pattern.md
       - rules/playbook-extension.md
       - rules/risky-file-permissions.md
       - rules/risky-octal.md
diff --git a/src/ansiblelint/config.py b/src/ansiblelint/config.py
index a262b0d316..30d72b6b73 100644
--- a/src/ansiblelint/config.py
+++ b/src/ansiblelint/config.py
@@ -53,11 +53,12 @@
     {"vars": "**/{host_vars,group_vars,vars,defaults}/**/*.{yaml,yml}"},
     {"tasks": "**/tasks/**/*.{yaml,yml}"},
     {"rulebook": "**/rulebooks/*.{yml,yaml"},
-    {"pattern": "**/patterns/*/meta/pattern.json"},
+    {"pattern": "**/extensions/patterns/*/meta/pattern.json"},
     {"play-argspec": "**/patterns/*/playbooks/meta/*.{yaml,yml}"},
     {"play-argspec": "**/*.meta.{yaml,yml}"},
     {"playbook": "**/playbooks/*.{yml,yaml}"},
     {"playbook": "**/*playbook*.{yml,yaml}"},
+    {"playbook": "**/extensions/patterns/*/playbooks/*.{yml,yaml}"},
     {"role": "**/roles/*/"},
     {"handlers": "**/handlers/*.{yaml,yml}"},
     {"test-meta": "**/tests/integration/targets/*/meta/main.{yaml,yml}"},
diff --git a/src/ansiblelint/constants.py b/src/ansiblelint/constants.py
index 811d0317a7..82967c35fd 100644
--- a/src/ansiblelint/constants.py
+++ b/src/ansiblelint/constants.py
@@ -77,6 +77,7 @@ def main():
 
 FileType = Literal[
     "playbook",
+    "pattern",  # pattern.json file
     "rulebook",
     "meta",  # role meta
     "meta-runtime",
diff --git a/src/ansiblelint/data/profiles.yml b/src/ansiblelint/data/profiles.yml
index 0749ad5030..37468742a6 100644
--- a/src/ansiblelint/data/profiles.yml
+++ b/src/ansiblelint/data/profiles.yml
@@ -104,6 +104,8 @@ production:
     as validated or certified content.
   extends: shared
   rules:
+    pattern:
+      url: https://github.com/ansible/ansible-lint/issues/123
     avoid-dot-notation:
       url: https://github.com/ansible/ansible-lint/issues/2174
     sanity:
diff --git a/src/ansiblelint/file_utils.py b/src/ansiblelint/file_utils.py
index 2d6afc8058..841af1cc5d 100644
--- a/src/ansiblelint/file_utils.py
+++ b/src/ansiblelint/file_utils.py
@@ -169,6 +169,10 @@ def kind_from_path(path: Path, *, base: bool = False) -> FileType:
     if str(path) == "/dev/stdin":
         return "playbook"
 
+    if "pattern.json" in str(path):
+        # This is a pattern file
+        return "pattern"
+
     # Unknown file types report a empty string (evaluated as False)
     return ""
 
diff --git a/src/ansiblelint/rules/pattern.md b/src/ansiblelint/rules/pattern.md
new file mode 100644
index 0000000000..57a6fde588
--- /dev/null
+++ b/src/ansiblelint/rules/pattern.md
@@ -0,0 +1,30 @@
+# pattern
+
+This rule aims to validate Ansible pattern directory structure.
+
+## pattern[missing-meta]
+
+`pattern[missing-meta]` is triggered when a pattern.json definition file exists but the parent meta directory (one level up from the pattern.json) is missing.
+
+## pattern[missing-playbook]
+
+`pattern[missing-playbook]` is triggered when either the required playbooks directory or playbook file is missing.
+
+## pattern[missing-readme]
+
+`pattern[missing-readme]` is triggered when the README.md file is missing from a pattern directory.
+
+## pattern[name-mismatch]
+
+`pattern[name-mismatch]` is triggered when the pattern directory name does not match the `name` field value in the pattern.json file.
+
+## Usage
+
+To validate a pattern's directory structure: navigate to the collection directory, run ansible-lint on the pattern definition meta file, and review the validation results:
+
+```bash
+cd existing_collection
+ansible-lint extensions/patterns/sample_pattern/meta/pattern.json
+```
+
+This command will check the pattern directory structure and report any violations found.
diff --git a/src/ansiblelint/rules/pattern.py b/src/ansiblelint/rules/pattern.py
new file mode 100644
index 0000000000..b2e87ea182
--- /dev/null
+++ b/src/ansiblelint/rules/pattern.py
@@ -0,0 +1,166 @@
+"""Implementation of PatternRule."""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from ansiblelint.rules import AnsibleLintRule
+
+if TYPE_CHECKING:
+    from ansiblelint.errors import MatchError
+    from ansiblelint.file_utils import Lintable
+
+
+class PatternRule(AnsibleLintRule):
+    """Rule for checking pattern directory."""
+
+    id = "pattern"
+    description = "Confirm that pattern has valid directory structure."
+    severity = "MEDIUM"
+    tags = ["metadata"]
+    version_changed = "25.7.0"
+    _ids = {
+        "pattern[missing-meta]": "Missing meta sub-directory in pattern directory.",
+        "pattern[missing-playbook]": "Missing playbooks sub-directory in pattern directory.",
+        "pattern[missing-readme]": "Missing README.md file in pattern directory.",
+        "pattern[name-mismatch]": "Pattern directory name does not match the name value in pattern.json file.",
+    }
+
+    def matchyaml(self, file: Lintable) -> list[MatchError]:
+        """Return matches found for a specific file."""
+        if file.kind != "pattern":
+            return []
+        results = []
+
+        pattern_dir = file.path.parent.parent.resolve()
+        meta_dir = pattern_dir / "meta"
+
+        # Check the presence of required meta sub-dir inside a pattern directory
+        if not meta_dir.is_dir():
+            results.append(
+                self.create_matcherror(
+                    message=(
+                        f"Pattern directory '{pattern_dir}' contains pattern.json but is missing the required 'meta' directory."
+                    ),
+                    tag=f"{self.id}[missing-meta]",
+                    filename=file,
+                ),
+            )
+
+        # Check the presence of required README.md file in a pattern directory
+        readme_file = pattern_dir / "README.md"
+        if not readme_file.exists():
+            results.append(
+                self.create_matcherror(
+                    message=(
+                        f"Pattern directory '{pattern_dir}' is missing required README.md file"
+                    ),
+                    tag=f"{self.id}[missing-readme]",
+                    filename=file,
+                ),
+            )
+
+        # Validate that pattern name matches with the name key in pattern.json file
+        values = values_from_pattern_json(file.path)
+        pattern_name_from_json_file = values[1]
+        pattern_name_from_dir = pattern_dir.name
+        if pattern_name_from_json_file != pattern_name_from_dir:
+            results.append(
+                self.create_matcherror(
+                    message=(
+                        f"Pattern directory name '{pattern_name_from_dir}' does not match the name key in pattern.json file: '{pattern_name_from_json_file}'."
+                    ),
+                    tag=f"{self.id}[name-mismatch]",
+                    filename=file,
+                ),
+            )
+
+        # Check the presence of playbooks directory and file matching entries in the pattern.json file
+        playbooks_dir = pattern_dir / "playbooks"
+        missing_playbook_items = []
+
+        if not playbooks_dir.is_dir():
+            missing_playbook_items.append("playbooks directory")
+        else:
+            values = values_from_pattern_json(file.path)
+            playbook = values[0]
+            playbook_file = playbooks_dir / playbook
+            if not playbook_file.exists():
+                missing_playbook_items.append("playbook file")
+
+        if missing_playbook_items:
+            results.append(
+                self.create_matcherror(
+                    message=(
+                        f"Pattern directory '{pattern_dir}' is missing required: {', '.join(missing_playbook_items)}"
+                    ),
+                    tag=f"{self.id}[missing-playbook]",
+                    filename=file,
+                ),
+            )
+
+        return results
+
+
+def values_from_pattern_json(file: Path) -> list[str]:
+    """Extract playbook name and pattern name values from pattern.json file."""
+    playbook_name: str = ""
+    pattern_name: str = ""
+    try:
+        with Path(file).open(encoding="utf-8") as f:
+            data = json.load(f)
+        try:
+            playbook_name = data["aap_resources"]["controller_job_templates"][0][
+                "playbook"
+            ]
+            pattern_name = data["name"]
+        except KeyError as exc:
+            msg = "Could not extract playbook name"
+            raise KeyError(msg) from exc
+    except FileNotFoundError as exc:
+        msg = "Pattern file not found."
+        raise FileNotFoundError(msg) from exc
+    return [playbook_name, pattern_name]
+
+
+if "pytest" in sys.modules:
+    import pytest
+
+    from ansiblelint.rules import RulesCollection  # pylint: disable=ungrouped-imports
+    from ansiblelint.runner import Runner
+
+    @pytest.mark.parametrize(
+        ("file", "expected"),
+        (
+            pytest.param(
+                "examples/collections/extensions/patterns/valid_pattern/meta/pattern.json",
+                [],
+                id="valid-pattern",
+            ),
+            pytest.param(
+                "examples/collections/extensions/patterns/invalid_pattern/pattern.json",
+                [
+                    "pattern[missing-meta]",
+                    "pattern[missing-readme]",
+                    "pattern[missing-playbook]",
+                    "pattern[name-mismatch]",
+                ],
+                id="invalid-pattern",
+            ),
+        ),
+    )
+    def test_pattern(
+        default_rules_collection: RulesCollection,
+        file: str,
+        expected: list[str],
+    ) -> None:
+        """Validate that rule works as intended."""
+        results = Runner(file, rules=default_rules_collection).run()
+
+        assert len(results) == len(expected)
+        for index, result in enumerate(results):
+            assert result.rule.id == PatternRule.id, result
+            assert result.tag == expected[index]
diff --git a/src/ansiblelint/runner.py b/src/ansiblelint/runner.py
index b664056495..ddaad27c8f 100644
--- a/src/ansiblelint/runner.py
+++ b/src/ansiblelint/runner.py
@@ -269,7 +269,7 @@ def worker(lintable: Lintable) -> list[MatchError]:
 
             for lintable in self.lintables:
                 if (
-                    lintable.kind not in ("playbook", "role")
+                    lintable.kind not in ("playbook", "role", "pattern")
                     or lintable.stop_processing
                 ):
                     continue
@@ -307,7 +307,6 @@ def worker(lintable: Lintable) -> list[MatchError]:
                         break
         # remove duplicates from files list
         files = list(dict.fromkeys(files))
-
         for file in self.lintables:
             if (
                 file in self.checked_files
diff --git a/src/ansiblelint/schemas/__store__.json b/src/ansiblelint/schemas/__store__.json
index 3e911aee34..a601b9ad73 100644
--- a/src/ansiblelint/schemas/__store__.json
+++ b/src/ansiblelint/schemas/__store__.json
@@ -44,7 +44,7 @@
     "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/play-argspec.json"
   },
   "playbook": {
-    "etag": "4f8cbba62fcf8a1fa6e8ef5e42696aec5b0876487478df83a7ffdf8bdbb4abcf",
+    "etag": "db849c713091b29d3a569e72a82f8a28f49b3b14ad90c1a96936099b04eb393e",
     "url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/playbook.json"
   },
   "requirements": {


Test patch:
diff --git a/.config/requirements-test.in b/.config/requirements-test.in
index f3c150f0ec..a958755a15 100644
--- a/.config/requirements-test.in
+++ b/.config/requirements-test.in
@@ -1,3 +1,4 @@
+ansible-creator >=24.6.0
 black # IDE support
 coverage-enable-subprocess # see https://github.com/nedbat/coveragepy/issues/1341#issuecomment-1228942657
 coverage[toml] >= 6.4.4
diff --git a/test/test_pattern.py b/test/test_pattern.py
new file mode 100644
index 0000000000..79fb34f948
--- /dev/null
+++ b/test/test_pattern.py
@@ -0,0 +1,53 @@
+"""Tests for the pattern feature."""
+
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+LINT_BIN = Path(sys.executable).parent / "ansible-lint"
+
+
+def test_creator_scaffolded_pattern() -> None:
+    """Validate creator scaffolded pattern.
+
+    Scaffold a pattern using ansible-creator. Run ansible-lint
+    on the scaffolded pattern and validate lint results.
+
+    Args:
+        monkeypatch: Monkeypatch fixture.
+    """
+    # Create a tmp dir and copy an existing collection into it
+    collection_src = Path("examples/collections/broken_no_runtime")
+    with tempfile.TemporaryDirectory() as tmpdir:
+        collection_dest = Path(tmpdir) / collection_src.name
+        shutil.copytree(collection_src, collection_dest)
+
+        # Scaffold a pattern using ansible-creator
+        result = subprocess.run(
+            [
+                "ansible-creator",
+                "add",
+                "resource",
+                "pattern",
+                "sample_pattern",
+                collection_dest,
+            ],
+            capture_output=True,
+            text=True,
+            check=False,
+        )
+        assert result.returncode == 0, f"Pattern scaffolding failed: {result.stderr}"
+
+        # Run ansible-lint on the scaffolded pattern
+        pattern_path = collection_dest
+
+        lint_result = subprocess.run(
+            [str(LINT_BIN), pattern_path],
+            capture_output=True,
+            text=True,
+            env={"NO_COLOR": "1"},
+            check=False,
+        )
+        assert lint_result.returncode == 0, f"ansible-lint failed: {lint_result.stderr}"
diff --git a/test/test_rules_collection.py b/test/test_rules_collection.py
index 7e3249052e..daf482caf1 100644
--- a/test/test_rules_collection.py
+++ b/test/test_rules_collection.py
@@ -173,5 +173,5 @@ def test_rules_id_format(config_options: Options) -> None:
             f"Rule {rule.id} must have at least one of:  .help, .description, .__doc__"
         )
     assert "yaml" in keys, "yaml rule is missing"
-    assert len(rules) == 51  # update this number when adding new rules!
+    assert len(rules) == 52  # update this number when adding new rules!
     assert len(keys) == len(rules), "Duplicate rule ids?"