mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 16:37:04 +00:00
Two bug fixes to the CMSD-1.0 license-header tool: 1. detect_existing() previously matched the SPDX token anywhere in the first 10 lines, including inside docstrings and prose comments. Tightened the matcher to require comment markers (#, //, *, <!--) and whitespace before the SPDX token. Regression tests: - test_detect_existing_no_false_match_in_docstring - test_detect_existing_no_false_match_inline_comment_prose 2. _read_enrollment() now returns ["**"] when the allowlist file is absent, per spec §5.2 "missing file → repo-wide enforcement". Previously it returned [] (nothing enforced), making Phase C closure impossible without an additional file. Empty allowlist file still returns [] (Phase A initial), so test_main_empty_allowlist_passes_check is unaffected. New tests: - test_read_enrollment_missing_file_means_repo_wide - test_main_check_missing_allowlist_enforces_repo_wide Suite: 49 → 53 passing. --check still exits 0 repo-wide. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
330 lines
10 KiB
Python
330 lines
10 KiB
Python
# 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:]))
|