Skip to content

Commit 55da6b3

Browse files
feat(scripts): add spdx-inject-copyright.sh + tests (#337)
## Summary Formalises the deterministic SPDX-FileCopyrightText injector originally prototyped in `/tmp` during the valence-shell 2026-06-01 M1 closure session (where it drove an 8-batched 267-file copyright sweep across valence-shell PRs #95-#102). Lifted from one-off tmp script to proper standards estate tool: 5 comment styles, 3 modes, robust against the prototype's `printf --` / `grep --` parsing bugs, idempotent, 18-case regression test. ## What it does For each input file (positional args, `--files <list>`, or stdin), checks: - **Already has Copyright line** → skip - **Has SPDX line but no Copyright** → insert Copyright line on line after SPDX - **Neither present** → prepend a fresh comment block ## Modes | Mode | Behaviour | Exit code | |---|---|---| | `--apply` (default) | Write changes | 0 on success | | `--check` | Don't write, exit 1 if any would change | 0 = clean / 1 = changes pending | | `--dry-run` | Print what would change, don't write | 0 = clean / 1 = changes pending | ## Comment style autodetection (override with `--style`) | Style | Extensions | |---|---| | `html` | .md .html .htm .xml .svg .vue .astro | | `slash` | .rs .c .h .cpp .hpp .cc .hh .zig .js .ts .tsx .jsx .go .swift .kt .scala .java .cs .dart .v .sv .adoc .asciidoc .css .scss .sass .less | | `dash` | .idr .hs .lhs .sql .lean .elm .purs .ada .vhdl | | `hash` | .ex .exs .py .rb .pl .pm .sh .bash .zsh .fish .yml .yaml .toml .r .tcl .jl .nix (+ Makefile, Dockerfile, .gitignore, .envrc, .editorconfig) | | `semi` | .lisp .scm .ss .el .clj .cljs .cljc .rkt | ## Customisation Required: `SPDX_COPYRIGHT_HOLDER` env var or `--holder NAME` Optional: `SPDX_LICENSE` env var or `--license ID` (defaults to MPL-2.0) ## Test plan - [x] `scripts/tests/spdx-inject-copyright-test.sh` exercises 18 cases, all passing locally - [x] Style autodetection per extension - [x] Idempotence (running twice == running once) - [x] `--check` exits 1 when changes pending; 0 when clean - [x] `--dry-run` never modifies - [x] Missing file: counted but not an error - [x] Unknown extension: skipped silently - [x] Regression: file content lines starting with `--` (the bug discovered in the Idris2 batch during the prototype run) - [x] `--files` flag + stdin input - [x] Missing `--holder` → exit 2 - [x] `--style` override on unknown extension ## Why now The valence-shell sweep used `/tmp/spdx-sweep/inject-copyright.sh` (a less polished version). Estate-wide SPDX sweeps will follow the same pattern; lifting this means future sweeps can rely on a tested, portable, idempotent, CI-ready tool rather than rediscovering the same edge cases. ## Wiring Not yet wired into `governance-reusable.yml` (governance only invokes scripts that check policy; this script also writes). Could be wired as an optional `spdx-inject-check` job using `--check` mode if desired — left as a follow-up for the owner. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1376eb6 commit 55da6b3

2 files changed

Lines changed: 647 additions & 0 deletions

File tree

scripts/spdx-inject-copyright.sh

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
4+
#
5+
# spdx-inject-copyright.sh
6+
#
7+
# Idempotent injector for SPDX-License-Identifier + SPDX-FileCopyrightText
8+
# (Copyright (c) ...) blocks at the top of source / doc files.
9+
#
10+
# Comment style is autodetected from file extension; override with --style.
11+
#
12+
# Behaviour by file:
13+
# - If the file already contains the configured Copyright line: skip.
14+
# - If it has the SPDX-License-Identifier line but not the Copyright
15+
# line: insert the Copyright line on the line immediately after the
16+
# SPDX line.
17+
# - Otherwise: prepend a fresh block (2 lines for line-comment styles,
18+
# 4 lines for block-comment HTML/Markdown style).
19+
#
20+
# Modes:
21+
# --apply (default) — write changes.
22+
# --check — exit non-zero if any file would change; do not write.
23+
# --dry-run — print what would change; do not write.
24+
#
25+
# Input: one path per line on stdin, OR --files <list-file>, OR positional
26+
# args. Files are processed in the order listed.
27+
#
28+
# Customisation env vars (or flags):
29+
# SPDX_LICENSE (default: MPL-2.0) — license identifier.
30+
# SPDX_COPYRIGHT_HOLDER (required) — copyright holder.
31+
# Example: "Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>"
32+
#
33+
# Exit codes:
34+
# 0 — success. In --check mode: no file would change.
35+
# 1 — In --check or --dry-run mode: at least one file would change.
36+
# In --apply mode: a hard error occurred (missing file, write failure).
37+
# 2 — invocation / environment error (missing required env var, bad flag).
38+
#
39+
# Stats line (always emitted on stderr at end):
40+
# STATS: processed=N already_has=N added_copyright=N inserted_block=N \
41+
# missing=N skipped_unknown_style=N
42+
#
43+
# Wired into governance-reusable.yml as the optional `spdx-inject-check`
44+
# job (calls this script with --check and a file list from caller).
45+
#
46+
# Portability: avoids `sed -i` (BSD vs GNU divergence); uses tempfile + mv.
47+
# Avoids `grep -qF "$pat"` and `printf "$str"` when $pat / $str can
48+
# begin with `--` (the `-F` / `printf` option parser would mis-classify
49+
# it as a flag terminator); always uses `grep -F -e "$pat"` and
50+
# `printf '%s\n' "$str"` form.
51+
52+
set -u
53+
54+
# --- defaults -----------------------------------------------------------------
55+
56+
LICENSE="${SPDX_LICENSE:-MPL-2.0}"
57+
HOLDER="${SPDX_COPYRIGHT_HOLDER:-}"
58+
MODE="apply"
59+
STYLE=""
60+
FILES_LIST=""
61+
declare -a ARG_FILES=()
62+
63+
# --- argument parsing ---------------------------------------------------------
64+
65+
usage() {
66+
cat >&2 <<'EOF'
67+
Usage: spdx-inject-copyright.sh [OPTIONS] [FILE...]
68+
69+
--apply (default) write changes
70+
--check exit 1 if any file would change; do not write
71+
--dry-run print what would change; do not write
72+
--style STYLE force comment style (html|slash|dash|hash|semi)
73+
overrides extension autodetection
74+
--license ID license identifier (default: MPL-2.0)
75+
can also use SPDX_LICENSE env var
76+
--holder NAME copyright holder (required if SPDX_COPYRIGHT_HOLDER unset)
77+
can also use SPDX_COPYRIGHT_HOLDER env var
78+
--files LIST_FILE read paths from LIST_FILE (one per line)
79+
reads stdin if LIST_FILE is "-"
80+
--help this message
81+
82+
Comment style autodetection:
83+
html .md .html .htm .xml .svg .vue .astro
84+
slash .rs .c .h .cpp .hpp .cc .hh .zig .js .ts .tsx .jsx .go
85+
.swift .kt .scala .java .cs .dart .v .sv .adoc .asciidoc
86+
.css .scss .sass .less
87+
dash .idr .hs .lhs .sql .lean .elm .purs .ada .vhdl
88+
hash .ex .exs .py .rb .pl .pm .sh .bash .zsh .fish .yml .yaml
89+
.toml .r .tcl .jl .nix .dockerfile .makefile .mk
90+
(also: filenames Makefile, Dockerfile, .gitignore, .envrc)
91+
semi .lisp .scm .ss .el .clj .cljs .cljc .rkt
92+
EOF
93+
}
94+
95+
while [[ $# -gt 0 ]]; do
96+
case "$1" in
97+
--apply) MODE="apply"; shift ;;
98+
--check) MODE="check"; shift ;;
99+
--dry-run) MODE="dry-run"; shift ;;
100+
--style) STYLE="$2"; shift 2 ;;
101+
--license) LICENSE="$2"; shift 2 ;;
102+
--holder) HOLDER="$2"; shift 2 ;;
103+
--files) FILES_LIST="$2"; shift 2 ;;
104+
--help|-h) usage; exit 0 ;;
105+
--) shift; while [[ $# -gt 0 ]]; do ARG_FILES+=("$1"); shift; done ;;
106+
-*) echo "ERROR: unknown flag: $1" >&2; usage; exit 2 ;;
107+
*) ARG_FILES+=("$1"); shift ;;
108+
esac
109+
done
110+
111+
if [[ -z "$HOLDER" ]]; then
112+
echo "ERROR: no copyright holder set. Use --holder NAME or set SPDX_COPYRIGHT_HOLDER." >&2
113+
exit 2
114+
fi
115+
116+
SPDX_LINE_TEXT="SPDX-License-Identifier: $LICENSE"
117+
COPY_LINE_TEXT="Copyright (c) $HOLDER"
118+
119+
# --- comment style helpers ----------------------------------------------------
120+
121+
# Return the comment style for a file based on its extension.
122+
# Sets the global STYLE_FOR_FILE on success; returns 0 on success, 1 on unknown.
123+
detect_style() {
124+
local file="$1"
125+
local base ext lower
126+
base="$(basename -- "$file")"
127+
lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')"
128+
129+
# Filename-based first (no extension required).
130+
case "$lower" in
131+
makefile|dockerfile|.gitignore|.envrc|.editorconfig)
132+
STYLE_FOR_FILE="hash"; return 0 ;;
133+
esac
134+
135+
ext="${lower##*.}"
136+
case "$ext" in
137+
md|html|htm|xml|svg|vue|astro)
138+
STYLE_FOR_FILE="html"; return 0 ;;
139+
rs|c|h|cpp|hpp|cc|hh|zig|js|ts|tsx|jsx|go|swift|kt|scala|java|cs|dart|v|sv|adoc|asciidoc|css|scss|sass|less)
140+
STYLE_FOR_FILE="slash"; return 0 ;;
141+
idr|hs|lhs|sql|lean|elm|purs|ada|vhdl)
142+
STYLE_FOR_FILE="dash"; return 0 ;;
143+
ex|exs|py|rb|pl|pm|sh|bash|zsh|fish|yml|yaml|toml|r|tcl|jl|nix)
144+
STYLE_FOR_FILE="hash"; return 0 ;;
145+
lisp|scm|ss|el|clj|cljs|cljc|rkt)
146+
STYLE_FOR_FILE="semi"; return 0 ;;
147+
esac
148+
STYLE_FOR_FILE=""
149+
return 1
150+
}
151+
152+
# Compute the SPDX line and Copyright line as they would appear in the file
153+
# (prefixed by the comment marker for line-comment styles, raw for html
154+
# block style which has its own surrounding markers).
155+
emit_style_lines() {
156+
local s="$1"
157+
case "$s" in
158+
html)
159+
BLOCK_OPEN="<!--"
160+
BLOCK_CLOSE="-->"
161+
SPDX_FORMATTED="$SPDX_LINE_TEXT"
162+
COPY_FORMATTED="$COPY_LINE_TEXT"
163+
;;
164+
slash)
165+
BLOCK_OPEN=""
166+
BLOCK_CLOSE=""
167+
SPDX_FORMATTED="// $SPDX_LINE_TEXT"
168+
COPY_FORMATTED="// $COPY_LINE_TEXT"
169+
;;
170+
dash)
171+
BLOCK_OPEN=""
172+
BLOCK_CLOSE=""
173+
SPDX_FORMATTED="-- $SPDX_LINE_TEXT"
174+
COPY_FORMATTED="-- $COPY_LINE_TEXT"
175+
;;
176+
hash)
177+
BLOCK_OPEN=""
178+
BLOCK_CLOSE=""
179+
SPDX_FORMATTED="# $SPDX_LINE_TEXT"
180+
COPY_FORMATTED="# $COPY_LINE_TEXT"
181+
;;
182+
semi)
183+
BLOCK_OPEN=""
184+
BLOCK_CLOSE=""
185+
SPDX_FORMATTED=";; $SPDX_LINE_TEXT"
186+
COPY_FORMATTED=";; $COPY_LINE_TEXT"
187+
;;
188+
*) return 1 ;;
189+
esac
190+
return 0
191+
}
192+
193+
# Insert COPY_FORMATTED on the line after the first occurrence of SPDX_LINE_TEXT.
194+
# Portable: writes to a tempfile, then mv over the original.
195+
insert_copyright_after_spdx() {
196+
local file="$1"
197+
local tmp
198+
tmp="$(mktemp)" || return 1
199+
local line_num
200+
line_num="$(grep -nF -e "$SPDX_LINE_TEXT" "$file" | head -1 | cut -d: -f1)"
201+
if [[ -z "$line_num" ]]; then
202+
rm -f "$tmp"
203+
return 1
204+
fi
205+
awk -v ln="$line_num" -v new="$COPY_FORMATTED" \
206+
'NR==ln { print; print new; next } { print }' \
207+
"$file" > "$tmp" && mv "$tmp" "$file"
208+
}
209+
210+
# Prepend a fresh block at the top of the file.
211+
prepend_block() {
212+
local file="$1"
213+
local tmp
214+
tmp="$(mktemp)" || return 1
215+
{
216+
if [[ -n "$BLOCK_OPEN" ]]; then
217+
printf '%s\n' "$BLOCK_OPEN"
218+
printf '%s\n' "$SPDX_FORMATTED"
219+
printf '%s\n' "$COPY_FORMATTED"
220+
printf '%s\n' "$BLOCK_CLOSE"
221+
else
222+
printf '%s\n' "$SPDX_FORMATTED"
223+
printf '%s\n' "$COPY_FORMATTED"
224+
fi
225+
cat -- "$file"
226+
} > "$tmp" && mv "$tmp" "$file"
227+
}
228+
229+
# --- counters ----------------------------------------------------------------
230+
231+
processed=0
232+
already_has=0
233+
added_copyright=0
234+
inserted_block=0
235+
missing=0
236+
skipped_unknown_style=0
237+
would_change=0
238+
239+
# --- file iteration ----------------------------------------------------------
240+
241+
process_file() {
242+
local file="$1"
243+
if [[ ! -f "$file" ]]; then
244+
missing=$((missing + 1))
245+
return 0
246+
fi
247+
processed=$((processed + 1))
248+
249+
# Determine style.
250+
local s
251+
if [[ -n "$STYLE" ]]; then
252+
s="$STYLE"
253+
else
254+
if detect_style "$file"; then
255+
s="$STYLE_FOR_FILE"
256+
else
257+
skipped_unknown_style=$((skipped_unknown_style + 1))
258+
[[ "$MODE" == "dry-run" ]] && echo "SKIP (unknown style): $file"
259+
return 0
260+
fi
261+
fi
262+
emit_style_lines "$s" || return 1
263+
264+
if grep -qF -e "$COPY_LINE_TEXT" -- "$file" 2>/dev/null; then
265+
already_has=$((already_has + 1))
266+
[[ "$MODE" == "dry-run" ]] && echo "SKIP (already has copyright): $file"
267+
return 0
268+
fi
269+
270+
if grep -qF -e "$SPDX_LINE_TEXT" -- "$file" 2>/dev/null; then
271+
would_change=$((would_change + 1))
272+
case "$MODE" in
273+
check)
274+
echo "WOULD-INSERT-COPYRIGHT: $file"
275+
;;
276+
dry-run)
277+
echo "WOULD-INSERT-COPYRIGHT: $file"
278+
;;
279+
apply)
280+
if ! insert_copyright_after_spdx "$file"; then
281+
echo "ERROR: insertion failed for $file" >&2
282+
return 1
283+
fi
284+
added_copyright=$((added_copyright + 1))
285+
;;
286+
esac
287+
else
288+
would_change=$((would_change + 1))
289+
case "$MODE" in
290+
check)
291+
echo "WOULD-PREPEND-BLOCK: $file"
292+
;;
293+
dry-run)
294+
echo "WOULD-PREPEND-BLOCK: $file"
295+
;;
296+
apply)
297+
if ! prepend_block "$file"; then
298+
echo "ERROR: prepend failed for $file" >&2
299+
return 1
300+
fi
301+
inserted_block=$((inserted_block + 1))
302+
;;
303+
esac
304+
fi
305+
}
306+
307+
# Read files from sources, in priority order: positional > --files > stdin.
308+
if [[ ${#ARG_FILES[@]} -gt 0 ]]; then
309+
for f in "${ARG_FILES[@]}"; do
310+
process_file "$f" || exit 1
311+
done
312+
elif [[ -n "$FILES_LIST" ]]; then
313+
if [[ "$FILES_LIST" == "-" ]]; then
314+
while IFS= read -r f; do
315+
[[ -z "$f" ]] && continue
316+
process_file "$f" || exit 1
317+
done
318+
else
319+
while IFS= read -r f; do
320+
[[ -z "$f" ]] && continue
321+
process_file "$f" || exit 1
322+
done < "$FILES_LIST"
323+
fi
324+
else
325+
while IFS= read -r f; do
326+
[[ -z "$f" ]] && continue
327+
process_file "$f" || exit 1
328+
done
329+
fi
330+
331+
# --- stats ------------------------------------------------------------------
332+
333+
printf 'STATS: processed=%d already_has=%d added_copyright=%d inserted_block=%d missing=%d skipped_unknown_style=%d\n' \
334+
"$processed" "$already_has" "$added_copyright" "$inserted_block" "$missing" "$skipped_unknown_style" >&2
335+
336+
# --- exit code --------------------------------------------------------------
337+
338+
case "$MODE" in
339+
check|dry-run)
340+
if [[ $would_change -gt 0 ]]; then
341+
exit 1
342+
fi
343+
exit 0
344+
;;
345+
apply)
346+
exit 0
347+
;;
348+
esac

0 commit comments

Comments
 (0)