chore: relicense to AGPL-3.0-or-later and add SPDX headers

Replaces LICENSE (GPLv3 -> AGPLv3) and prepends
`SPDX-License-Identifier: AGPL-3.0-or-later` to every source file
across decnet/, decnet_web/, tests/, scripts/, and tools/.

Rationale: closes the GPLv3 ASP loophole so any party operating a
modified DECNET as a network service must offer their modified
source. Personal copyright (Samuel Paschuan) + inbound=outbound
contributions make a future unilateral relicense infeasible.

- LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt)
- COPYRIGHT: project copyright notice
- tools/add_spdx_headers.py: idempotent header injector
  (shebang- and PEP 263-aware)

Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh).
No behavior change; comments only.
This commit is contained in:
2026-05-22 21:04:16 -04:00
parent ee10b55cfe
commit f2b3393669
1563 changed files with 1810 additions and 77 deletions

169
tools/add_spdx_headers.py Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Prepend SPDX-License-Identifier headers to every source file in DECNET.
One-shot tool. Safe to re-run (idempotent: skips files that already have SPDX).
Rules:
- .py / .sh -> '# SPDX-License-Identifier: AGPL-3.0-or-later'
- .ts / .tsx / .js / .jsx
-> '// SPDX-License-Identifier: AGPL-3.0-or-later'
- .css -> '/* SPDX-License-Identifier: AGPL-3.0-or-later */'
Shebang preservation:
- If line 1 starts with '#!', the SPDX header is inserted on line 2.
- For .py, if a coding declaration (PEP 263) follows the shebang or sits on
line 1/2, the SPDX header is inserted AFTER it.
Skips: .venv, .311, .git, node_modules, __pycache__, .mypy_cache, dist, build,
.next, artifacts, bait, .benchmarks, .pytest_cache, decnet.egg-info,
wiki-checkout, and any file matching --exclude.
Idempotency: a file containing 'SPDX-License-Identifier' anywhere in its first
12 lines is left untouched.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
SPDX_ID = "SPDX-License-Identifier: AGPL-3.0-or-later"
COMMENT_BY_EXT: dict[str, str] = {
".py": f"# {SPDX_ID}",
".sh": f"# {SPDX_ID}",
".ts": f"// {SPDX_ID}",
".tsx": f"// {SPDX_ID}",
".js": f"// {SPDX_ID}",
".jsx": f"// {SPDX_ID}",
".css": f"/* {SPDX_ID} */",
}
SKIP_DIRS = {
".venv",
".venv_test",
".311",
".git",
"node_modules",
"__pycache__",
".mypy_cache",
".pytest_cache",
".benchmarks",
"site-packages",
"dist",
"build",
".next",
"artifacts",
"bait",
"decnet.egg-info",
"wiki-checkout",
}
CODING_RE = re.compile(rb"coding[=:]\s*([-\w.]+)")
def is_skipped(path: Path, root: Path) -> bool:
try:
rel_parts = path.relative_to(root).parts
except ValueError:
return True
return any(part in SKIP_DIRS for part in rel_parts)
def has_spdx(head_lines: list[bytes]) -> bool:
for line in head_lines[:12]:
if b"SPDX-License-Identifier" in line:
return True
return False
def compute_insert_index(lines: list[bytes], ext: str) -> int:
"""Return the index where the SPDX line should be inserted."""
idx = 0
if lines and lines[0].startswith(b"#!"):
idx = 1
if ext == ".py":
# PEP 263 allows coding decl on line 1 or 2.
if idx < len(lines) and CODING_RE.search(lines[idx]):
idx += 1
return idx
def process_file(path: Path, *, dry_run: bool) -> str:
ext = path.suffix
header = COMMENT_BY_EXT.get(ext)
if header is None:
return "skip-ext"
try:
raw = path.read_bytes()
except OSError as e:
return f"error:{e}"
# Preserve trailing-newline state.
had_trailing_nl = raw.endswith(b"\n")
lines = raw.split(b"\n")
# If file ended with \n, split produced a trailing empty element; drop it
# for processing and restore at write time.
if had_trailing_nl and lines and lines[-1] == b"":
lines.pop()
if has_spdx(lines):
return "already"
insert_at = compute_insert_index(lines, ext)
new_lines = lines[:insert_at] + [header.encode("utf-8")] + lines[insert_at:]
out = b"\n".join(new_lines)
if had_trailing_nl or out and not out.endswith(b"\n"):
out += b"\n"
if dry_run:
return "would-add"
path.write_bytes(out)
return "added"
def iter_targets(root: Path) -> list[Path]:
out: list[Path] = []
for p in root.rglob("*"):
if not p.is_file():
continue
if p.suffix not in COMMENT_BY_EXT:
continue
if is_skipped(p, root):
continue
out.append(p)
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--root", default=".", help="Repo root (default: cwd)")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
root = Path(args.root).resolve()
targets = iter_targets(root)
counts: dict[str, int] = {}
samples: dict[str, list[Path]] = {}
for p in targets:
status = process_file(p, dry_run=args.dry_run)
counts[status] = counts.get(status, 0) + 1
samples.setdefault(status, []).append(p)
print(f"root: {root}")
print(f"total candidates: {len(targets)}")
for status, n in sorted(counts.items()):
print(f" {status}: {n}")
for s in samples[status][:3]:
print(f" e.g. {s.relative_to(root)}")
return 0
if __name__ == "__main__":
sys.exit(main())