Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .codearbiter/coding-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ plugin's own authoring gates (`skill-author` v2 house style), not by this file.

## File headers and copyright

- License is MIT; the copyright holder of record is **"codeArbiter contributors"**
(`LICENSE`).
- License is **AGPL-3.0-only**; the copyright holder of record is **SUaDtL**
(`LICENSE`, ADR-0009 — relicensed from MIT at v2.6.0).
- **No per-file license or SPDX headers.** The single root `LICENSE` governs; new
files carry no copyright header. The `# codeArbiter — …` / `/** … */` purpose
comment is documentation, not a license header.
Expand Down
89 changes: 89 additions & 0 deletions .codearbiter/specs/license-consistency-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Spec — license-consistency CI check

## Problem

A license change (e.g. the v2.6.0 MIT → AGPLv3 relicense, ADR-0009) must update several
independent surfaces, and human review keeps missing some. This session alone found three drift
instances left behind after the relicense: `plugins/ca/.claude-plugin/plugin.json` still declared
`MIT` (fixed in #151), the top-of-README license notice still used the pre-#149 "available
separately" offering wording (fixed in #151), and `plugins/ca-sandbox/.claude-plugin/plugin.json`
still declares `MIT` (open). There is no mechanical guard, so a partial relicense ships silently.

## Caller

The maintainer / release process. "Done" = a dedicated CI check that goes **red** when any project
license-declaration surface disagrees with the canonical license, so a future license change cannot
land on only some surfaces.

## Scope

**In scope — the project's own license-declaration surfaces:**
- `plugins/ca/.claude-plugin/plugin.json` `license` field — the **canonical source of truth** (SPDX).
- `plugins/ca-sandbox/.claude-plugin/plugin.json` `license` field — must equal the canonical.
- `LICENSE` file — its text family must match the canonical SPDX.
- `README.md` version-line license badge.
- `README.md` license-notice callout prose.
- A one-field data fix included in this work: set `ca-sandbox`'s manifest `license` to
`AGPL-3.0-only` (decided 2026-06-27; the relicense oversight, same class as ca's). ca-sandbox
0.1.1 is untagged, so this needs no version bump.

**Out of scope:**
- Third-party / transitive dependency licenses (`package-lock.json`, `node_modules`) — never read.
- Historical references to a prior license (CHANGELOG entries, ADR-0009 narrative) — never read; the
check reads only the enumerated surface files, never a repo-wide grep.
- `marketplace.json` (carries no `license` field today).
- The CLA's legal substance, and the *choice* of license itself — that stays a human/ADR decision.
- Asserting the full LICENSE text verbatim — only the family-identifying header is checked.

## Design

Canonical SPDX is read from `plugins/ca/.claude-plugin/plugin.json`. A small `LICENSE_FAMILIES`
table maps an SPDX id → the markers that identify that license on each surface (LICENSE-file header
substrings, README badge marker, README prose marker). The check is otherwise license-agnostic: a
future relicense changes ca's manifest + adds/uses the family entry, and the check then forces every
other surface to match. Retired commercial-**offering** phrases are a fixed forbidden list (the prose
guard), independent of which license is current.

Implementation mirrors `_releaselib` / `check_badge_consistency.py`: stdlib-only Python, pure
functions over file contents that **never raise on malformed input** (degrade to a reported finding),
plus a CLI entrypoint, with a unit test file. Wired into `.github/workflows/ci.yml` as a dedicated
job in the required-checks aggregation.

For `AGPL-3.0-only` the family markers are: LICENSE header contains `GNU AFFERO GENERAL PUBLIC LICENSE`
and `Version 3`; README badge contains `license-AGPL_v3`; README notice contains `AGPLv3`.

## Acceptance criteria

1. **Manifest agreement.** Given the two plugin manifests' `license` values and the canonical SPDX,
the check passes iff both equal the canonical; a manifest whose `license` differs (e.g. `MIT`)
fails with a finding naming the file and both values. (pure fn over inputs)
2. **No stale prior-license string.** A manifest `license` equal to a known prior license (`MIT`)
fails specifically as a stale-relicense finding, even if both manifests agreed on it — the
canonical is AGPL-family, so MIT can never pass. (pure fn)
3. **LICENSE-file family match.** Given the LICENSE file text and the canonical SPDX, the check
passes iff the text contains all family-identifying markers for that SPDX (AGPL-3.0-only →
`GNU AFFERO GENERAL PUBLIC LICENSE` + `Version 3`); a LICENSE file missing a marker fails. (pure fn)
4. **README badge match.** Given the README text and canonical SPDX, passes iff the family badge
marker (`license-AGPL_v3`) is present; absent/mismatched badge fails. (pure fn)
5. **README notice names the canonical license.** Given the README text and canonical SPDX, passes
iff the family prose marker (`AGPLv3`) is present in the notice; a notice naming only a prior
license fails. (pure fn)
6. **Prose offering-guard.** Given the README text, fails if it contains any retired offering phrase
in the fixed forbidden set (`available separately`, `offers the same code`); the current
post-#151 README (which uses `separate proprietary terms` / `not offered at this time`) passes.
A finding names the offending phrase. (pure fn)
7. **Unknown canonical SPDX is a clear failure, not a crash.** If ca's manifest declares an SPDX with
no `LICENSE_FAMILIES` entry, the check fails with a "no family mapping" finding (degrade, never
raise) — so adding a relicense without a family entry is caught, not silently passed. (pure fn)
8. **Never raises on malformed input.** Missing file, unparseable JSON, or non-string input yields a
reported finding (and non-zero CLI exit), never an exception. (pure fn)
9. **Live repo passes after the ca-sandbox fix.** Run against the real repo with ca-sandbox set to
`AGPL-3.0-only`, the check exits 0; with ca-sandbox left `MIT`, it exits non-zero naming that file.
(integration over real files)
10. **CI wiring.** `.github/workflows/ci.yml` defines a license-consistency job that runs the check
and is included in the required-checks aggregation, so a red check blocks merge. (structural)

## Open questions

None blocking. Both design decisions resolved with the user 2026-06-27: (a) ca-sandbox → AGPL-3.0-only
(fix it; check enforces both manifests); (b) identity + narrow prose guard (AC-6).
215 changes: 215 additions & 0 deletions .github/scripts/check_license_consistency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
# codeArbiter — license-consistency CI check.
#
# Guards against a partial relicense: asserts the project's own license-
# declaration surfaces all agree with the canonical SPDX (ca's plugin.json
# `license`). Reads ONLY the enumerated surfaces below — never a repo-wide grep
# — so third-party (package-lock) and historical (CHANGELOG / ADR) license
# mentions can't false-positive. Motivated by the v2.6.0 relicense, which left
# the ca manifest, the README callout, and the ca-sandbox manifest on stale
# declarations that human review kept missing.
#
# Design invariants (mirror the other .github/scripts checks and _*lib.py):
# - Stdlib only; zero side effects at import.
# - Pure functions over synthetic input; filesystem access isolated to the
# named readers (read_manifest_license / read_text).
# - Never raise on malformed input — degrade to a surfaced finding, since a
# CI check that crashes is worse than one that reports a problem.
#
# Public API:
# resolve_family(spdx) -> dict | None
# check_manifest_agreement(canonical_spdx, manifest_licenses) -> list[str]
# check_license_file(family, license_text) -> list[str]
# check_readme_badge(family, readme_text) -> list[str]
# check_readme_notice(family, readme_text) -> list[str]
# check_offering_prose(readme_text) -> list[str]
# read_manifest_license(path) -> str | None
# read_text(path) -> str | None
# evaluate(canonical_spdx, manifest_licenses, license_text, readme_text) -> list[str]
# run_all(repo_root) -> list[str]
# main(argv) -> int

import json
import os
import sys

# The canonical license lives in ca's manifest; every other surface must agree.
CANONICAL_MANIFEST = "plugins/ca/.claude-plugin/plugin.json"
MANIFESTS = [
"plugins/ca/.claude-plugin/plugin.json",
"plugins/ca-sandbox/.claude-plugin/plugin.json",
]
LICENSE_FILE = "LICENSE"
README_FILE = "README.md"

# Prior licenses the project has moved off of. A manifest still declaring one is
# a stale (incomplete) relicense, called out distinctly from a generic mismatch
# so the failure reads as "this surface wasn't updated", not just "wrong value".
KNOWN_PRIOR_LICENSES = {"MIT"}

# Retired commercial-OFFERING phrasing. License-agnostic prose guard: the owner
# reserves the right to dual-license but does not offer it, so phrasing that
# implies an active commercial offering must not reappear in the README.
FORBIDDEN_OFFERING_PHRASES = ["available separately", "offers the same code"]

# Per-SPDX identifying markers on each surface. A future relicense adds an entry
# here (and changes ca's manifest); the check then forces every other surface to
# match — so the check is license-agnostic, not pinned to AGPL.
LICENSE_FAMILIES = {
"AGPL-3.0-only": {
"license_file_markers": ["GNU AFFERO GENERAL PUBLIC LICENSE", "Version 3"],
"badge_marker": "license-AGPL_v3",
"prose_marker": "AGPLv3",
},
}


def resolve_family(spdx):
"""Return the family token map for `spdx`, or None if there is no entry."""
if not isinstance(spdx, str):
return None
return LICENSE_FAMILIES.get(spdx)


def check_manifest_agreement(canonical_spdx, manifest_licenses):
"""Findings for any manifest whose `license` != `canonical_spdx`. A value in
KNOWN_PRIOR_LICENSES is flagged as a stale relicense; any other mismatch as a
generic disagreement. `manifest_licenses` maps path -> license string."""
findings = []
for path, val in sorted(manifest_licenses.items()):
if val == canonical_spdx:
continue
if val in KNOWN_PRIOR_LICENSES:
findings.append(
f"{path}: declares prior license '{val}'; expected canonical "
f"'{canonical_spdx}' (relicense incomplete)")
else:
findings.append(
f"{path}: license '{val}' does not match canonical '{canonical_spdx}'")
return findings


def check_license_file(family, license_text):
"""Findings if the LICENSE text is missing any family-identifying marker."""
if not isinstance(family, dict) or not isinstance(license_text, str):
return [f"{LICENSE_FILE}: unreadable, or no license family to match against"]
return [f"{LICENSE_FILE}: missing expected marker '{marker}'"
for marker in family.get("license_file_markers", []) if marker not in license_text]


def check_readme_badge(family, readme_text):
"""Findings if the README lacks the family's license badge marker."""
if not isinstance(family, dict) or not isinstance(readme_text, str):
return [f"{README_FILE}: unreadable, or no license family to match against"]
marker = family.get("badge_marker", "")
if marker and marker not in readme_text:
return [f"{README_FILE}: license badge '{marker}' not found"]
return []


def check_readme_notice(family, readme_text):
"""Findings if the README never names the canonical license (family prose marker)."""
if not isinstance(family, dict) or not isinstance(readme_text, str):
return [f"{README_FILE}: unreadable, or no license family to match against"]
marker = family.get("prose_marker", "")
if marker and marker not in readme_text:
return [f"{README_FILE}: license notice does not name '{marker}'"]
return []


def check_offering_prose(readme_text):
"""Findings for any retired commercial-offering phrase present in the README."""
if not isinstance(readme_text, str):
return []
low = readme_text.lower()
return [f"{README_FILE}: retired offering phrase '{p}' present"
for p in FORBIDDEN_OFFERING_PHRASES if p in low]


def read_manifest_license(path):
"""The `license` field of a JSON manifest, or None if missing / unparseable / absent."""
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, ValueError):
return None
val = data.get("license") if isinstance(data, dict) else None
return val if isinstance(val, str) else None


def read_text(path):
"""File text, or None if missing / unreadable."""
try:
with open(path, encoding="utf-8") as fh:
return fh.read()
except OSError:
return None


def evaluate(canonical_spdx, manifest_licenses, license_text, readme_text):
"""Aggregate every check over already-read inputs. An unknown canonical SPDX
(no LICENSE_FAMILIES entry) is itself a finding, and the family-dependent
surface checks are skipped (there is nothing to match against)."""
findings = list(check_manifest_agreement(canonical_spdx, manifest_licenses))
family = resolve_family(canonical_spdx)
if family is None:
findings.append(
f"{CANONICAL_MANIFEST}: canonical license '{canonical_spdx}' has no license "
f"family mapping (no LICENSE_FAMILIES entry) — add one so the surfaces can be checked")
else:
findings += check_license_file(family, license_text)
findings += check_readme_badge(family, readme_text)
findings += check_readme_notice(family, readme_text)
findings += check_offering_prose(readme_text)
return findings


def run_all(repo_root):
"""Read every surface under `repo_root` and return all findings (empty = consistent).
A missing required surface is a finding, never an exception."""
canonical = read_manifest_license(os.path.join(repo_root, CANONICAL_MANIFEST))
if canonical is None:
return [f"{CANONICAL_MANIFEST}: missing or no readable `license` field "
f"(cannot determine the canonical license)"]

findings = []
manifest_licenses = {}
for rel in MANIFESTS:
val = read_manifest_license(os.path.join(repo_root, rel))
if val is None:
findings.append(f"{rel}: missing or no readable `license` field")
else:
manifest_licenses[rel] = val

license_text = read_text(os.path.join(repo_root, LICENSE_FILE))
if license_text is None:
findings.append(f"{LICENSE_FILE}: missing or unreadable")
readme_text = read_text(os.path.join(repo_root, README_FILE))
if readme_text is None:
findings.append(f"{README_FILE}: missing or unreadable")

findings += evaluate(canonical, manifest_licenses, license_text or "", readme_text or "")

seen, deduped = set(), [] # a missing-file finding can echo a downstream check finding
for f in findings:
if f not in seen:
seen.add(f)
deduped.append(f)
return deduped


def main(argv):
"""CLI: `check_license_consistency.py [repo_root]`. Prints findings; exit 1 if any."""
repo_root = argv[0] if argv else "."
findings = run_all(repo_root)
if findings:
sys.stderr.write("license-consistency check FAILED:\n")
for f in findings:
sys.stderr.write(f" - {f}\n")
return 1
print("license declarations consistent across all surfaces")
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Loading
Loading