diff --git a/.github/scripts/reuse_gate.py b/.github/scripts/reuse_gate.py index ef2487c9..172a74c0 100644 --- a/.github/scripts/reuse_gate.py +++ b/.github/scripts/reuse_gate.py @@ -22,6 +22,18 @@ here. If the maintainers later decide to prune the catalog down to only the licenses in use, delete the TOLERATED set below and this script becomes a straight `reuse lint`. + +Why the inline-header check +--------------------------- +`REUSE.toml` annotates whole trees (`core/**`, `web/apps/**`, `web/packages/**` +and so on), so plain `reuse lint` already counts every file as licensed +whether or not it carries the matching identifier and copyright lines +inline. That means a new file with no header at all is still reported as +compliant, which silently breaks the house rule (BRIEF-COMMON: every new +file carries the SPDX header its directory's licence row in LICENSE-MAP.md +gives). This gate therefore runs its own second pass over the tracked source +files and fails when a file in a language extension is missing one or both +inline header lines. """ from __future__ import annotations @@ -47,6 +59,49 @@ "missing_copyright_info": "Files with no copyright information", "missing_licensing_info": "Files with no licensing information", "invalid_spdx_expressions": "Files with an unparseable SPDX expression", + "missing_inline_header": ( + "Files with no inline SPDX header (an SPDX-License-Identifier and " + "SPDX-FileCopyrightText pair in the first ten lines)" + ), +} + +# File extensions whose tracked files must carry the inline SPDX header. +# The list mirrors the source kinds LICENSE-MAP.md places in scope: +# Go under core/, SQL under core/, TypeScript and JSX/TSX under web/, +# Python under .github/ and core/, shell at the repo root, Rust under +# tauri/, and CSS under web/apps/. +INLINE_CHECK_EXTENSIONS: tuple[str, ...] = ( + ".go", + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".sql", + ".py", + ".sh", + ".rs", + ".css", +) + +# The literal header tags, composed at runtime so they do not appear as a +# single substring anywhere outside the SPDX header comment at the top of +# this file. reuse scans every line for the literal "SPDX-License-Identifier" +# pattern and would otherwise try to parse these strings as SPDX expressions. +_LICENSE_TAG = "SPDX-" + "License-Identifier:" +_COPYRIGHT_TAG = "SPDX-" + "FileCopyrightText:" + +# Tracked files that look like source but are generated and therefore cannot +# carry an inline header: the generator owns the whole file and would +# overwrite any header on every regeneration. The REUSE.toml tree annotation +# for the file's directory supplies the licence instead. Each entry pairs a +# repo-relative path with a short reason that explains *why* it is exempt. +INLINE_EXEMPT: dict[str, str] = { + "web/packages/api-client/src/schema.d.ts": ( + "generated by `npm run generate` (openapi-typescript), lives under " + "the web/packages/** annotation in REUSE.toml" + ), } @@ -71,6 +126,48 @@ def invalid_expressions(report: dict) -> list[str]: return found +def missing_inline_headers(git_root: str) -> list[str]: + """Tracked source files missing the inline SPDX header. + + The header is the same pair `reuse lint` looks for, an identifier + line and a copyright line, appearing somewhere in the first ten lines + of the file. Files in `INLINE_EXEMPT` are excluded, and files outside + `INLINE_CHECK_EXTENSIONS` are ignored (the inline header is a house rule + for source code, not a requirement for every file in the tree). + """ + try: + tracked = subprocess.check_output( + ["git", "-C", git_root, "ls-files"], + text=True, + ).splitlines() + except (subprocess.CalledProcessError, FileNotFoundError): + return [""] + + missing: list[str] = [] + for path in tracked: + if not path.endswith(INLINE_CHECK_EXTENSIONS): + continue + if path in INLINE_EXEMPT: + continue + full_path = os.path.join(git_root, path) + try: + with open(full_path, "r", encoding="utf-8", errors="replace") as fh: + head = "".join(fh.readline() for _ in range(10)) + except OSError as exc: + missing.append(f"{path}: ") + continue + has_id = _LICENSE_TAG in head + has_cr = _COPYRIGHT_TAG in head + if not (has_id and has_cr): + pieces = [] + if not has_id: + pieces.append("identifier line") + if not has_cr: + pieces.append("copyright line") + missing.append(f"{path}: missing { ' and '.join(pieces) }") + return missing + + def emit(lines: list[str]) -> None: """Print to stdout and, on GitHub Actions, to the job step summary.""" text = "\n".join(lines) @@ -110,6 +207,17 @@ def main() -> int: # Not reported under non_compliant by reuse 6.2 -- see invalid_expressions(). non_compliant["invalid_spdx_expressions"] = invalid_expressions(report) + # Inline-header pass (see the module docstring for the why). Scans + # the git repository root rather than the current directory so the gate + # behaves the same wherever it is invoked from; reuse lint itself only + # cares about the REUSE.toml annotations, so it cannot see a missing + # inline header. + git_root = subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + text=True, + ).strip() + non_compliant["missing_inline_header"] = missing_inline_headers(git_root) + failures = { key: value for key, value in non_compliant.items() diff --git a/.github/scripts/test_reuse_gate.py b/.github/scripts/test_reuse_gate.py new file mode 100644 index 00000000..c34b296a --- /dev/null +++ b/.github/scripts/test_reuse_gate.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LicenseRef-OpenLBM-Docs-1.0 +# SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors +"""Unit tests for reuse_gate.py. + +The inline-header check looks at every git-tracked file whose name ends in +one of the language extensions in `INLINE_CHECK_EXTENSIONS` and reports the +ones that do not carry an identifier and a copyright line in their first ten +lines. The gate tolerates `INLINE_EXEMPT` entries (generated files the +generator owns end-to-end). + +Two kinds of tests live here: + +1. `InlineHeaderCheck`: a throwaway git repository is built under + `tempfile.mkdtemp`, files are committed, and `missing_inline_headers` is + called against that repo's root. These tests do not depend on the working + tree the tests run from, and they do not touch the host's git config + (a `local-user.email`/`local-user.name` pair is set on the throwaway + repo only, never on the caller). + +2. `MainSubprocessCheck`: the gate script itself is launched as a subprocess + with `REUSE_BIN` pointed at a tiny stub. A throwaway repo with a + headerless file fails the gate (exit 1) and the summary names the file; + a clean throwaway repo passes it (exit 0). Without these tests, deleting + the inline-header call from `main()` would still leave the suite green. + +CI runs these tests with the same command a developer uses locally: +``python3 -m unittest discover -s .github/scripts -p test_reuse_gate.py``. +A bare `python3 -m unittest discover` from the repo root skips `.github/`, +which is not an importable package, and reports `NO TESTS RAN`. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import textwrap +import unittest + +# Make sure we can import reuse_gate.py next to this test file. The +# `scripts/` directory is on $PATH for the actual gate run, but it is not on +# `sys.path` for plain `python -m unittest`. +HERE = os.path.dirname(os.path.abspath(__file__)) +if HERE not in sys.path: + sys.path.insert(0, HERE) + +import reuse_gate # noqa: E402 (sys.path tweak above is intentional) + +# The literal header tags reuse itself looks for, composed at runtime so the +# strings never appear as a contiguous substring in this file's source. reuse +# would otherwise scrape these as if they were the file's own SPDX +# expressions. The leading prefix breaks the substring so the regex never +# matches. +_LIC = "SPDX-" + "License-Identifier: " + "LicenseRef-Test-1.0" +_CR = "SPDX-" + "FileCopyrightText: 2026 Fixture" + + +def _git(cwd: str, *args: str) -> None: + """Run a git command in `cwd` and raise on non-zero exit.""" + subprocess.check_call(["git", "-C", cwd, *args]) + + +def _init_repo(tmp: str) -> None: + """Initialise `tmp` as a throwaway git repository with a local identity.""" + _git(tmp, "init", "-q") + # The throwaway repo never inherits the host's identity: its commits are + # local to the test fixture and never leave this directory. + _git(tmp, "config", "user.email", "test@example.invalid") + _git(tmp, "config", "user.name", "REUSE gate test") + + +def _write(tmp: str, relpath: str, body: str) -> None: + path = os.path.join(tmp, relpath) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(body) + _git(tmp, "add", "--", relpath) + + +def _commit(tmp: str) -> None: + _git(tmp, "-c", "commit.gpgsign=false", "commit", "-q", "-m", "fixture") + + +def _make_stub_reuse(tmp: str) -> str: + """Write a tiny shell script that pretends to be `reuse lint --json`. + + The stub prints a minimal clean REUSE report (zero non-compliant files) + so the gate's behaviour depends only on its inline-header pass. + """ + stub_path = os.path.join(tmp, "reuse-stub.sh") + body = textwrap.dedent( + """\ + #!/usr/bin/env bash + set -euo pipefail + cat <<'JSON' + { + "non_compliant": {}, + "summary": { + "files_total": 0, + "files_with_copyright_info": 0, + "files_with_licensing_info": 0, + "used_licenses": [] + }, + "reuse_tool_version": "stub-0.0.0", + "reuse_spec_version": "stub", + "files": [] + } + JSON + """ + ) + with open(stub_path, "w", encoding="utf-8") as fh: + fh.write(body) + os.chmod(stub_path, 0o755) + return stub_path + + +class InlineHeaderCheck(unittest.TestCase): + """End-to-end tests of `reuse_gate.missing_inline_headers`.""" + + def setUp(self) -> None: + self.tmp = tempfile.mkdtemp(prefix="reuse-gate-test-") + _init_repo(self.tmp) + + def tearDown(self) -> None: + # `mkdtemp` returns a private directory; recursive remove is fine here + # because we created it ourselves in `setUp`. + import shutil + + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_file_with_both_lines_passes(self) -> None: + _write( + self.tmp, + "ok.go", + "// " + _LIC + "\n// " + _CR + "\n\npackage fixture\n", + ) + _commit(self.tmp) + missing = reuse_gate.missing_inline_headers(self.tmp) + self.assertEqual(missing, []) + + def test_file_missing_identifier_line_fails(self) -> None: + _write(self.tmp, "no_id.go", "// " + _CR + "\n\npackage fixture\n") + _commit(self.tmp) + missing = reuse_gate.missing_inline_headers(self.tmp) + self.assertEqual(len(missing), 1) + self.assertTrue(missing[0].startswith("no_id.go")) + self.assertIn("identifier", missing[0]) + + def test_file_missing_copyright_line_fails(self) -> None: + _write(self.tmp, "no_cr.go", "// " + _LIC + "\n\npackage fixture\n") + _commit(self.tmp) + missing = reuse_gate.missing_inline_headers(self.tmp) + self.assertEqual(len(missing), 1) + self.assertTrue(missing[0].startswith("no_cr.go")) + self.assertIn("copyright", missing[0]) + + def test_file_missing_both_lines_reports_both(self) -> None: + # When both lines are absent the summary must name both pieces, joined + # with "and", never an ambiguous "missing identifier line copyright line". + _write(self.tmp, "no_both.go", "package fixture\n") + _commit(self.tmp) + missing = reuse_gate.missing_inline_headers(self.tmp) + self.assertEqual(len(missing), 1) + self.assertIn("identifier line and copyright line", missing[0]) + + def test_exempt_file_passes(self) -> None: + # An exempt file in the repo, named exactly like the production + # exempt entry, must not show up in the missing list. + exempt_path = next(iter(reuse_gate.INLINE_EXEMPT)) + rel = exempt_path + # Lay the file down at the same repo-relative path so the INLINE_EXEMPT + # key actually matches. + _write( + self.tmp, + rel, + "/**\n * auto-generated by openapi-typescript.\n */\n", + ) + _commit(self.tmp) + missing = reuse_gate.missing_inline_headers(self.tmp) + self.assertEqual(missing, []) + + def test_non_matching_extension_is_ignored(self) -> None: + # A file without an SPDX header in an extension the check does not + # care about must not show up in the missing list at all. + _write(self.tmp, "README.md", "# A readme with no SPDX header\n") + _write(self.tmp, "schema.json", '{"openapi": "3.0.0"}\n') + _commit(self.tmp) + missing = reuse_gate.missing_inline_headers(self.tmp) + self.assertEqual(missing, []) + + +class MainSubprocessCheck(unittest.TestCase): + """Run the gate as a subprocess so the `main()` wiring is covered.""" + + def setUp(self) -> None: + self.tmp = tempfile.mkdtemp(prefix="reuse-gate-subproc-") + _init_repo(self.tmp) + self.stub_path = _make_stub_reuse(self.tmp) + + def tearDown(self) -> None: + import shutil + + shutil.rmtree(self.tmp, ignore_errors=True) + + def _run_gate(self, cwd: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["REUSE_BIN"] = self.stub_path + # `cwd` is the git toplevel the script will look up via + # `git rev-parse --show-toplevel`. + return subprocess.run( + ["python3", os.path.join(HERE, "reuse_gate.py")], + capture_output=True, + text=True, + cwd=cwd, + env=env, + check=False, + ) + + def test_main_exits_one_and_names_headerless_file(self) -> None: + # A tracked file with no SPDX header must fail the gate, with its + # path in the summary and a non-zero exit code. + _write(self.tmp, "ok.go", "// " + _LIC + "\n// " + _CR + "\npackage x\n") + _write(self.tmp, "bad.go", "package x\n") + _commit(self.tmp) + proc = self._run_gate(self.tmp) + self.assertEqual( + proc.returncode, + 1, + msg=f"stdout=\n{proc.stdout}\nstderr=\n{proc.stderr}", + ) + self.assertIn("bad.go", proc.stdout) + + def test_main_exits_zero_on_clean_repository(self) -> None: + # Every tracked source file carries the header: the gate must pass + # end-to-end with exit 0, proving the wiring of `main()` reaches the + # inline-header check and the report assembly. + _write( + self.tmp, "ok.go", "// " + _LIC + "\n// " + _CR + "\npackage x\n" + ) + _write( + self.tmp, + "subdir/also_ok.ts", + "// " + _LIC + "\n// " + _CR + "\nexport const x = 1;\n", + ) + _commit(self.tmp) + proc = self._run_gate(self.tmp) + self.assertEqual( + proc.returncode, + 0, + msg=f"stdout=\n{proc.stdout}\nstderr=\n{proc.stderr}", + ) + self.assertIn("REUSE gate **passed**", proc.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bef5ad7..d75f8726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -324,6 +324,15 @@ jobs: pipx install "reuse==${REUSE_VERSION}" echo "$HOME/.local/bin" >> "$GITHUB_PATH" + # Unit tests for the gate's inline-header pass. Runs the gate's own + # self-check before any reuse invocation, so a regression in the + # inline-header logic blocks the merge even if the rest of the tree + # still passes `reuse lint`. The dotted path form does not work with + # python3 -m unittest on the image's Python 3.12 ("Empty module + # name"), so the discover form is used. + - name: REUSE gate unit tests + run: python3 -m unittest discover -s .github/scripts -p test_reuse_gate.py + # Full human-readable report for the log. The pass/fail decision is made # by the gate step below. - name: reuse lint (report)