mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 11:12:29 +00:00
Merge pull request #84 from CyberMind-FR/feature/license-headers-phase-a
feat(license): CMSD-1.0 header tool, CI, and conventions (Phase A)
This commit is contained in:
commit
b0b6e78cab
|
|
@ -38,20 +38,28 @@ Développeur : Gérald Kerma (Gandalf) — CyberMind, Notre-Dame-du-Cruet, Savoi
|
|||
|
||||
### Python
|
||||
```python
|
||||
# Entête standard SecuBox-Deb
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
"""
|
||||
SecuBox-Deb :: <NomModule>
|
||||
CyberMind — https://cybermind.fr
|
||||
Author: Gérald Kerma <gandalf@gk2.net>
|
||||
License: Proprietary / ANSSI CSPN candidate
|
||||
"""
|
||||
```
|
||||
|
||||
The SPDX block is added/verified by `scripts/license-headers.py`.
|
||||
|
||||
### Bash
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
# SecuBox-Deb :: <nom_script>
|
||||
# CyberMind — Gérald Kerma
|
||||
set -euo pipefail
|
||||
readonly MODULE="<nom>"
|
||||
readonly VERSION="<semver>"
|
||||
|
|
|
|||
22
.github/workflows/license-check.yml
vendored
Normal file
22
.github/workflows/license-check.yml
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
name: License Headers
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Verify CMSD-1.0 headers
|
||||
run: python3 scripts/license-headers.py --check
|
||||
|
|
@ -333,6 +333,42 @@ cp -a /var/lib/secubox/rollback/pre-migration-20260429-143022/* /etc/
|
|||
|
||||
---
|
||||
|
||||
## license-headers.py
|
||||
|
||||
CMSD-1.0 SPDX header tool. Adds, verifies, or previews license headers
|
||||
on all first-party source files. Pure stdlib; no dependencies.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
python3 scripts/license-headers.py --check # CI mode; exit 1 if missing
|
||||
python3 scripts/license-headers.py --fix # add headers in place
|
||||
python3 scripts/license-headers.py --list # list files that would be touched
|
||||
python3 scripts/license-headers.py --diff # unified diff per file (no writes)
|
||||
python3 scripts/license-headers.py --fix common/ # scope to a path
|
||||
```
|
||||
|
||||
**Enrollment allowlist:** `scripts/license-headers-enrolled.txt`. One glob
|
||||
per line; `#`-prefixed lines are comments. Empty means no enforcement.
|
||||
Phase A leaves it nearly empty; Phase B adds lines per package; Phase C
|
||||
deletes it to enforce repo-wide.
|
||||
|
||||
**Optional pre-commit hook** (off by default):
|
||||
|
||||
```yaml
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: license-headers
|
||||
name: License Headers (CMSD-1.0)
|
||||
entry: python3 scripts/license-headers.py --fix
|
||||
language: system
|
||||
pass_filenames: true
|
||||
```
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-12-license-headers-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|
|
|
|||
8
scripts/license-headers-enrolled.txt
Normal file
8
scripts/license-headers-enrolled.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# CMSD-1.0 license header enrollment allowlist.
|
||||
# One glob per line (paths relative to repo root).
|
||||
# Phase A initial state: empty — CI does not enforce headers yet.
|
||||
# Phase B adds one line per package as they're enrolled.
|
||||
# Phase C: this file is deleted; CI then enforces repo-wide.
|
||||
scripts/license-headers.py
|
||||
tests/test_license_headers.py
|
||||
.github/workflows/license-check.yml
|
||||
329
scripts/license-headers.py
Normal file
329
scripts/license-headers.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
# scripts/license-headers.py
|
||||
"""CMSD-1.0 license header tool.
|
||||
|
||||
Adds and verifies the SPDX-License-Identifier: LicenseRef-CMSD-1.0 header
|
||||
on every first-party source file. See docs/superpowers/specs/2026-05-12-license-headers-design.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import fnmatch
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HEADER_LINES = (
|
||||
"SPDX-License-Identifier: LicenseRef-CMSD-1.0",
|
||||
"Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>",
|
||||
"Source-Disclosed License — All rights reserved except as expressly granted.",
|
||||
"See LICENCE-CMSD-1.0.md for terms.",
|
||||
)
|
||||
|
||||
|
||||
_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.
|
||||
|
||||
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:
|
||||
if style == "hash":
|
||||
return "".join(f"# {line}\n" for line in HEADER_LINES)
|
||||
if style == "slash":
|
||||
return "".join(f"// {line}\n" for line in HEADER_LINES)
|
||||
if style == "block":
|
||||
body = "".join(f" * {line}\n" for line in HEADER_LINES)
|
||||
return f"/*\n{body} */\n"
|
||||
if style == "html":
|
||||
body = "".join(f" {line}\n" for line in HEADER_LINES)
|
||||
return f"<!--\n{body}-->\n"
|
||||
raise ValueError(f"unknown comment style: {style}")
|
||||
|
||||
|
||||
SKIP_DIRS = frozenset({
|
||||
"kernel-build", "redroid", "Tow-Boot",
|
||||
"output", "cache", "backups", "apt", "repo",
|
||||
"node_modules", ".venv", ".git", "__pycache__", "dist", "build",
|
||||
})
|
||||
|
||||
SKIP_GLOBS = (
|
||||
"*.min.js",
|
||||
"*.min.css",
|
||||
"package-lock.json",
|
||||
"*.lock",
|
||||
)
|
||||
|
||||
|
||||
LANG_TABLE: dict[str, tuple[str, str]] = {
|
||||
".py": ("hash", "python"),
|
||||
".sh": ("hash", "shebang_hash"),
|
||||
".js": ("slash", "top"),
|
||||
".mjs": ("slash", "top"),
|
||||
".ts": ("slash", "top"),
|
||||
".css": ("block", "top"),
|
||||
".c": ("block", "top"),
|
||||
".h": ("block", "top"),
|
||||
".html": ("html", "html"),
|
||||
".htm": ("html", "html"),
|
||||
".md": ("html", "markdown"),
|
||||
".toml": ("hash", "top"),
|
||||
".yaml": ("hash", "top"),
|
||||
".yml": ("hash", "top"),
|
||||
".conf": ("hash", "top"),
|
||||
}
|
||||
|
||||
|
||||
def _place_python(header: str, text: str) -> str:
|
||||
lines = text.splitlines(keepends=True)
|
||||
insert_at = 0
|
||||
# Skip shebang
|
||||
if lines and lines[0].startswith("#!"):
|
||||
insert_at = 1
|
||||
# Skip encoding declaration (PEP 263)
|
||||
if insert_at < len(lines) and re.match(r"^#.*coding[:=]", lines[insert_at]):
|
||||
insert_at += 1
|
||||
return "".join(lines[:insert_at]) + header + "\n" + "".join(lines[insert_at:])
|
||||
|
||||
|
||||
def _place_shebang_hash(header: str, text: str) -> str:
|
||||
"""Hash-comment language with a shebang line on line 1."""
|
||||
lines = text.splitlines(keepends=True)
|
||||
insert_at = 1 if lines and lines[0].startswith("#!") else 0
|
||||
return "".join(lines[:insert_at]) + header + "\n" + "".join(lines[insert_at:])
|
||||
|
||||
|
||||
def _place_top(header: str, text: str) -> str:
|
||||
"""Insert header at line 1 with one blank line separator."""
|
||||
return header + "\n" + text
|
||||
|
||||
|
||||
def _place_html(header: str, text: str) -> str:
|
||||
lines = text.splitlines(keepends=True)
|
||||
insert_at = 0
|
||||
if lines and lines[0].lstrip().lower().startswith("<!doctype"):
|
||||
insert_at = 1
|
||||
return "".join(lines[:insert_at]) + header + "\n" + "".join(lines[insert_at:])
|
||||
|
||||
|
||||
def _place_markdown(header: str, text: str) -> str:
|
||||
"""Place after YAML frontmatter if present, else at top."""
|
||||
lines = text.splitlines(keepends=True)
|
||||
insert_at = 0
|
||||
if lines and lines[0].rstrip() == "---":
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].rstrip() == "---":
|
||||
insert_at = i + 1
|
||||
break
|
||||
return "".join(lines[:insert_at]) + header + "\n" + "".join(lines[insert_at:])
|
||||
|
||||
|
||||
_PLACERS = {
|
||||
"python": _place_python,
|
||||
"shebang_hash": _place_shebang_hash,
|
||||
"top": _place_top,
|
||||
"html": _place_html,
|
||||
"markdown": _place_markdown,
|
||||
}
|
||||
|
||||
|
||||
def apply(text: str, ext: str) -> str:
|
||||
"""Insert the CMSD header into `text` for files with extension `ext`.
|
||||
|
||||
Returns `text` unchanged if a CMSD header is already present or if a
|
||||
foreign SPDX header is detected.
|
||||
"""
|
||||
if ext not in LANG_TABLE:
|
||||
return text
|
||||
status = detect_existing(text)
|
||||
if status in ("MATCH", "FOREIGN"):
|
||||
return text
|
||||
style, placer_name = LANG_TABLE[ext]
|
||||
header = render_header(style)
|
||||
return _PLACERS[placer_name](header, text)
|
||||
|
||||
|
||||
def _is_in_scope(path: Path) -> bool:
|
||||
"""True iff `path` has an extension in LANG_TABLE and matches no skip glob."""
|
||||
if path.suffix not in LANG_TABLE:
|
||||
return False
|
||||
name = path.name
|
||||
for pat in SKIP_GLOBS:
|
||||
if fnmatch.fnmatch(name, pat):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _matches_allowlist(rel: Path, enrolled: list[str]) -> bool:
|
||||
"""True iff `rel` matches any glob in `enrolled`."""
|
||||
rel_str = rel.as_posix()
|
||||
for pat in enrolled:
|
||||
if fnmatch.fnmatch(rel_str, pat):
|
||||
return True
|
||||
# Allow "common/**" to match "common/a.py"
|
||||
if pat.endswith("/**") and (
|
||||
rel_str == pat[:-3] or rel_str.startswith(pat[:-2])
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def walk(paths: list[Path], enrolled: list[str], repo_root: Path | None = None):
|
||||
"""Yield in-scope files under each path, honoring SKIP_DIRS, SKIP_GLOBS, allowlist.
|
||||
|
||||
`repo_root` (when provided) is the base for allowlist pattern matching.
|
||||
Allowlist patterns are repo-relative, so a caller walking a subdirectory
|
||||
must pass `repo_root` separately for the patterns to match. When omitted,
|
||||
paths are matched relative to their walk root (backwards-compatible).
|
||||
"""
|
||||
allowlist_base = repo_root.resolve() if repo_root is not None else None
|
||||
for root in paths:
|
||||
root = Path(root)
|
||||
if root.is_file():
|
||||
if not _is_in_scope(root):
|
||||
continue
|
||||
rel = (
|
||||
root.resolve().relative_to(allowlist_base)
|
||||
if allowlist_base is not None
|
||||
else root
|
||||
)
|
||||
if _matches_allowlist(rel, enrolled):
|
||||
yield root
|
||||
continue
|
||||
for p in root.rglob("*"):
|
||||
# Prune skip-dirs
|
||||
if any(part in SKIP_DIRS for part in p.relative_to(root).parts):
|
||||
continue
|
||||
if not p.is_file():
|
||||
continue
|
||||
if not _is_in_scope(p):
|
||||
continue
|
||||
if allowlist_base is not None:
|
||||
try:
|
||||
rel = p.resolve().relative_to(allowlist_base)
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
rel = p.relative_to(root)
|
||||
if not _matches_allowlist(rel, enrolled):
|
||||
continue
|
||||
yield p
|
||||
|
||||
|
||||
def _find_repo_root(start: Path) -> Path:
|
||||
cur = start.resolve()
|
||||
while cur != cur.parent:
|
||||
if (cur / ".git").exists():
|
||||
return cur
|
||||
cur = cur.parent
|
||||
return start
|
||||
|
||||
|
||||
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 ["**"]
|
||||
patterns: list[str] = []
|
||||
for raw in f.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
patterns.append(line)
|
||||
return patterns
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="CMSD-1.0 license header tool")
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--check", action="store_true")
|
||||
mode.add_argument("--fix", action="store_true")
|
||||
mode.add_argument("--list", dest="list_", action="store_true")
|
||||
mode.add_argument("--diff", action="store_true")
|
||||
parser.add_argument("paths", nargs="*")
|
||||
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except SystemExit as e:
|
||||
return int(e.code) if isinstance(e.code, int) else 2
|
||||
|
||||
repo_root = _find_repo_root(Path.cwd())
|
||||
enrolled = _read_enrollment(repo_root)
|
||||
paths = [Path(p) for p in args.paths] if args.paths else [repo_root]
|
||||
|
||||
missing: list[Path] = []
|
||||
for p in walk(paths, enrolled, repo_root=repo_root):
|
||||
text = p.read_text(encoding="utf-8", errors="replace")
|
||||
status = detect_existing(text)
|
||||
|
||||
if status == "FOREIGN":
|
||||
print(f"skip (foreign SPDX): {p}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
if args.list_:
|
||||
if status == "NONE":
|
||||
print(p)
|
||||
continue
|
||||
|
||||
if args.diff:
|
||||
if status == "NONE":
|
||||
new = apply(text, p.suffix)
|
||||
for line in difflib.unified_diff(
|
||||
text.splitlines(keepends=True),
|
||||
new.splitlines(keepends=True),
|
||||
fromfile=str(p),
|
||||
tofile=str(p),
|
||||
):
|
||||
sys.stdout.write(line)
|
||||
continue
|
||||
|
||||
if args.fix:
|
||||
if status == "NONE":
|
||||
p.write_text(apply(text, p.suffix), encoding="utf-8")
|
||||
continue
|
||||
|
||||
if args.check:
|
||||
if status == "NONE":
|
||||
missing.append(p)
|
||||
|
||||
if args.check and missing:
|
||||
print("Files missing CMSD-1.0 header:")
|
||||
for p in missing:
|
||||
print(f" {p}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
463
tests/test_license_headers.py
Normal file
463
tests/test_license_headers.py
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
# tests/test_license_headers.py
|
||||
"""Tests for scripts/license-headers.py.
|
||||
|
||||
The tool's filename contains a hyphen so it is loaded via
|
||||
importlib.util.spec_from_file_location rather than a normal import.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_TOOL_PATH = REPO_ROOT / "scripts" / "license-headers.py"
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("license_headers", _TOOL_PATH)
|
||||
license_headers = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(license_headers)
|
||||
|
||||
|
||||
def test_module_imports():
|
||||
assert hasattr(license_headers, "main")
|
||||
|
||||
|
||||
EXPECTED_HASH_HEADER = (
|
||||
"# SPDX-License-Identifier: LicenseRef-CMSD-1.0\n"
|
||||
"# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>\n"
|
||||
"# Source-Disclosed License — All rights reserved except as expressly granted.\n"
|
||||
"# See LICENCE-CMSD-1.0.md for terms.\n"
|
||||
)
|
||||
|
||||
|
||||
def test_render_header_hash():
|
||||
assert license_headers.render_header("hash") == EXPECTED_HASH_HEADER
|
||||
|
||||
|
||||
EXPECTED_SLASH_HEADER = (
|
||||
"// SPDX-License-Identifier: LicenseRef-CMSD-1.0\n"
|
||||
"// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>\n"
|
||||
"// Source-Disclosed License — All rights reserved except as expressly granted.\n"
|
||||
"// See LICENCE-CMSD-1.0.md for terms.\n"
|
||||
)
|
||||
|
||||
EXPECTED_BLOCK_HEADER = (
|
||||
"/*\n"
|
||||
" * SPDX-License-Identifier: LicenseRef-CMSD-1.0\n"
|
||||
" * Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>\n"
|
||||
" * Source-Disclosed License — All rights reserved except as expressly granted.\n"
|
||||
" * See LICENCE-CMSD-1.0.md for terms.\n"
|
||||
" */\n"
|
||||
)
|
||||
|
||||
EXPECTED_HTML_HEADER = (
|
||||
"<!--\n"
|
||||
" SPDX-License-Identifier: LicenseRef-CMSD-1.0\n"
|
||||
" Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>\n"
|
||||
" Source-Disclosed License — All rights reserved except as expressly granted.\n"
|
||||
" See LICENCE-CMSD-1.0.md for terms.\n"
|
||||
"-->\n"
|
||||
)
|
||||
|
||||
|
||||
def test_render_header_slash():
|
||||
assert license_headers.render_header("slash") == EXPECTED_SLASH_HEADER
|
||||
|
||||
|
||||
def test_render_header_block():
|
||||
assert license_headers.render_header("block") == EXPECTED_BLOCK_HEADER
|
||||
|
||||
|
||||
def test_render_header_html():
|
||||
assert license_headers.render_header("html") == EXPECTED_HTML_HEADER
|
||||
|
||||
|
||||
def test_detect_existing_none_on_empty():
|
||||
assert license_headers.detect_existing("") == "NONE"
|
||||
|
||||
|
||||
def test_detect_existing_none_on_plain_code():
|
||||
assert license_headers.detect_existing("print('hello')\n") == "NONE"
|
||||
|
||||
|
||||
def test_detect_existing_match_hash():
|
||||
text = EXPECTED_HASH_HEADER + "\nprint('hello')\n"
|
||||
assert license_headers.detect_existing(text) == "MATCH"
|
||||
|
||||
|
||||
def test_detect_existing_match_slash():
|
||||
text = EXPECTED_SLASH_HEADER + "\nconsole.log('hi');\n"
|
||||
assert license_headers.detect_existing(text) == "MATCH"
|
||||
|
||||
|
||||
def test_detect_existing_match_block():
|
||||
text = EXPECTED_BLOCK_HEADER + "\nint main(void) { return 0; }\n"
|
||||
assert license_headers.detect_existing(text) == "MATCH"
|
||||
|
||||
|
||||
def test_detect_existing_match_html():
|
||||
text = EXPECTED_HTML_HEADER + "\n<html></html>\n"
|
||||
assert license_headers.detect_existing(text) == "MATCH"
|
||||
|
||||
|
||||
def test_detect_existing_foreign_mit():
|
||||
text = "# SPDX-License-Identifier: MIT\nprint('hello')\n"
|
||||
assert license_headers.detect_existing(text) == "FOREIGN"
|
||||
|
||||
|
||||
def test_detect_existing_foreign_gpl():
|
||||
text = "// SPDX-License-Identifier: GPL-2.0-or-later\nint x;\n"
|
||||
assert license_headers.detect_existing(text) == "FOREIGN"
|
||||
|
||||
|
||||
def test_detect_existing_only_checks_first_10_lines():
|
||||
"""A CMSD line buried deep in the file should NOT be treated as MATCH."""
|
||||
text = "\n".join(["# unrelated"] * 20) + "\n# SPDX-License-Identifier: LicenseRef-CMSD-1.0\n"
|
||||
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")
|
||||
assert out == EXPECTED_HASH_HEADER + "\n" + src
|
||||
|
||||
|
||||
def test_apply_python_idempotent():
|
||||
src = '"""Docstring."""\nprint("hi")\n'
|
||||
once = license_headers.apply(src, ".py")
|
||||
twice = license_headers.apply(once, ".py")
|
||||
assert once == twice
|
||||
|
||||
|
||||
def test_apply_foreign_python_unchanged():
|
||||
src = "# SPDX-License-Identifier: MIT\nprint('x')\n"
|
||||
out = license_headers.apply(src, ".py")
|
||||
assert out == src
|
||||
|
||||
|
||||
def test_apply_python_with_shebang():
|
||||
src = '#!/usr/bin/env python3\nprint("x")\n'
|
||||
out = license_headers.apply(src, ".py")
|
||||
assert out.startswith("#!/usr/bin/env python3\n")
|
||||
assert out.splitlines()[1] == "# SPDX-License-Identifier: LicenseRef-CMSD-1.0"
|
||||
|
||||
|
||||
def test_apply_python_with_encoding():
|
||||
src = '# -*- coding: utf-8 -*-\nprint("x")\n'
|
||||
out = license_headers.apply(src, ".py")
|
||||
assert out.startswith("# -*- coding: utf-8 -*-\n")
|
||||
assert out.splitlines()[1] == "# SPDX-License-Identifier: LicenseRef-CMSD-1.0"
|
||||
|
||||
|
||||
def test_apply_python_with_shebang_and_encoding():
|
||||
src = '#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint("x")\n'
|
||||
out = license_headers.apply(src, ".py")
|
||||
lines = out.splitlines()
|
||||
assert lines[0] == "#!/usr/bin/env python3"
|
||||
assert lines[1] == "# -*- coding: utf-8 -*-"
|
||||
assert lines[2] == "# SPDX-License-Identifier: LicenseRef-CMSD-1.0"
|
||||
|
||||
|
||||
def test_apply_bash_with_shebang():
|
||||
src = '#!/usr/bin/env bash\nset -euo pipefail\necho "hi"\n'
|
||||
out = license_headers.apply(src, ".sh")
|
||||
assert out.startswith("#!/usr/bin/env bash\n")
|
||||
assert out.splitlines()[1] == "# SPDX-License-Identifier: LicenseRef-CMSD-1.0"
|
||||
|
||||
|
||||
def test_apply_bash_without_shebang():
|
||||
src = 'echo "hi"\n'
|
||||
out = license_headers.apply(src, ".sh")
|
||||
assert out.startswith("# SPDX-License-Identifier: LicenseRef-CMSD-1.0\n")
|
||||
|
||||
|
||||
def test_apply_bash_idempotent():
|
||||
src = '#!/usr/bin/env bash\nset -e\n'
|
||||
once = license_headers.apply(src, ".sh")
|
||||
twice = license_headers.apply(once, ".sh")
|
||||
assert once == twice
|
||||
|
||||
|
||||
def test_apply_javascript():
|
||||
src = 'console.log("hi");\n'
|
||||
out = license_headers.apply(src, ".js")
|
||||
assert out == EXPECTED_SLASH_HEADER + "\n" + src
|
||||
|
||||
|
||||
def test_apply_typescript():
|
||||
src = 'const x: number = 1;\n'
|
||||
out = license_headers.apply(src, ".ts")
|
||||
assert out == EXPECTED_SLASH_HEADER + "\n" + src
|
||||
|
||||
|
||||
def test_apply_css():
|
||||
src = 'body { color: red; }\n'
|
||||
out = license_headers.apply(src, ".css")
|
||||
assert out == EXPECTED_BLOCK_HEADER + "\n" + src
|
||||
|
||||
|
||||
def test_apply_c():
|
||||
src = 'int main(void) { return 0; }\n'
|
||||
out = license_headers.apply(src, ".c")
|
||||
assert out == EXPECTED_BLOCK_HEADER + "\n" + src
|
||||
|
||||
|
||||
def test_apply_idempotent_all_styles():
|
||||
cases = [
|
||||
(".js", 'console.log("x");\n'),
|
||||
(".ts", 'const x = 1;\n'),
|
||||
(".css", 'a { color: red; }\n'),
|
||||
(".c", 'int x;\n'),
|
||||
(".h", '#pragma once\n'),
|
||||
]
|
||||
for ext, src in cases:
|
||||
once = license_headers.apply(src, ext)
|
||||
twice = license_headers.apply(once, ext)
|
||||
assert once == twice, f"non-idempotent for {ext}"
|
||||
|
||||
|
||||
def test_apply_html_no_doctype():
|
||||
src = '<html><body>hi</body></html>\n'
|
||||
out = license_headers.apply(src, ".html")
|
||||
assert out == EXPECTED_HTML_HEADER + "\n" + src
|
||||
|
||||
|
||||
def test_apply_html_with_doctype():
|
||||
src = '<!DOCTYPE html>\n<html><body>hi</body></html>\n'
|
||||
out = license_headers.apply(src, ".html")
|
||||
lines = out.splitlines(keepends=True)
|
||||
assert lines[0] == "<!DOCTYPE html>\n"
|
||||
assert lines[1] == "<!--\n"
|
||||
assert "SPDX-License-Identifier: LicenseRef-CMSD-1.0" in lines[2]
|
||||
|
||||
|
||||
def test_apply_html_idempotent():
|
||||
src = '<!DOCTYPE html>\n<html></html>\n'
|
||||
once = license_headers.apply(src, ".html")
|
||||
twice = license_headers.apply(once, ".html")
|
||||
assert once == twice
|
||||
|
||||
|
||||
def test_apply_markdown_plain():
|
||||
src = '# Title\n\nBody.\n'
|
||||
out = license_headers.apply(src, ".md")
|
||||
assert out == EXPECTED_HTML_HEADER + "\n" + src
|
||||
|
||||
|
||||
def test_apply_markdown_with_frontmatter():
|
||||
src = '---\ntitle: Foo\n---\n\n# Body\n'
|
||||
out = license_headers.apply(src, ".md")
|
||||
lines = out.splitlines(keepends=True)
|
||||
assert lines[0] == "---\n"
|
||||
assert lines[1] == "title: Foo\n"
|
||||
assert lines[2] == "---\n"
|
||||
assert lines[3] == "<!--\n"
|
||||
|
||||
|
||||
def test_apply_markdown_idempotent_with_frontmatter():
|
||||
src = '---\ntitle: Foo\n---\n\nBody.\n'
|
||||
once = license_headers.apply(src, ".md")
|
||||
twice = license_headers.apply(once, ".md")
|
||||
assert once == twice
|
||||
|
||||
|
||||
def test_walk_finds_python(tmp_path):
|
||||
(tmp_path / "a.py").write_text("x = 1\n")
|
||||
(tmp_path / "b.txt").write_text("ignore me\n")
|
||||
result = list(license_headers.walk([tmp_path], enrolled=["**"]))
|
||||
assert tmp_path / "a.py" in result
|
||||
assert tmp_path / "b.txt" not in result
|
||||
|
||||
|
||||
def test_walk_prunes_kernel_build(tmp_path):
|
||||
(tmp_path / "kernel-build").mkdir()
|
||||
(tmp_path / "kernel-build" / "a.c").write_text("int x;\n")
|
||||
(tmp_path / "real.c").write_text("int y;\n")
|
||||
result = list(license_headers.walk([tmp_path], enrolled=["**"]))
|
||||
assert tmp_path / "real.c" in result
|
||||
assert tmp_path / "kernel-build" / "a.c" not in result
|
||||
|
||||
|
||||
def test_walk_prunes_node_modules(tmp_path):
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
(tmp_path / "node_modules" / "lib.js").write_text("// vendor\n")
|
||||
(tmp_path / "src.js").write_text("// app\n")
|
||||
result = list(license_headers.walk([tmp_path], enrolled=["**"]))
|
||||
assert tmp_path / "src.js" in result
|
||||
assert tmp_path / "node_modules" / "lib.js" not in result
|
||||
|
||||
|
||||
def test_walk_skips_minified(tmp_path):
|
||||
(tmp_path / "app.min.js").write_text("// min\n")
|
||||
(tmp_path / "app.js").write_text("// raw\n")
|
||||
result = list(license_headers.walk([tmp_path], enrolled=["**"]))
|
||||
assert tmp_path / "app.js" in result
|
||||
assert tmp_path / "app.min.js" not in result
|
||||
|
||||
|
||||
def test_walk_respects_empty_allowlist(tmp_path):
|
||||
(tmp_path / "a.py").write_text("x = 1\n")
|
||||
result = list(license_headers.walk([tmp_path], enrolled=[]))
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_walk_respects_glob_allowlist(tmp_path):
|
||||
(tmp_path / "common").mkdir()
|
||||
(tmp_path / "common" / "a.py").write_text("x = 1\n")
|
||||
(tmp_path / "other").mkdir()
|
||||
(tmp_path / "other" / "b.py").write_text("y = 1\n")
|
||||
result = list(license_headers.walk([tmp_path], enrolled=["common/**"]))
|
||||
rel = sorted(p.relative_to(tmp_path).as_posix() for p in result)
|
||||
assert rel == ["common/a.py"]
|
||||
|
||||
|
||||
def test_walk_subdir_with_repo_root_arg(tmp_path):
|
||||
"""Walking a subdirectory must match repo-relative allowlist patterns
|
||||
(regression: discovered during Phase B pilot — user passes
|
||||
`--fix packages/secubox-hub`, allowlist has `packages/secubox-hub/**`).
|
||||
"""
|
||||
(tmp_path / "packages" / "hub").mkdir(parents=True)
|
||||
(tmp_path / "packages" / "hub" / "a.py").write_text("x = 1\n")
|
||||
(tmp_path / "packages" / "other" / "b.py").parent.mkdir(parents=True)
|
||||
(tmp_path / "packages" / "other" / "b.py").write_text("y = 1\n")
|
||||
result = list(
|
||||
license_headers.walk(
|
||||
[tmp_path / "packages" / "hub"],
|
||||
enrolled=["packages/hub/**"],
|
||||
repo_root=tmp_path,
|
||||
)
|
||||
)
|
||||
rel = sorted(p.resolve().relative_to(tmp_path.resolve()).as_posix() for p in result)
|
||||
assert rel == ["packages/hub/a.py"]
|
||||
|
||||
|
||||
def _write_enrolled(tmp_path: Path, patterns: list[str]) -> Path:
|
||||
f = tmp_path / "scripts" / "license-headers-enrolled.txt"
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_text("\n".join(patterns) + "\n")
|
||||
return f
|
||||
|
||||
|
||||
def test_main_check_clean_repo(tmp_path, monkeypatch):
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / "scripts").mkdir(exist_ok=True)
|
||||
_write_enrolled(tmp_path, ["**"])
|
||||
(tmp_path / "a.py").write_text(EXPECTED_HASH_HEADER + "\nx = 1\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
rc = license_headers.main(["--check"])
|
||||
assert rc == 0
|
||||
|
||||
|
||||
def test_main_check_dirty_repo(tmp_path, monkeypatch, capsys):
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / "scripts").mkdir(exist_ok=True)
|
||||
_write_enrolled(tmp_path, ["**"])
|
||||
(tmp_path / "a.py").write_text("x = 1\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
rc = license_headers.main(["--check"])
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "a.py" in out
|
||||
|
||||
|
||||
def test_main_fix_writes_files(tmp_path, monkeypatch):
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / "scripts").mkdir(exist_ok=True)
|
||||
_write_enrolled(tmp_path, ["**"])
|
||||
f = tmp_path / "a.py"
|
||||
f.write_text("x = 1\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
rc = license_headers.main(["--fix"])
|
||||
assert rc == 0
|
||||
text = f.read_text()
|
||||
assert "SPDX-License-Identifier: LicenseRef-CMSD-1.0" in text
|
||||
|
||||
|
||||
def test_main_list(tmp_path, monkeypatch, capsys):
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / "scripts").mkdir(exist_ok=True)
|
||||
_write_enrolled(tmp_path, ["**"])
|
||||
(tmp_path / "a.py").write_text("x = 1\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
rc = license_headers.main(["--list"])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "a.py" in out
|
||||
|
||||
|
||||
def test_main_diff(tmp_path, monkeypatch, capsys):
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / "scripts").mkdir(exist_ok=True)
|
||||
_write_enrolled(tmp_path, ["**"])
|
||||
(tmp_path / "a.py").write_text("x = 1\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
rc = license_headers.main(["--diff"])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "SPDX-License-Identifier: LicenseRef-CMSD-1.0" in out
|
||||
# File was not modified by --diff
|
||||
assert (tmp_path / "a.py").read_text() == "x = 1\n"
|
||||
|
||||
|
||||
def test_main_modes_mutually_exclusive():
|
||||
rc = license_headers.main(["--check", "--fix"])
|
||||
assert rc == 2
|
||||
|
||||
|
||||
def test_main_requires_a_mode():
|
||||
rc = license_headers.main([])
|
||||
assert rc == 2
|
||||
|
||||
|
||||
def test_main_empty_allowlist_passes_check(tmp_path, monkeypatch):
|
||||
"""An empty enrollment file means nothing is checked — Phase A initial state."""
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / "scripts").mkdir(exist_ok=True)
|
||||
_write_enrolled(tmp_path, [])
|
||||
(tmp_path / "a.py").write_text("x = 1\n") # no header, but not enrolled
|
||||
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
|
||||
Loading…
Reference in New Issue
Block a user