From 6bc81fd2013431912b044a85a059d773ce199a5e Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 12:08:18 -0700 Subject: [PATCH 1/3] tools(reuse): gate checks the inline SPDX header 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. 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). The License (REUSE) job therefore needs its own second pass. .reuse_gate.py adds a missing_inline_header pass over git ls-files. Files whose name ends in one of .go, .ts, .tsx, .js, .mjs, .sql, .py or .sh must carry an identifier line and a copyright line somewhere in their first ten lines; anything else is reported under a new `missing_inline_header` category and makes the gate exit 1. The generated schema.d.ts (openapi-typescript output) is the one tracked file that cannot carry the inline header and is exempted explicitly, with a one-line reason; future generated files get added to the same table. .test_reuse_gate.py covers the four contract cases (both lines present, identifier missing, copyright missing, exempt file) plus a negative case for a non-matching extension. It builds a throwaway git repo per test and never touches the host's git config. The CI workflow now runs it under the License (REUSE) job, before the gate itself, so a regression in the inline-header logic blocks the merge even if the rest of the tree still passes `reuse lint`. Signed-off-by: colton --- .github/scripts/reuse_gate.py | 97 ++++++++++++++++++- .github/scripts/test_reuse_gate.py | 149 +++++++++++++++++++++++++++++ .github/workflows/ci.yml | 7 ++ 3 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/test_reuse_gate.py diff --git a/.github/scripts/reuse_gate.py b/.github/scripts/reuse_gate.py index ef2487c9..668deed6 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,41 @@ "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. +INLINE_CHECK_EXTENSIONS: tuple[str, ...] = ( + ".go", + ".ts", + ".tsx", + ".js", + ".mjs", + ".sql", + ".py", + ".sh", +) + +# 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 +118,47 @@ 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): + missing.append( + f"{path}: missing " + + ("identifier line " if not has_id else "") + + ("copyright line" if not has_cr else "") + ) + 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 +198,13 @@ 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). Operates on + # the working directory's tree; reuse lint itself only cares about the + # REUSE.toml annotations, so it cannot see a missing inline header. + non_compliant["missing_inline_header"] = missing_inline_headers( + os.getcwd() + ) + failures = { key: value for key, value in non_compliant.items() @@ -162,4 +257,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/test_reuse_gate.py b/.github/scripts/test_reuse_gate.py new file mode 100644 index 00000000..4f26c93f --- /dev/null +++ b/.github/scripts/test_reuse_gate.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LicenseRef-OpenLBM-Docs-1.0 +# SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors +"""Unit tests for the inline-header pass in 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). + +These tests build a throwaway git repository under `tempfile.mkdtemp`, +commit a handful of files, and call `missing_inline_headers` against that +repo's root. They 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). +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +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]) + + +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-") + _git(self.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(self.tmp, "config", "user.email", "test@example.invalid") + _git(self.tmp, "config", "user.name", "REUSE gate test") + + 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 _write_and_commit(self, name: str, body: str) -> None: + path = os.path.join(self.tmp, name) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(body) + _git(self.tmp, "add", "--", name) + + def _commit(self) -> None: + _git( + self.tmp, + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + "fixture", + ) + + def test_file_with_both_lines_passes(self) -> None: + self._write_and_commit( + "ok.go", + "// " + _LIC + "\n// " + _CR + "\n\npackage fixture\n", + ) + self._commit() + missing = reuse_gate.missing_inline_headers(self.tmp) + self.assertEqual(missing, []) + + def test_file_missing_identifier_line_fails(self) -> None: + self._write_and_commit( + "no_id.go", + "// " + _CR + "\n\npackage fixture\n", + ) + self._commit() + 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: + self._write_and_commit( + "no_cr.go", + "// " + _LIC + "\n\npackage fixture\n", + ) + self._commit() + 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_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. + self._write_and_commit( + rel, + "/**\n * auto-generated by openapi-typescript.\n */\n", + ) + self._commit() + 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. + self._write_and_commit( + "README.md", + "# A readme with no SPDX header\n", + ) + self._write_and_commit( + "schema.json", + '{"openapi": "3.0.0"}\n', + ) + self._commit() + missing = reuse_gate.missing_inline_headers(self.tmp) + self.assertEqual(missing, []) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bef5ad7..118375b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -324,6 +324,13 @@ 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`. + - name: REUSE gate unit tests + run: python3 -m unittest .github/scripts/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) From 7f7f13eb976cafabe957681de4e51cbc2a19c900 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 12:10:37 -0700 Subject: [PATCH 2/3] ci(license): use unittest discover for the gate unit tests python3 -m unittest .github/scripts/test_reuse_gate.py fails on the image's Python 3.12 with 'Empty module name', because the leading-dot path form is not a valid module identifier to unittest's loader. The discover form (start in .github/scripts, pattern test_reuse_gate.py) works on every Python 3 it has been tested against, so it is the one the workflow runs. Signed-off-by: colton --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 118375b9..d75f8726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -327,9 +327,11 @@ jobs: # 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`. + # 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 .github/scripts/test_reuse_gate.py + 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. From 8cd602758f22b9b52ff6687fcae12acf0b2eaf97 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 12:24:16 -0700 Subject: [PATCH 3/3] tools(reuse): cover gate wiring, broaden extensions, name both header lines Signed-off-by: colton --- .github/scripts/reuse_gate.py | 43 ++++-- .github/scripts/test_reuse_gate.py | 217 ++++++++++++++++++++++------- 2 files changed, 191 insertions(+), 69 deletions(-) diff --git a/.github/scripts/reuse_gate.py b/.github/scripts/reuse_gate.py index 668deed6..172a74c0 100644 --- a/.github/scripts/reuse_gate.py +++ b/.github/scripts/reuse_gate.py @@ -66,15 +66,23 @@ } # 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 @@ -85,7 +93,7 @@ _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 +# 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. @@ -121,8 +129,8 @@ def invalid_expressions(report: dict) -> list[str]: 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 + 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). @@ -151,11 +159,12 @@ def missing_inline_headers(git_root: str) -> list[str]: has_id = _LICENSE_TAG in head has_cr = _COPYRIGHT_TAG in head if not (has_id and has_cr): - missing.append( - f"{path}: missing " - + ("identifier line " if not has_id else "") - + ("copyright line" if not has_cr else "") - ) + 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 @@ -198,12 +207,16 @@ 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). Operates on - # the working directory's tree; reuse lint itself only cares about the - # REUSE.toml annotations, so it cannot see a missing inline header. - non_compliant["missing_inline_header"] = missing_inline_headers( - os.getcwd() - ) + # 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 @@ -257,4 +270,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/.github/scripts/test_reuse_gate.py b/.github/scripts/test_reuse_gate.py index 4f26c93f..c34b296a 100644 --- a/.github/scripts/test_reuse_gate.py +++ b/.github/scripts/test_reuse_gate.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: LicenseRef-OpenLBM-Docs-1.0 # SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors -"""Unit tests for the inline-header pass in reuse_gate.py. +"""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 @@ -9,11 +9,25 @@ lines. The gate tolerates `INLINE_EXEMPT` entries (generated files the generator owns end-to-end). -These tests build a throwaway git repository under `tempfile.mkdtemp`, -commit a handful of files, and call `missing_inline_headers` against that -repo's root. They 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). +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 @@ -22,10 +36,11 @@ 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 +# `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: @@ -47,16 +62,66 @@ def _git(cwd: str, *args: str) -> None: 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-") - _git(self.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(self.tmp, "config", "user.email", "test@example.invalid") - _git(self.tmp, "config", "user.name", "REUSE gate test") + _init_repo(self.tmp) def tearDown(self) -> None: # `mkdtemp` returns a private directory; recursive remove is fine here @@ -65,55 +130,41 @@ def tearDown(self) -> None: shutil.rmtree(self.tmp, ignore_errors=True) - def _write_and_commit(self, name: str, body: str) -> None: - path = os.path.join(self.tmp, name) - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as fh: - fh.write(body) - _git(self.tmp, "add", "--", name) - - def _commit(self) -> None: - _git( - self.tmp, - "-c", - "commit.gpgsign=false", - "commit", - "-q", - "-m", - "fixture", - ) - def test_file_with_both_lines_passes(self) -> None: - self._write_and_commit( + _write( + self.tmp, "ok.go", "// " + _LIC + "\n// " + _CR + "\n\npackage fixture\n", ) - self._commit() + _commit(self.tmp) missing = reuse_gate.missing_inline_headers(self.tmp) self.assertEqual(missing, []) def test_file_missing_identifier_line_fails(self) -> None: - self._write_and_commit( - "no_id.go", - "// " + _CR + "\n\npackage fixture\n", - ) - self._commit() + _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: - self._write_and_commit( - "no_cr.go", - "// " + _LIC + "\n\npackage fixture\n", - ) - self._commit() + _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. @@ -121,29 +172,87 @@ def test_exempt_file_passes(self) -> None: rel = exempt_path # Lay the file down at the same repo-relative path so the INLINE_EXEMPT # key actually matches. - self._write_and_commit( + _write( + self.tmp, rel, "/**\n * auto-generated by openapi-typescript.\n */\n", ) - self._commit() + _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. - self._write_and_commit( - "README.md", - "# A readme with no SPDX header\n", - ) - self._write_and_commit( - "schema.json", - '{"openapi": "3.0.0"}\n', - ) - self._commit() + _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() \ No newline at end of file + unittest.main()