From cdc74563b294fd83fb5c3b154c17807e5d5ec647 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 12 May 2026 09:17:33 +0200 Subject: [PATCH 01/19] docs(license): spec + Phase A implementation plan for CMSD-1.0 headers Adds the brainstormed design and TDD implementation plan for adding the CMSD-1.0 SPDX header to all first-party source files. Spec covers scope, header rendering per language, placement rules, tool architecture, CI integration via enrollment allowlist, and verification. Plan breaks Phase A into 17 TDD tasks producing scripts/license-headers.py, tests/test_license_headers.py, and .github/workflows/license-check.yml. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-12-license-headers.md | 1801 +++++++++++++++++ .../2026-05-12-license-headers-design.md | 270 +++ 2 files changed, 2071 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-12-license-headers.md create mode 100644 docs/superpowers/specs/2026-05-12-license-headers-design.md diff --git a/docs/superpowers/plans/2026-05-12-license-headers.md b/docs/superpowers/plans/2026-05-12-license-headers.md new file mode 100644 index 00000000..bac47d05 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-license-headers.md @@ -0,0 +1,1801 @@ +# CMSD-1.0 License Headers — Phase A Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the `scripts/license-headers.py` tool, its tests, the CI workflow, and update conventions docs — so that subsequent per-package PRs can simply run the tool and enroll their paths in the allowlist. + +**Architecture:** A single-file stdlib Python tool with four CLI modes (`--check`/`--fix`/`--list`/`--diff`), language-aware comment rendering, idempotent `apply()`, and a per-path enrollment allowlist read by `--check`. CI runs `--check` on every PR. Phase B (per-package rollout) and Phase C (allowlist removal) are operational and out of scope for this plan. + +**Tech Stack:** Python 3.11+ stdlib only (`argparse`, `pathlib`, `re`, `sys`, `difflib`, `fnmatch`), pytest for tests, GitHub Actions for CI. + +**Spec:** `docs/superpowers/specs/2026-05-12-license-headers-design.md` + +--- + +## File Structure + +| Path | Responsibility | +|---|---| +| `scripts/license-headers.py` | The CLI tool: walk → detect → apply → write/check | +| `tests/test_license_headers.py` | pytest suite covering rendering, placement, idempotence, walker, CLI | +| `scripts/license-headers-enrolled.txt` | Empty allowlist file (one glob per line); read by `--check` | +| `.github/workflows/license-check.yml` | CI: runs `python3 scripts/license-headers.py --check` on PRs and pushes to master | +| `scripts/README.md` | Add a section documenting the tool, modes, and optional pre-commit hook | +| `CLAUDE.md` | Update Python and Bash convention examples to show the SPDX block above docstring/shebang | + +The tool is intentionally one file. The plan keeps internal functions (`render_header`, `detect_existing`, `apply`, `walk`, `main`) testable as module-level callables — no class hierarchy. + +--- + +## Conventions used in every task + +- Working directory: repo root (`/home/reepost/CyberMindStudio/secubox-deb/secubox-deb`). +- Run pytest with `python3 -m pytest tests/test_license_headers.py -v` (or scope to one test with `::test_name`). +- Commit messages follow `feat:` / `test:` / `chore:` / `docs:` / `ci:` conventional prefixes, no Co-Authored-By unless the project standard requires it (CLAUDE.md does — include it). +- Every commit references the GitHub Issue created in Task 0 (e.g. `(ref #NN)`). + +--- + +## Task 0: Create tracking GitHub Issue + +**Files:** none yet. + +- [ ] **Step 1: Create the issue** + +```bash +gh issue create --title "Add CMSD-1.0 SPDX header to all first-party source files" \ + --body "$(cat <<'EOF' +## Context + +Spec: docs/superpowers/specs/2026-05-12-license-headers-design.md +Plan: docs/superpowers/plans/2026-05-12-license-headers.md + +Today 0 of ~2,170 first-party source files carry the CMSD-1.0 SPDX header documented in LICENSING.md. This issue tracks Phase A (tool + CI + conventions) and the subsequent per-package enrollment PRs. + +## Phase A tasks + +- [ ] Tool: scripts/license-headers.py +- [ ] Tests: tests/test_license_headers.py +- [ ] CI: .github/workflows/license-check.yml +- [ ] Empty enrollment allowlist: scripts/license-headers-enrolled.txt +- [ ] scripts/README.md documents the tool +- [ ] CLAUDE.md convention examples updated + +## Phase B (separate PRs, one per package; tracked by sub-issues) + +- [ ] 14 PRs for packages/secubox-* (one per package) +- [ ] common/ + api/ +- [ ] scripts/ + image/ + board/ + top-level +- [ ] docs/ + .claude/ + Markdown + +## Phase C (closure) + +- [ ] Delete scripts/license-headers-enrolled.txt +- [ ] Note in LICENSING.md +EOF +)" --label "infra,documentation" +``` + +Expected: prints the issue URL. Capture the issue number into your scratch space (call it `$ISSUE`) — every subsequent commit message references it. + +- [ ] **Step 2: Confirm** + +```bash +gh issue list --state open | head -5 +``` + +Expected: the new issue appears at the top. + +--- + +## Task 1: Scaffold the tool file and the test file + +**Files:** +- Create: `scripts/license-headers.py` +- Create: `tests/test_license_headers.py` + +- [ ] **Step 1: Create the tool stub** + +```python +# 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 sys +from pathlib import Path + + +def main(argv: list[str] | None = None) -> int: + raise NotImplementedError("see Task 9") + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) +``` + +- [ ] **Step 2: Create the test file with one smoke test** + +```python +# 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") +``` + +- [ ] **Step 3: Run the smoke test** + +```bash +python3 -m pytest tests/test_license_headers.py::test_module_imports -v +``` + +Expected: 1 passed. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/license-headers.py tests/test_license_headers.py +git commit -m "$(cat < +EOF +)" +``` + +--- + +## Task 2: `render_header()` for hash-comment languages (Python, Bash, YAML, TOML, conf) + +**Files:** +- Modify: `scripts/license-headers.py` +- Modify: `tests/test_license_headers.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_license_headers.py`: + +```python +EXPECTED_HASH_HEADER = ( + "# SPDX-License-Identifier: LicenseRef-CMSD-1.0\n" + "# Copyright (c) 2026 CyberMind — Gérald Kerma \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 +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +python3 -m pytest tests/test_license_headers.py::test_render_header_hash -v +``` + +Expected: FAIL with `AttributeError: module 'license-headers' has no attribute 'render_header'`. + +- [ ] **Step 3: Implement `render_header()` minimally** + +Add to `scripts/license-headers.py`, above `main()`: + +```python +HEADER_LINES = ( + "SPDX-License-Identifier: LicenseRef-CMSD-1.0", + "Copyright (c) 2026 CyberMind — Gérald Kerma ", + "Source-Disclosed License — All rights reserved except as expressly granted.", + "See LICENCE-CMSD-1.0.md for terms.", +) + + +def render_header(style: str) -> str: + if style == "hash": + return "".join(f"# {line}\n" for line in HEADER_LINES) + raise ValueError(f"unknown comment style: {style}") +``` + +- [ ] **Step 4: Run to verify pass** + +```bash +python3 -m pytest tests/test_license_headers.py::test_render_header_hash -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/license-headers.py tests/test_license_headers.py +git commit -m "$(cat < +EOF +)" +``` + +--- + +## Task 3: `render_header()` for slash, block, and HTML styles + +**Files:** +- Modify: `scripts/license-headers.py` +- Modify: `tests/test_license_headers.py` + +- [ ] **Step 1: Write three failing tests** + +Append to `tests/test_license_headers.py`: + +```python +EXPECTED_SLASH_HEADER = ( + "// SPDX-License-Identifier: LicenseRef-CMSD-1.0\n" + "// Copyright (c) 2026 CyberMind — Gérald Kerma \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 \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" +) + + +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 +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +python3 -m pytest tests/test_license_headers.py -k "render_header_slash or render_header_block or render_header_html" -v +``` + +Expected: 3 FAILs with `ValueError: unknown comment style`. + +- [ ] **Step 3: Extend `render_header()`** + +In `scripts/license-headers.py`, replace `render_header` with: + +```python +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" + raise ValueError(f"unknown comment style: {style}") +``` + +- [ ] **Step 4: Run to verify pass** + +```bash +python3 -m pytest tests/test_license_headers.py -v +``` + +Expected: 5 passed (test_module_imports + 4 render tests). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/license-headers.py tests/test_license_headers.py +git commit -m "$(cat < +EOF +)" +``` + +--- + +## Task 4: `detect_existing()` — MATCH / FOREIGN / NONE + +**Files:** +- Modify: `scripts/license-headers.py` +- Modify: `tests/test_license_headers.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_license_headers.py`: + +```python +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\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" +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +python3 -m pytest tests/test_license_headers.py -k "detect_existing" -v +``` + +Expected: 9 FAILs. + +- [ ] **Step 3: Implement `detect_existing()`** + +Add to `scripts/license-headers.py`, near the top: + +```python +import re + +_SPDX_RE = re.compile(r"SPDX-License-Identifier:\s*(\S+)") +_CMSD_ID = "LicenseRef-CMSD-1.0" + + +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" +``` + +- [ ] **Step 4: Run to verify pass** + +```bash +python3 -m pytest tests/test_license_headers.py -v +``` + +Expected: all tests pass (5 prior + 9 new = 14). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/license-headers.py tests/test_license_headers.py +git commit -m "$(cat < +EOF +)" +``` + +--- + +## Task 5: `LANG_TABLE` + `apply()` for plain Python + +**Files:** +- Modify: `scripts/license-headers.py` +- Modify: `tests/test_license_headers.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_license_headers.py`: + +```python +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 +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +python3 -m pytest tests/test_license_headers.py -k "apply_python or apply_foreign" -v +``` + +Expected: 3 FAILs with `AttributeError: ... 'apply'`. + +- [ ] **Step 3: Implement `LANG_TABLE` and `apply()` for `.py`** + +Add to `scripts/license-headers.py`, above `main()`: + +```python +# Maps file extension → (comment_style, placement_handler_name) +LANG_TABLE: dict[str, tuple[str, str]] = { + ".py": ("hash", "python"), +} + + +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) on first or second line + 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:]) + + +_PLACERS = { + "python": _place_python, +} + + +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) +``` + +- [ ] **Step 4: Run to verify pass** + +```bash +python3 -m pytest tests/test_license_headers.py -v +``` + +Expected: 17 passed. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/license-headers.py tests/test_license_headers.py +git commit -m "$(cat < +EOF +)" +``` + +--- + +## Task 6: `apply()` Python placement — shebang and encoding declaration + +**Files:** +- Modify: `tests/test_license_headers.py` + +The implementation in Task 5 already handles shebang and encoding declarations. This task is **verification tests only**. + +- [ ] **Step 1: Write the placement tests** + +Append to `tests/test_license_headers.py`: + +```python +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" +``` + +- [ ] **Step 2: Run** + +```bash +python3 -m pytest tests/test_license_headers.py -k "shebang or encoding" -v +``` + +Expected: 3 passed. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_license_headers.py +git commit -m "$(cat < +EOF +)" +``` + +--- + +## Task 7: `apply()` for Bash (shebang-aware) + +**Files:** +- Modify: `scripts/license-headers.py` +- Modify: `tests/test_license_headers.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_license_headers.py`: + +```python +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 +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +python3 -m pytest tests/test_license_headers.py -k "apply_bash" -v +``` + +Expected: 3 FAILs (since `.sh` not yet in `LANG_TABLE`, `apply` returns input unchanged). + +- [ ] **Step 3: Add Bash to `LANG_TABLE` and reuse the Python placer** + +In `scripts/license-headers.py`: + +```python +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:]) +``` + +Rename `_place_python` so the shebang+encoding logic stays Python-specific, and wire Bash to the new helper: + +```python +LANG_TABLE: dict[str, tuple[str, str]] = { + ".py": ("hash", "python"), + ".sh": ("hash", "shebang_hash"), +} + +_PLACERS = { + "python": _place_python, + "shebang_hash": _place_shebang_hash, +} +``` + +- [ ] **Step 4: Run to verify pass** + +```bash +python3 -m pytest tests/test_license_headers.py -v +``` + +Expected: 23 passed. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/license-headers.py tests/test_license_headers.py +git commit -m "$(cat < +EOF +)" +``` + +--- + +## Task 8: `apply()` for JS/TS, CSS, C/H + +**Files:** +- Modify: `scripts/license-headers.py` +- Modify: `tests/test_license_headers.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_license_headers.py`: + +```python +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}" +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +python3 -m pytest tests/test_license_headers.py -k "apply_javascript or apply_typescript or apply_css or apply_c or idempotent_all" -v +``` + +Expected: 5 FAILs. + +- [ ] **Step 3: Extend `LANG_TABLE` and add a `top` placer** + +In `scripts/license-headers.py`: + +```python +def _place_top(header: str, text: str) -> str: + """Insert header at line 1 with one blank line separator.""" + return header + "\n" + text + + +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"), +} + +_PLACERS = { + "python": _place_python, + "shebang_hash": _place_shebang_hash, + "top": _place_top, +} +``` + +- [ ] **Step 4: Run to verify pass** + +```bash +python3 -m pytest tests/test_license_headers.py -v +``` + +Expected: 28 passed. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/license-headers.py tests/test_license_headers.py +git commit -m "$(cat < +EOF +)" +``` + +--- + +## Task 9: `apply()` for HTML (doctype) and Markdown (frontmatter) + +**Files:** +- Modify: `scripts/license-headers.py` +- Modify: `tests/test_license_headers.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_license_headers.py`: + +```python +def test_apply_html_no_doctype(): + src = 'hi\n' + out = license_headers.apply(src, ".html") + assert out == EXPECTED_HTML_HEADER + "\n" + src + + +def test_apply_html_with_doctype(): + src = '\nhi\n' + out = license_headers.apply(src, ".html") + lines = out.splitlines(keepends=True) + assert lines[0] == "\n" + assert lines[1] == "`. + +- [ ] **Step 5: Foreign SPDX is skipped** + +```bash +cat > /tmp/mit_test.js <<'EOF' +// SPDX-License-Identifier: MIT +const x = 1; +EOF +python3 -c " +import sys; sys.path.insert(0, 'scripts') +import importlib; m = importlib.import_module('license-headers') +src = open('/tmp/mit_test.js').read() +print('detect:', m.detect_existing(src)) +print('apply unchanged:', m.apply(src, '.js') == src) +" +rm /tmp/mit_test.js +``` + +Expected: `detect: FOREIGN`, `apply unchanged: True`. + +- [ ] **Step 6: Idempotence on the tool itself** + +```bash +python3 scripts/license-headers.py --fix scripts/license-headers.py +git diff --stat scripts/license-headers.py +``` + +Expected: no diff (tool already has its own header from Task 12). + +- [ ] **Step 7: All unit tests pass** + +```bash +python3 -m pytest tests/test_license_headers.py -v +``` + +Expected: 48 passed. + +- [ ] **Step 8: No commit needed** — smoke tests only. + +--- + +## Task 17: Update Issue and open Phase A PR + +**Files:** none. + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin HEAD +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "feat(license): CMSD-1.0 header tool, CI, and conventions (Phase A)" \ + --body "$(cat < +python3 scripts/license-headers.py --fix packages/secubox- +echo "packages/secubox-/**" >> scripts/license-headers-enrolled.txt +git add -A +git commit -m "chore(license): enroll secubox- in CMSD header check (ref #$ISSUE)" +gh pr create --title "chore(license): enroll secubox-" --body "..." +``` + +Phase C is one PR that deletes `scripts/license-headers-enrolled.txt` and adds a note to `LICENSING.md`. + +--- + +## Self-review notes (post-write) + +1. **Spec coverage:** §2 scope → Task 5-9. §3 header content → Task 2-3. §3.2 placement → Task 5-9 placers. §4 tool → Task 5-11. §4.4 6 test cases → all covered (idempotence, shebang/doctype/frontmatter preservation, foreign skip, no duplication, --check exit code, skip-dir pruning). §5 CI → Task 13 + 12 (allowlist). §6.1 Phase A deliverables → all 7 covered. §7.1 smoke tests → Task 16. +2. **Type consistency:** `apply(text, ext)`, `detect_existing(text)`, `render_header(style)`, `walk(paths, enrolled)`, `main(argv)`, `_PLACERS`, `LANG_TABLE` — names match across tasks. Comment styles `"hash"/"slash"/"block"/"html"` consistent. Placer names `"python"/"shebang_hash"/"top"/"html"/"markdown"` consistent. +3. **No placeholders:** every code/test block is concrete and runnable. No TODO/TBD. + +## Execution Handoff + +Plan complete and saved to [docs/superpowers/plans/2026-05-12-license-headers.md](docs/superpowers/plans/2026-05-12-license-headers.md). diff --git a/docs/superpowers/specs/2026-05-12-license-headers-design.md b/docs/superpowers/specs/2026-05-12-license-headers-design.md new file mode 100644 index 00000000..e6f64b98 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-license-headers-design.md @@ -0,0 +1,270 @@ +# Design — CMSD-1.0 License Headers Across the Codebase + +**Date:** 2026-05-12 +**Status:** Approved by user (sections 1–5), pending plan +**Author:** Claude (brainstormed with @CyberMind-FR) +**Tracking:** GitHub Issue TBD (created at start of Phase A) + +--- + +## 1. Goal + +Add the CyberMind Source-Disclosed License v1.0 (CMSD-1.0) SPDX header to every first-party source file in the `secubox-deb` repository, and keep it that way going forward with a CI check. + +Today the canonical header template lives in `LICENSING.md` (lines 70–75) but **zero of ~2,170 first-party code files** carry it. This design closes that gap mechanically, idempotently, and reviewably. + +## 2. Scope + +### 2.1 File types in scope + +| Extension(s) | Comment style | Count (approx.) | +|---|---|---| +| `.py` | `#` line comments | 865 | +| `.sh` | `#` line comments | 122 | +| `.js`, `.mjs`, `.ts` | `//` line comments | 613 | +| `.c`, `.h` | `/* ... */` block (C89-safe) | 86 | +| `.css` | `/* ... */` block | 100 | +| `.html` | `` block | 384 | +| `.md` | `` block | (many) | +| `.toml`, `.yaml`, `.yml`, `.conf` | `#` line comments | (some) | + +JSON is **out of scope** (no comment syntax). + +### 2.2 Paths excluded (skip-list) + +Hard-coded directory prefixes the walker never descends into: + +- `kernel-build/`, `redroid/`, `tools/Tow-Boot/` — vendor/upstream code with its own licenses +- `output/`, `cache/`, `backups/`, `apt/`, `repo/` — generated/build artifacts +- `node_modules/`, `.venv/`, `.git/`, `__pycache__/`, `dist/`, `build/` — tool-managed trees + +Glob excludes: + +- `*.min.js`, `*.min.css` — minified bundles +- `package-lock.json`, `*.lock` — lockfiles +- Any file whose first 10 lines already contain `SPDX-License-Identifier: ` followed by **something other than** `LicenseRef-CMSD-1.0` → skipped with a warning to stderr (do not overwrite third-party licenses) + +### 2.3 Out of scope (explicitly) + +- Modifying the CMSD-1.0 license terms themselves +- Adding the header to the LICENSE files (they already define the license) +- Re-licensing any third-party code +- A "remove header" mode — not needed + +## 3. Header content + +The canonical 4-line header, parameterized by language comment marker (``): + +``` + SPDX-License-Identifier: LicenseRef-CMSD-1.0 + Copyright (c) 2026 CyberMind — Gérald Kerma + Source-Disclosed License — All rights reserved except as expressly granted. + See LICENCE-CMSD-1.0.md for terms. +``` + +Followed by **one blank line**, then the file's pre-existing content. + +### 3.1 Per-language rendering + +| Language | Rendered as | +|---|---| +| Python, Bash, YAML, TOML, conf | Four `# ` prefixed lines | +| JS, TS, MJS | Four `// ` prefixed lines | +| C, H | Single `/* ... */` block, internal lines prefixed ` * ` | +| CSS | Single `/* ... */` block, internal lines prefixed ` * ` | +| HTML, Markdown | Single `` block, internal lines indented 2 spaces | + +### 3.2 Placement rules + +The header MUST appear at the top of the file, with the following exceptions: + +| File type | Header goes... | +|---|---| +| Python with `#!` shebang | Immediately after the shebang line | +| Python with `# -*- coding: ... -*-` | Immediately after the encoding declaration | +| Bash with `#!` shebang | Immediately after the shebang line | +| JS/TS with `"use strict"` or top-level directive | Above the directive (header at line 1) | +| HTML with `` | Immediately after the doctype, on its own line | +| Markdown with YAML frontmatter `---` | Immediately after the closing `---` | +| All others | Line 1 | + +## 4. The tool — `scripts/license-headers.py` + +Single-file Python 3.11+ utility, **stdlib only** (`argparse`, `pathlib`, `re`, `sys`). + +### 4.1 CLI + +``` +license-headers.py --check [PATHS...] # exit 0 if all good, 1 if missing +license-headers.py --fix [PATHS...] # add headers in place; idempotent +license-headers.py --list [PATHS...] # dry-run: list files that would be modified +license-headers.py --diff [PATHS...] # unified diff per file, no writes +``` + +- Default `PATHS` = repo root (cwd-relative). +- The repo root is detected by walking up from cwd to the nearest `.git/`. +- Mode flags are mutually exclusive; exactly one is required. +- During `--check`, the enrollment allowlist (see §5) constrains which paths are evaluated. + +### 4.2 Internal structure (~300 LOC, one file) + +| Component | Responsibility | +|---|---| +| `SKIP_DIRS: frozenset[str]` | Dirnames the walker prunes (§2.2 directory list) | +| `SKIP_GLOBS: tuple[str, ...]` | Glob patterns for excluded files | +| `LANG_TABLE: dict[str, LangSpec]` | Maps `.ext` → `(comment_style, placement_rule, header_renderer)` | +| `detect_existing(text) -> Status` | Returns `MATCH` / `FOREIGN` / `NONE` based on first 10 lines | +| `render_header(comment_style) -> str` | Builds the 4-line block for one language | +| `apply(text, ext) -> str` | Pure function: input file content → output content with header inserted at correct position | +| `walk(paths, enrolled) -> Iterator[Path]` | Yields in-scope files, honoring skip-list and enrollment allowlist | +| `main()` | Argparse dispatch | + +### 4.3 Idempotence contract + +``` +apply(apply(text, ext), ext) == apply(text, ext) +``` + +Explicitly tested. Detection is based on matching the literal token `SPDX-License-Identifier: LicenseRef-CMSD-1.0` anywhere in the first 10 lines, tolerant of comment-marker variation and whitespace. + +### 4.4 Tests — `tests/test_license_headers.py` (pytest) + +1. `apply` is idempotent for every extension in `LANG_TABLE`. +2. Shebang, doctype, frontmatter, and encoding declarations are preserved in their original position. +3. Foreign SPDX (e.g., `GPL-2.0-or-later`, `MIT`) → file returned unchanged; warning emitted. +4. Existing CMSD header is not duplicated, regardless of comment style variation. +5. `--check` exits 1 when any in-scope file lacks the header. +6. `SKIP_DIRS` are pruned: a file under `kernel-build/` is never yielded by `walk`. + +All tests run without filesystem side effects (use `tmp_path` fixtures and string inputs to `apply`). + +## 5. CI integration + +### 5.1 Workflow — `.github/workflows/license-check.yml` + +```yaml +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' } + - run: python3 scripts/license-headers.py --check +``` + +No `pip install`; runs in <10s. + +### 5.2 Enrollment allowlist + +To avoid breaking CI before Phase B is complete, the `--check` mode reads an **enrollment allowlist** at `scripts/license-headers-enrolled.txt`: + +- One glob pattern per line (e.g., `common/**`, `packages/secubox-hub/**`). +- Lines starting with `#` are comments. +- Empty/missing file → nothing is enforced (Phase A initial state). +- File contains `**` only → repo-wide enforcement (Phase C final state). +- File deleted at end of Phase C; tool then defaults to repo-wide. + +Phase B PRs each add one line to this file as they enroll a path. + +### 5.3 Optional pre-commit hook + +Documented in `scripts/README.md`; not installed 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 +``` + +## 6. Rollout + +### 6.1 Phase A — Foundation (1 PR, GitHub Issue, label: `infra`) + +Deliverables: +1. `scripts/license-headers.py` (the tool) +2. `tests/test_license_headers.py` (unit tests) +3. `.github/workflows/license-check.yml` (CI) +4. `scripts/license-headers-enrolled.txt` (initial empty enrollment) +5. `scripts/README.md` updated to document the tool and pre-commit hook +6. `CLAUDE.md` Python and Bash convention examples updated to show the SPDX block above the existing docstring/shebang preamble (replaces the current "License: Proprietary / ANSSI CSPN candidate" line in the Python example) +7. The tool's own files (`license-headers.py`, test file, workflow) carry the CMSD header (sanity check of `--fix`) + +CI passes because the enrollment allowlist is empty. + +### 6.2 Phase B — Per-package enrollment (~16 PRs, label: `migration`) + +One PR per scope, each PR: +1. Runs `python3 scripts/license-headers.py --fix ` +2. Adds `/**` to `scripts/license-headers-enrolled.txt` +3. Commits with message `chore(license): enroll in CMSD header check (ref #NNN)` +4. PR description summarizes file counts touched + +PR list: +- 14 PRs for `packages/secubox-*` (one per package) +- 1 PR for `common/` + `api/` +- 1 PR for `scripts/` + `image/` + `board/` + top-level files (`*.py`, `*.sh` at root) +- 1 PR for `docs/` + `.claude/` + Markdown files repo-wide +- (Optional) 1 PR for commentable configs (`*.toml`, `*.yaml`, `*.conf` outside packages already covered) + +### 6.3 Phase C — Closure (1 PR, label: `infra`) + +1. Delete `scripts/license-headers-enrolled.txt` +2. Update `LICENSING.md` with a note: *"All source files carry the CMSD-1.0 SPDX header; see `scripts/license-headers.py`."* +3. Run `--check` repo-wide locally before merging. + +## 7. Verification + +### 7.1 Phase A smoke tests (before merging the PR) + +| # | Command | Expected | +|---|---|---| +| 1 | `python3 scripts/license-headers.py --diff common/secubox_core/auth.py` | Clean 5-line addition above the docstring | +| 2 | `python3 scripts/license-headers.py --diff scripts/deploy.sh` | Header lands after `#!/usr/bin/env bash` | +| 3 | `python3 scripts/license-headers.py --diff packages/secubox-hub/www/index.html` | Header lands after `` | +| 4 | Run `--fix` on a file, then `--fix` again | Second run produces no changes (idempotence) | +| 5 | Create temp file with `# SPDX-License-Identifier: MIT` | Tool skips with stderr warning | +| 6 | `pytest tests/test_license_headers.py` | All 6 tests pass | + +### 7.2 Per-PR verification in Phase B + +1. Reviewer eyeballs ~5 randomly-sampled files in the PR diff for correct placement. +2. For Python packages with existing `pytest` suites: run the package's tests after applying headers — confirms no syntactic breakage. +3. For Bash scripts touched: `bash -n ` for each, no syntax errors. +4. CI's `--check` step passes (with the newly enrolled path). + +### 7.3 Phase C verification + +1. Locally: with the allowlist file removed, `python3 scripts/license-headers.py --check` exits 0. +2. Create a deliberately header-less throwaway file → CI fails as expected → remove it before merging. + +## 8. Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Header insertion breaks file syntax (e.g., HTML doctype, Bash shebang) | Placement rules in §3.2; tested case-by-case in §4.4 | +| Third-party file accidentally gets re-licensed | Foreign SPDX detection skips and warns (§2.2) | +| Future contributors forget headers | CI check on every PR (§5.1) | +| Tool itself has bugs that corrupt files | Pure `apply()` function fully unit-tested; `--diff` mode lets reviewers eyeball before any write | +| OpenWrt frontend parity diffs grow noisy | Acceptable trade-off accepted in clarifying questions; mitigated by per-package PR shape | +| Vendor/build trees accidentally enrolled | `SKIP_DIRS` enforced even when `**` is in the allowlist | + +## 9. Open questions + +None at design time. Any deviations during implementation should be raised as comments on the tracking GitHub Issue. + +## 10. References + +- `LICENSING.md` — canonical license summary and header template +- `LICENCE-CMSD-1.0.md` (FR, authoritative) / `LICENSE-CMSD-1.0.en.md` (EN, informative) +- `CLAUDE.md` — project conventions (to be updated in Phase A) +- REUSE specification: (informational; we use a custom `LicenseRef-` SPDX identifier) From c7166597f52b533450c1ee171cb0f285219ed42d Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 12 May 2026 09:26:37 +0200 Subject: [PATCH 02/19] chore(license): scaffold license-headers tool and test module (ref #81) Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/license-headers.py | 18 ++++++++++++++++++ tests/test_license_headers.py | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 scripts/license-headers.py create mode 100644 tests/test_license_headers.py diff --git a/scripts/license-headers.py b/scripts/license-headers.py new file mode 100644 index 00000000..290adb25 --- /dev/null +++ b/scripts/license-headers.py @@ -0,0 +1,18 @@ +# 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 sys +from pathlib import Path + + +def main(argv: list[str] | None = None) -> int: + raise NotImplementedError("see Task 9") + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py new file mode 100644 index 00000000..e0d782d6 --- /dev/null +++ b/tests/test_license_headers.py @@ -0,0 +1,21 @@ +# 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") From bd7975d12f787fcc27386539212335f7d7d7b5f1 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 12 May 2026 09:33:03 +0200 Subject: [PATCH 03/19] chore(license): fix NotImplementedError task reference (ref #81) Per code review: main() is implemented in Task 11 (CLI dispatch), not Task 9. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/license-headers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/license-headers.py b/scripts/license-headers.py index 290adb25..05f36349 100644 --- a/scripts/license-headers.py +++ b/scripts/license-headers.py @@ -11,7 +11,7 @@ from pathlib import Path def main(argv: list[str] | None = None) -> int: - raise NotImplementedError("see Task 9") + raise NotImplementedError("see Task 11") if __name__ == "__main__": From f08eb38073b59315ddf8e5e4a9bb5f5a4f1c1c5a Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 12 May 2026 09:34:43 +0200 Subject: [PATCH 04/19] feat(license): render_header for hash-comment languages (ref #81) Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/license-headers.py | 14 ++++++++++++++ tests/test_license_headers.py | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/scripts/license-headers.py b/scripts/license-headers.py index 05f36349..5202d7c7 100644 --- a/scripts/license-headers.py +++ b/scripts/license-headers.py @@ -10,6 +10,20 @@ import sys from pathlib import Path +HEADER_LINES = ( + "SPDX-License-Identifier: LicenseRef-CMSD-1.0", + "Copyright (c) 2026 CyberMind — Gérald Kerma ", + "Source-Disclosed License — All rights reserved except as expressly granted.", + "See LICENCE-CMSD-1.0.md for terms.", +) + + +def render_header(style: str) -> str: + if style == "hash": + return "".join(f"# {line}\n" for line in HEADER_LINES) + raise ValueError(f"unknown comment style: {style}") + + def main(argv: list[str] | None = None) -> int: raise NotImplementedError("see Task 11") diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py index e0d782d6..c3188ba0 100644 --- a/tests/test_license_headers.py +++ b/tests/test_license_headers.py @@ -19,3 +19,15 @@ _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 \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 From 70ac535e681e4fa201b499b6eb8a3b1fb3e04fde Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 12 May 2026 09:38:20 +0200 Subject: [PATCH 05/19] feat(license): render_header for slash/block/html comment styles (ref #81) Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/license-headers.py | 8 ++++++++ tests/test_license_headers.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/scripts/license-headers.py b/scripts/license-headers.py index 5202d7c7..d35e541e 100644 --- a/scripts/license-headers.py +++ b/scripts/license-headers.py @@ -21,6 +21,14 @@ HEADER_LINES = ( 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" raise ValueError(f"unknown comment style: {style}") diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py index c3188ba0..10b7c912 100644 --- a/tests/test_license_headers.py +++ b/tests/test_license_headers.py @@ -31,3 +31,41 @@ EXPECTED_HASH_HEADER = ( 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 \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 \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" +) + + +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 From 5b4d3e25fb7dc2538e60a5d81f72a1734831ae79 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 12 May 2026 09:41:04 +0200 Subject: [PATCH 06/19] feat(license): detect_existing distinguishes CMSD/foreign/none (ref #81) Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/license-headers.py | 14 +++++++++++ tests/test_license_headers.py | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/scripts/license-headers.py b/scripts/license-headers.py index d35e541e..12c2dcf1 100644 --- a/scripts/license-headers.py +++ b/scripts/license-headers.py @@ -6,6 +6,7 @@ on every first-party source file. See docs/superpowers/specs/2026-05-12-license- """ from __future__ import annotations +import re import sys from pathlib import Path @@ -18,6 +19,19 @@ HEADER_LINES = ( ) +_SPDX_RE = re.compile(r"SPDX-License-Identifier:\s*(\S+)") +_CMSD_ID = "LicenseRef-CMSD-1.0" + + +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" + + def render_header(style: str) -> str: if style == "hash": return "".join(f"# {line}\n" for line in HEADER_LINES) diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py index 10b7c912..02192e1e 100644 --- a/tests/test_license_headers.py +++ b/tests/test_license_headers.py @@ -69,3 +69,47 @@ def test_render_header_block(): 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\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" From 63e62c1d54836476fb17b4f76634a064b1cba880 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 12 May 2026 09:45:34 +0200 Subject: [PATCH 07/19] feat(license): apply() for plain Python, with idempotence and foreign-skip (ref #81) Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/license-headers.py | 38 +++++++++++++++++++++++++++++++++++ tests/test_license_headers.py | 19 ++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/scripts/license-headers.py b/scripts/license-headers.py index 12c2dcf1..3a82d619 100644 --- a/scripts/license-headers.py +++ b/scripts/license-headers.py @@ -46,6 +46,44 @@ def render_header(style: str) -> str: raise ValueError(f"unknown comment style: {style}") +LANG_TABLE: dict[str, tuple[str, str]] = { + ".py": ("hash", "python"), +} + + +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:]) + + +_PLACERS = { + "python": _place_python, +} + + +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 main(argv: list[str] | None = None) -> int: raise NotImplementedError("see Task 11") diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py index 02192e1e..32bb3807 100644 --- a/tests/test_license_headers.py +++ b/tests/test_license_headers.py @@ -113,3 +113,22 @@ 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_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 From 9b0fce3faccbc52c8b22770a016bb7f8fbf92cbd Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 12 May 2026 09:47:43 +0200 Subject: [PATCH 08/19] test(license): cover Python shebang and encoding-declaration placement (ref #81) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_license_headers.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py index 32bb3807..8a57ac91 100644 --- a/tests/test_license_headers.py +++ b/tests/test_license_headers.py @@ -132,3 +132,26 @@ 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" From 6474728dc41dc0e3dcbb6cd67c1bcac4eacb0953 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 12 May 2026 09:48:43 +0200 Subject: [PATCH 09/19] feat(license): apply() for Bash with shebang-aware placement (ref #81) Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/license-headers.py | 9 +++++++++ tests/test_license_headers.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/scripts/license-headers.py b/scripts/license-headers.py index 3a82d619..b9dd5a26 100644 --- a/scripts/license-headers.py +++ b/scripts/license-headers.py @@ -48,6 +48,7 @@ def render_header(style: str) -> str: LANG_TABLE: dict[str, tuple[str, str]] = { ".py": ("hash", "python"), + ".sh": ("hash", "shebang_hash"), } @@ -63,8 +64,16 @@ def _place_python(header: str, text: str) -> str: 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:]) + + _PLACERS = { "python": _place_python, + "shebang_hash": _place_shebang_hash, } diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py index 8a57ac91..a9dcf7de 100644 --- a/tests/test_license_headers.py +++ b/tests/test_license_headers.py @@ -155,3 +155,23 @@ def test_apply_python_with_shebang_and_encoding(): 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 From ada50406933b3ef86d06ced57177a1606b4930fe Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 12 May 2026 09:49:28 +0200 Subject: [PATCH 10/19] feat(license): apply() for JS/TS/CSS/C/H with idempotence (ref #81) Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/license-headers.py | 12 +++++++++++ tests/test_license_headers.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/scripts/license-headers.py b/scripts/license-headers.py index b9dd5a26..967057f7 100644 --- a/scripts/license-headers.py +++ b/scripts/license-headers.py @@ -49,6 +49,12 @@ def render_header(style: str) -> str: 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"), } @@ -71,9 +77,15 @@ def _place_shebang_hash(header: str, text: str) -> str: 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 + + _PLACERS = { "python": _place_python, "shebang_hash": _place_shebang_hash, + "top": _place_top, } diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py index a9dcf7de..85faa7d7 100644 --- a/tests/test_license_headers.py +++ b/tests/test_license_headers.py @@ -175,3 +175,41 @@ def test_apply_bash_idempotent(): 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}" From 2bd965f4810fed46a8ed24a76fa2db4fa7e3eca0 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 12 May 2026 09:50:48 +0200 Subject: [PATCH 11/19] feat(license): apply() for HTML (doctype) and Markdown (frontmatter), plus TOML/YAML/conf (ref #81) Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/license-headers.py | 29 ++++++++++++++++++++++ tests/test_license_headers.py | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/scripts/license-headers.py b/scripts/license-headers.py index 967057f7..e3f83359 100644 --- a/scripts/license-headers.py +++ b/scripts/license-headers.py @@ -55,6 +55,13 @@ LANG_TABLE: dict[str, tuple[str, str]] = { ".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"), } @@ -82,10 +89,32 @@ def _place_top(header: str, text: str) -> str: 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(" 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, } diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py index 85faa7d7..35eb802c 100644 --- a/tests/test_license_headers.py +++ b/tests/test_license_headers.py @@ -213,3 +213,48 @@ def test_apply_idempotent_all_styles(): 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 = 'hi\n' + out = license_headers.apply(src, ".html") + assert out == EXPECTED_HTML_HEADER + "\n" + src + + +def test_apply_html_with_doctype(): + src = '\nhi\n' + out = license_headers.apply(src, ".html") + lines = out.splitlines(keepends=True) + assert lines[0] == "\n" + assert lines[1] == ") 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