diff --git a/scripts/license-headers.py b/scripts/license-headers.py index b36b92a8..bbf32fc6 100644 --- a/scripts/license-headers.py +++ b/scripts/license-headers.py @@ -27,19 +27,29 @@ HEADER_LINES = ( ) -_SPDX_RE = re.compile(r"SPDX-License-Identifier:\s*(\S+)") _CMSD_ID = "LicenseRef-CMSD-1.0" +# Matches an SPDX line only when preceded by comment markers and/or +# whitespace. Prevents false-matches when a docstring mentions the +# token "SPDX-License-Identifier:" in prose. +_SPDX_LINE_RE = re.compile( + r"^[\s/*#]*\s*SPDX-License-Identifier:\s*(\S+)" +) ENROLLMENT_FILE = "scripts/license-headers-enrolled.txt" def detect_existing(text: str) -> str: - """Return 'MATCH', 'FOREIGN', or 'NONE' based on the first 10 lines.""" - head = "\n".join(text.splitlines()[:10]) - match = _SPDX_RE.search(head) - if not match: - return "NONE" - return "MATCH" if match.group(1) == _CMSD_ID else "FOREIGN" + """Return 'MATCH', 'FOREIGN', or 'NONE' based on the first 10 lines. + + Only lines whose non-whitespace content begins with comment markers + (#, //, *, ) and then an SPDX identifier count as a license + declaration. Prose mentions inside docstrings are ignored. + """ + for line in text.splitlines()[:10]: + match = _SPDX_LINE_RE.match(line) + if match: + return "MATCH" if match.group(1) == _CMSD_ID else "FOREIGN" + return "NONE" def render_header(style: str) -> str: @@ -235,9 +245,16 @@ def _find_repo_root(start: Path) -> Path: def _read_enrollment(repo_root: Path) -> list[str]: + """Return enrollment patterns from scripts/license-headers-enrolled.txt. + + Phase semantics (per spec §5.2): + * Missing file → ["**"] — repo-wide enforcement (Phase C final state) + * File exists, empty / only comments → [] — nothing enforced (Phase A initial) + * File with patterns → those patterns + """ f = repo_root / ENROLLMENT_FILE if not f.exists(): - return [] + return ["**"] patterns: list[str] = [] for raw in f.read_text().splitlines(): line = raw.strip() diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py index b485ceaa..1e99bfad 100644 --- a/tests/test_license_headers.py +++ b/tests/test_license_headers.py @@ -120,6 +120,29 @@ def test_detect_existing_only_checks_first_10_lines(): assert license_headers.detect_existing(text) == "NONE" +def test_detect_existing_no_false_match_in_docstring(): + """Prose mentions of SPDX inside docstrings/comments should NOT match. + + Regression: previously the regex matched any 'SPDX-License-Identifier:' + token anywhere in the first 10 lines, including inside Python docstrings + that *describe* what an SPDX header looks like. + """ + text = ( + '"""License header tool.\n' + '\n' + 'Adds the SPDX-License-Identifier: LicenseRef-CMSD-1.0 header.\n' + '"""\n' + 'x = 1\n' + ) + assert license_headers.detect_existing(text) == "NONE" + + +def test_detect_existing_no_false_match_inline_comment_prose(): + """`# Description mentioning SPDX-License-Identifier: ...` is NOT a license line.""" + text = "# This module documents SPDX-License-Identifier: MIT compliance.\nx = 1\n" + assert license_headers.detect_existing(text) == "NONE" + + def test_apply_python_plain(): src = '"""Docstring."""\nprint("hi")\n' out = license_headers.apply(src, ".py") @@ -422,3 +445,19 @@ def test_main_empty_allowlist_passes_check(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) rc = license_headers.main(["--check"]) assert rc == 0 + + +def test_read_enrollment_missing_file_means_repo_wide(tmp_path): + """Spec §5.2: missing allowlist file = repo-wide enforcement (Phase C final).""" + assert license_headers._read_enrollment(tmp_path) == ["**"] + + +def test_main_check_missing_allowlist_enforces_repo_wide(tmp_path, monkeypatch): + """With no allowlist file present, --check should fail on any unheadered file.""" + (tmp_path / ".git").mkdir() + (tmp_path / "scripts").mkdir(exist_ok=True) + # No enrollment file written. + (tmp_path / "a.py").write_text("x = 1\n") # no header + monkeypatch.chdir(tmp_path) + rc = license_headers.main(["--check"]) + assert rc == 1