This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git


The following commit(s) were added to refs/heads/main by this push:
     new fadab63e feat(validator): enforce license headers on tool Python files 
(check #8) (#474)
fadab63e is described below

commit fadab63eb21ed1476b04ba9cb32cfa61792efb46
Author: Justin Mclean <[email protected]>
AuthorDate: Thu Jun 11 18:22:19 2026 +1000

    feat(validator): enforce license headers on tool Python files (check #8) 
(#474)
    
    * feat(validator): add license-header enforcement (check #8)
    
    Add a HARD check to skill-and-tool-validator requiring every non-trivial
    Python source file under tools/ to carry either the SPDX one-liner or the
    full ASF license preamble. Seed the header into the 6
    security-tracker-stats-dashboard scripts that lacked it so the real-repo
    integration test stays green.
    
    Skill .md files are exempt: they already declare their license via the
    required `license:` frontmatter key (validated by the frontmatter check),
    so a separate SPDX comment would be redundant.
    
    * fix formatting
---
 .../fetch_bodies.py                                |  18 +++
 .../fetch_events.py                                |  18 +++
 .../fetch_issues.py                                |  18 +++
 .../security-tracker-stats-dashboard/fetch_prs.py  |  18 +++
 .../fetch_roster.py                                |  18 +++
 tools/security-tracker-stats-dashboard/render.py   |  18 +++
 .../src/skill_and_tool_validator/__init__.py       |  94 ++++++++++++-
 .../tests/test_validator.py                        | 147 ++++++++++++++++++++-
 8 files changed, 344 insertions(+), 5 deletions(-)

diff --git a/tools/security-tracker-stats-dashboard/fetch_bodies.py 
b/tools/security-tracker-stats-dashboard/fetch_bodies.py
index dccfd6b9..6e346afd 100644
--- a/tools/security-tracker-stats-dashboard/fetch_bodies.py
+++ b/tools/security-tracker-stats-dashboard/fetch_bodies.py
@@ -1,4 +1,22 @@
 #!/usr/bin/env python3
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
 """Fetch issue body + closedByPullRequestsReferences for every tracker
 issue and cache to /tmp/claude/dashboard/issue_extra.json."""
 
diff --git a/tools/security-tracker-stats-dashboard/fetch_events.py 
b/tools/security-tracker-stats-dashboard/fetch_events.py
index 6488572a..3d4bdc9b 100644
--- a/tools/security-tracker-stats-dashboard/fetch_events.py
+++ b/tools/security-tracker-stats-dashboard/fetch_events.py
@@ -1,4 +1,22 @@
 #!/usr/bin/env python3
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
 """Fetch per-issue label-history events. Resumes from cache."""
 
 import json
diff --git a/tools/security-tracker-stats-dashboard/fetch_issues.py 
b/tools/security-tracker-stats-dashboard/fetch_issues.py
index e1587ce7..f71de349 100644
--- a/tools/security-tracker-stats-dashboard/fetch_issues.py
+++ b/tools/security-tracker-stats-dashboard/fetch_issues.py
@@ -1,4 +1,22 @@
 #!/usr/bin/env python3
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
 """Dump all tracker issues (state=all, no PRs) to <cache>/issues.json."""
 
 import json
diff --git a/tools/security-tracker-stats-dashboard/fetch_prs.py 
b/tools/security-tracker-stats-dashboard/fetch_prs.py
index 8ed7ca9a..4b545397 100644
--- a/tools/security-tracker-stats-dashboard/fetch_prs.py
+++ b/tools/security-tracker-stats-dashboard/fetch_prs.py
@@ -1,4 +1,22 @@
 #!/usr/bin/env python3
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
 """Fetch createdAt + mergedAt + state for every upstream-repo PR referenced
 by any tracker (via closedByPullRequestsReferences or body parse). Cache to
 `<TRACKER_STATS_CACHE>/prs.json`.
diff --git a/tools/security-tracker-stats-dashboard/fetch_roster.py 
b/tools/security-tracker-stats-dashboard/fetch_roster.py
index 2ef0f669..9d3aef25 100644
--- a/tools/security-tracker-stats-dashboard/fetch_roster.py
+++ b/tools/security-tracker-stats-dashboard/fetch_roster.py
@@ -1,4 +1,22 @@
 #!/usr/bin/env python3
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
 """Dump the security-team roster (tracker repo's collaborators) to 
<cache>/roster.txt."""
 
 import os
diff --git a/tools/security-tracker-stats-dashboard/render.py 
b/tools/security-tracker-stats-dashboard/render.py
index 42c3cff4..26c21680 100644
--- a/tools/security-tracker-stats-dashboard/render.py
+++ b/tools/security-tracker-stats-dashboard/render.py
@@ -1,4 +1,22 @@
 #!/usr/bin/env python3
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
 """
 Regenerate a tracker-stats dashboard. Reads cached issues+events+PR data
 from `$TRACKER_STATS_CACHE` (default `/tmp/tracker-stats-cache`) and writes
diff --git 
a/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py 
b/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py
index bed6b5ff..1d12cbf0 100644
--- a/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py
+++ b/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py
@@ -17,7 +17,7 @@
 
 """Validate framework skill definitions.
 
-This module validates seven aspects of every skill under
+This module validates eight aspects of every skill under
 skills/:
 
 1. YAML frontmatter — every SKILL.md must have a valid frontmatter
@@ -44,6 +44,11 @@ skills/:
 7. Trigger-phrase preservation (SOFT) — quoted phrases inside
    when_to_use must not be dropped vs the base ref (default
    origin/main), preventing routing-recall regressions.
+8. License-header presence (HARD) — every non-trivial Python source
+   file under ``tools/`` must carry the SPDX one-liner or the full
+   Apache Software Foundation license preamble.  Skill ``.md`` files
+   declare their license via the required ``license:`` frontmatter key
+   (checked by aspect 1), so they need no separate header.
 
 SOFT categories surface as advisory warnings (stderr) without
 failing the run unless ``--strict`` is passed.
@@ -242,6 +247,9 @@ LOWERCASE_F_FIELD_CATEGORY = "lowercase_f_field"
 # Every framework skill is installed under a `magpie-` namespace prefix, so its
 # SKILL.md `name:` must be `magpie-<directory-name>` (see 
skills/setup/SKILL.md).
 NAME_CONVENTION_CATEGORY = "name_convention"
+# License-header check: every skill .md and non-trivial tool Python file must
+# carry the Apache-2.0 SPDX identifier or the full ASF preamble.
+LICENSE_HEADER_CATEGORY = "license_header"
 
 # The `magpie-` namespace prefix every installed framework skill carries.
 SKILL_NAME_PREFIX = "magpie-"
@@ -263,6 +271,7 @@ HARD_CATEGORIES: frozenset[str] = frozenset(
         CAPABILITY_SYNC_CATEGORY,
         INJECTION_GUARD_CATEGORY,
         NAME_CONVENTION_CATEGORY,
+        LICENSE_HEADER_CATEGORY,
     }
 )
 ALL_CATEGORIES = HARD_CATEGORIES | SOFT_CATEGORIES
@@ -1589,6 +1598,78 @@ def validate_lowercase_f_field(path: Path, text: str) -> 
Iterable[Violation]:
         )
 
 
+# ---------------------------------------------------------------------------
+# License-header check
+# ---------------------------------------------------------------------------
+
+# Acceptable license markers for Python source files: either the SPDX
+# one-liner or the full Apache Software Foundation license preamble URL.
+_LICENSE_PY_MARKERS: tuple[str, ...] = (
+    "SPDX-License-Identifier: Apache-2.0",
+    "apache.org/licenses/LICENSE-2.0",
+)
+
+# Files smaller than this threshold (bytes / characters) are treated as
+# empty placeholder stubs and exempted from the license-header check.
+_MIN_LICENSE_FILE_SIZE = 50
+
+# Path components that mark generated or vendored subtrees that must not
+# be checked (venv, installed packages, etc.).
+_LICENSE_SKIP_PATH_PARTS: frozenset[str] = frozenset(
+    {".venv", "site-packages", "node_modules", "__pycache__"}
+)
+
+
+def collect_tool_python_files(root: Path | None = None) -> list[Path]:
+    """Return non-trivial Python source files owned by this framework under 
tools/.
+
+    Excludes generated / vendored subtrees (``.venv``, ``site-packages``,
+    ``node_modules``, ``__pycache__``) and empty placeholder files whose
+    content is shorter than ``_MIN_LICENSE_FILE_SIZE`` characters.
+    """
+    base = (root or find_repo_root()) / TOOLS_DIR
+    if not base.exists():
+        return []
+    result: list[Path] = []
+    for path in base.rglob("*.py"):
+        if any(part in _LICENSE_SKIP_PATH_PARTS for part in path.parts):
+            continue
+        try:
+            if path.stat().st_size < _MIN_LICENSE_FILE_SIZE:
+                continue
+        except OSError:
+            continue
+        result.append(path)
+    return sorted(result)
+
+
+def validate_license_header(path: Path, text: str) -> Iterable[Violation]:
+    """Check that a tool ``.py`` file carries a license header.
+
+    **Python files** (``tools/**/*.py``, non-trivial): must contain either the
+    SPDX one-liner (``# SPDX-License-Identifier: Apache-2.0``) or the full
+    Apache Software Foundation license preamble URL
+    (``apache.org/licenses/LICENSE-2.0``).
+
+    Skill ``.md`` files are exempt — they declare their license via the
+    required ``license:`` frontmatter key (validated by the frontmatter
+    check), so a separate SPDX comment would be redundant.
+
+    A missing header is a HARD failure — caught at validation time rather
+    than in code review.
+    """
+    if path.suffix.lower() == ".py" and not any(marker in text for marker in 
_LICENSE_PY_MARKERS):
+        yield Violation(
+            path,
+            1,
+            "missing license header — Python source files must carry either "
+            "'# SPDX-License-Identifier: Apache-2.0' or the Apache Software "
+            "Foundation license preamble (URL: 
apache.org/licenses/LICENSE-2.0); "
+            "see AGENTS.md § Commit and PR conventions",
+            category=LICENSE_HEADER_CATEGORY,
+        )
+
+
 def collect_skill_dirs(root: Path | None = None) -> set[Path]:
     """Return the set of skill directories (immediate children of skills)."""
     base = (root or find_repo_root()) / SKILLS_DIR
@@ -1671,13 +1752,22 @@ def run_validation(root: Path | None = None) -> 
list[Violation]:
             violations.extend(validate_privacy_patterns(path, text))
             violations.extend(validate_trigger_preservation(path, text, 
repo_root=repo_root))
 
-        # All skill files get link + placeholder + security-pattern validation
+        # All skill files get link + placeholder + security-pattern checks
         violations.extend(validate_links(path, text, skill_dirs, doc_files))
         violations.extend(validate_placeholders(path, text))
         violations.extend(validate_security_patterns(path, text))
         violations.extend(validate_gh_list_limit(path, text))
         violations.extend(validate_lowercase_f_field(path, text))
 
+    # License-header check for tool Python source files.
+    for py_path in collect_tool_python_files(repo_root):
+        try:
+            py_text = py_path.read_text(encoding="utf-8")
+        except OSError as exc:
+            violations.append(Violation(py_path, None, f"cannot read file: 
{exc}"))
+            continue
+        violations.extend(validate_license_header(py_path, py_text))
+
     # Tool-level checks: every tools/<name>/ has a README that declares its 
capability.
     violations.extend(validate_tools(repo_root))
 
diff --git a/tools/skill-and-tool-validator/tests/test_validator.py 
b/tools/skill-and-tool-validator/tests/test_validator.py
index c68c6eb3..2e5430b3 100644
--- a/tools/skill-and-tool-validator/tests/test_validator.py
+++ b/tools/skill-and-tool-validator/tests/test_validator.py
@@ -37,6 +37,7 @@ from skill_and_tool_validator import (
     INJECTION_GUARD_CATEGORY,
     INJECTION_GUARD_TODO_CATEGORY,
     INJECTION_GUARD_TODO_SENTINEL,
+    LICENSE_HEADER_CATEGORY,
     LOWERCASE_F_FIELD_CATEGORY,
     MAX_METADATA_CHARS,
     PRINCIPLE_CATEGORY,
@@ -48,6 +49,7 @@ from skill_and_tool_validator import (
     collect_doc_files,
     collect_files_to_check,
     collect_skill_dirs,
+    collect_tool_python_files,
     extract_headings,
     find_repo_root,
     is_path_allowlisted,
@@ -62,6 +64,7 @@ from skill_and_tool_validator import (
     validate_frontmatter,
     validate_gh_list_limit,
     validate_injection_guard,
+    validate_license_header,
     validate_links,
     validate_lowercase_f_field,
     validate_name_convention,
@@ -631,7 +634,9 @@ class TestSubDocFiles:
         skill_dir = root / "skills" / skill_name
         skill_dir.mkdir(parents=True)
         (skill_dir / "SKILL.md").write_text(
-            f"---\nname: magpie-{skill_name}\ndescription: bar\ncapability: 
capability:setup\nlicense: Apache-2.0\n---\n# body\n",
+            f"---\nname: magpie-{skill_name}\ndescription: bar\ncapability: 
capability:setup\nlicense: Apache-2.0\n---\n"
+            "<!-- SPDX-License-Identifier: Apache-2.0\n     
https://www.apache.org/licenses/LICENSE-2.0 -->\n"
+            "# body\n",
             encoding="utf-8",
         )
         docs = root / "docs"
@@ -699,6 +704,7 @@ class TestSubDocFiles:
         skill_dir = self._make_skill_dir(tmp_path, skill_name="setup")
         for name in ("adopt.md", "agents.md", "overrides.md", "upgrade.md", 
"verify.md"):
             (skill_dir / name).write_text(
+                "<!-- SPDX-License-Identifier: Apache-2.0\n     
https://www.apache.org/licenses/LICENSE-2.0 -->\n"
                 f"# {name.removesuffix('.md')}\n\nContent for {name}.\n",
                 encoding="utf-8",
             )
@@ -1469,6 +1475,135 @@ class TestLowercaseFField:
         assert LOWERCASE_F_FIELD_CATEGORY in SOFT_CATEGORIES
 
 
+# ---------------------------------------------------------------------------
+# License-header check
+# ---------------------------------------------------------------------------
+
+# Full Apache License preamble as used in Python tool files.
+_ASF_HEADER = (
+    "# Licensed to the Apache Software Foundation (ASF) under one\n"
+    "# or more contributor license agreements.  See the NOTICE file\n"
+    "# distributed with this work for additional information\n"
+    "# regarding copyright ownership.  The ASF licenses this file\n"
+    "# to you under the Apache License, Version 2.0 (the\n"
+    '# "License"); you may not use this file except in compliance\n'
+    "# with the License.  You may obtain a copy of the License at\n"
+    "#\n"
+    "#   http://www.apache.org/licenses/LICENSE-2.0\n";
+    "#\n"
+    "# Unless required by applicable law or agreed to in writing,\n"
+    "# software distributed under the License is distributed on an\n"
+    '# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n'
+    "# KIND, either express or implied.  See the License for the\n"
+    "# specific language governing permissions and limitations\n"
+    "# under the License.\n"
+)
+_SPDX_PY_HEADER = "# SPDX-License-Identifier: Apache-2.0\n"
+
+
+class TestValidateLicenseHeader:
+    # ------------------------------------------------------------------ #
+    # Python (.py) checks                                                 #
+    # ------------------------------------------------------------------ #
+
+    def test_license_header_violation_is_hard_category(self) -> None:
+        assert LICENSE_HEADER_CATEGORY in HARD_CATEGORIES
+        assert LICENSE_HEADER_CATEGORY not in SOFT_CATEGORIES
+
+    def test_md_file_is_exempt(self, tmp_path: Path) -> None:
+        """Skill .md files declare license via frontmatter, so they need no 
header."""
+        path = tmp_path / "SKILL.md"
+        text = "---\nname: foo\ndescription: bar\ncapability: 
capability:setup\nlicense: Apache-2.0\n---\n# Body\n"
+        violations = list(validate_license_header(path, text))
+        assert violations == []
+
+    def test_py_with_asf_header_passes(self, tmp_path: Path) -> None:
+        """A Python file with the full ASF license preamble → no violation."""
+        path = tmp_path / "tool.py"
+        text = _ASF_HEADER + '\n"""Module docstring."""\n'
+        violations = list(validate_license_header(path, text))
+        assert violations == []
+
+    def test_py_with_spdx_one_liner_passes(self, tmp_path: Path) -> None:
+        """A Python file with only the SPDX one-liner → no violation."""
+        path = tmp_path / "tool.py"
+        text = _SPDX_PY_HEADER + '\n"""Module docstring."""\n'
+        violations = list(validate_license_header(path, text))
+        assert violations == []
+
+    def test_py_without_any_header_fails(self, tmp_path: Path) -> None:
+        """A Python file with no license marker → HARD violation."""
+        path = tmp_path / "tool.py"
+        text = '"""Module with no license header."""\n\ndef foo() -> None:\n   
 pass\n'
+        violations = list(validate_license_header(path, text))
+        assert len(violations) == 1
+        assert violations[0].category == LICENSE_HEADER_CATEGORY
+        assert "license header" in violations[0].message
+
+    def test_py_shebang_plus_asf_passes(self, tmp_path: Path) -> None:
+        """A script with shebang + ASF header → no violation."""
+        path = tmp_path / "script.py"
+        text = "#!/usr/bin/env python3\n" + _ASF_HEADER + '"""Script."""\n'
+        violations = list(validate_license_header(path, text))
+        assert violations == []
+
+    def test_non_py_non_md_file_ignored(self, tmp_path: Path) -> None:
+        """Files with other extensions are not checked."""
+        path = tmp_path / "config.toml"
+        text = "[tool]\nno_license = true\n"
+        violations = list(validate_license_header(path, text))
+        assert violations == []
+
+    # ------------------------------------------------------------------ #
+    # collect_tool_python_files scoping                                   #
+    # ------------------------------------------------------------------ #
+
+    def test_collect_tool_python_files_includes_src_files(self, tmp_path: 
Path) -> None:
+        """Non-trivial Python files under tools/*/src/ are included."""
+        (tmp_path / "tools" / "my-tool" / "src" / 
"my_tool").mkdir(parents=True)
+        target = tmp_path / "tools" / "my-tool" / "src" / "my_tool" / 
"__init__.py"
+        target.write_text(_ASF_HEADER + '"""Package."""\n')
+        files = collect_tool_python_files(tmp_path)
+        assert target in files
+
+    def test_collect_tool_python_files_excludes_venv(self, tmp_path: Path) -> 
None:
+        """Files under .venv/ are excluded even if otherwise eligible."""
+        venv_py = tmp_path / "tools" / "my-tool" / ".venv" / "lib" / 
"python3.12" / "site-packages" / "pkg.py"
+        venv_py.parent.mkdir(parents=True)
+        venv_py.write_text(_ASF_HEADER + '"""Third-party."""\n')
+        files = collect_tool_python_files(tmp_path)
+        assert venv_py not in files
+
+    def test_collect_tool_python_files_excludes_empty_stubs(self, tmp_path: 
Path) -> None:
+        """Truly empty __init__.py stubs are excluded (below the size 
threshold)."""
+        (tmp_path / "tools" / "my-tool" / "tests").mkdir(parents=True)
+        stub = tmp_path / "tools" / "my-tool" / "tests" / "__init__.py"
+        stub.write_text("")  # empty
+        files = collect_tool_python_files(tmp_path)
+        assert stub not in files
+
+    def test_collect_tool_python_files_returns_empty_when_no_tools_dir(self, 
tmp_path: Path) -> None:
+        assert collect_tool_python_files(tmp_path) == []
+
+    # ------------------------------------------------------------------ #
+    # Integration: real repo passes                                       #
+    # ------------------------------------------------------------------ #
+
+    def test_real_repo_tool_python_files_all_have_headers(self) -> None:
+        """Every non-trivial tool Python file in the real repo carries a 
license header."""
+        from skill_and_tool_validator import _LICENSE_PY_MARKERS
+
+        repo_root = find_repo_root()
+        missing = [
+            p
+            for p in collect_tool_python_files(repo_root)
+            if not any(marker in p.read_text(encoding="utf-8") for marker in 
_LICENSE_PY_MARKERS)
+        ]
+        assert missing == [], f"{len(missing)} tool Python file(s) missing any 
license header:\n" + "\n".join(
+            f"  {p.relative_to(repo_root)}" for p in missing
+        )
+
+
 # ---------------------------------------------------------------------------
 # SOFT category exposure
 # ---------------------------------------------------------------------------
@@ -1952,7 +2087,9 @@ def _make_valid_skill(root: Path, name: str) -> Path:
     skill_dir = root / "skills" / name
     skill_dir.mkdir(parents=True, exist_ok=True)
     (skill_dir / "SKILL.md").write_text(
-        f"---\nname: magpie-{name}\ndescription: A test skill.\ncapability: 
capability:setup\nlicense: Apache-2.0\n---\n# Body\nSome content.\n"
+        f"---\nname: magpie-{name}\ndescription: A test skill.\ncapability: 
capability:setup\nlicense: Apache-2.0\n---\n"
+        "<!-- SPDX-License-Identifier: Apache-2.0\n     
https://www.apache.org/licenses/LICENSE-2.0 -->\n"
+        "# Body\nSome content.\n"
     )
     # Inject a row into the skill table of the seeded doc.
     doc = root / "docs" / "labels-and-capabilities.md"
@@ -2002,7 +2139,10 @@ class TestMain:
         root = _skill_root(tmp_path)
         skill_dir = root / "skills" / "bad-skill"
         skill_dir.mkdir(parents=True)
-        (skill_dir / "SKILL.md").write_text("# No frontmatter\n")
+        (skill_dir / "SKILL.md").write_text(
+            "<!-- SPDX-License-Identifier: Apache-2.0\n     
https://www.apache.org/licenses/LICENSE-2.0 -->\n"
+            "# No frontmatter\n"
+        )
         monkeypatch.chdir(root)
 
         # Frontmatter violations use the "general" default category.
@@ -2022,6 +2162,7 @@ class TestMain:
             "description: A test skill.\n"
             "capability: capability:setup\nlicense: Apache-2.0\n"
             "---\n"
+            "<!-- SPDX-License-Identifier: Apache-2.0\n     
https://www.apache.org/licenses/LICENSE-2.0 -->\n"
             "```bash\n"
             'gh pr comment 1 --body "attacker content"\n'
             "```\n"

Reply via email to