Files
awesome-copilot/eng/lib/license.mjs
T
github-actions[bot] 6212c7a01b chore: publish from main
2026-07-30 04:36:20 +00:00

262 lines
6.6 KiB
JavaScript

// Shared license validation used by both the external plugin catalog validator
// (eng/external-plugin-validation.mjs) and the local plugin.json validator
// (eng/validate-plugins.mjs). Keeping it here avoids one validator importing from
// the other and gives license rules a single, dedicated home.
//
// The agent-plugins-spec schema (https://github.com/agentplugins/agent-plugins-spec)
// does not enforce SPDX, and plugins may legitimately use proprietary or non-OSS
// licenses. A non-SPDX license is therefore a warning, never an error.
import { inlineCode } from "./markdown.mjs";
// A single SPDX license identifier token (e.g. "MIT", "Apache-2.0", "LicenseRef-Foo").
const SPDX_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]*\+?$/;
const SPDX_IDSTRING_PATTERN = /^[A-Za-z0-9.-]+$/;
// Curated set of common SPDX license identifiers. Not exhaustive: unrecognized
// but well-formed identifiers produce a warning rather than an error.
const KNOWN_SPDX_IDS = new Set([
"0BSD",
"AGPL-3.0",
"AGPL-3.0-only",
"AGPL-3.0-or-later",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"CC-BY-4.0",
"CC-BY-SA-4.0",
"CC0-1.0",
"EPL-2.0",
"GPL-2.0",
"GPL-2.0-only",
"GPL-2.0-or-later",
"GPL-3.0",
"GPL-3.0-only",
"GPL-3.0-or-later",
"ISC",
"LGPL-2.1",
"LGPL-2.1-only",
"LGPL-2.1-or-later",
"LGPL-3.0",
"LGPL-3.0-only",
"LGPL-3.0-or-later",
"MIT",
"MPL-2.0",
"Unlicense",
"WTFPL",
"Zlib",
]);
const KNOWN_SPDX_EXCEPTIONS = new Set([
"Classpath-exception-2.0",
"GCC-exception-3.1",
"LLVM-exception",
"Autoconf-exception-3.0",
"Bison-exception-2.2",
"Font-exception-2.0",
"GPL-3.0-linking-exception",
"Linux-syscall-note",
"OpenSSL-exception",
"Qt-GPL-exception-1.0",
]);
function isNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0;
}
function isSpdxIdString(value) {
return SPDX_IDSTRING_PATTERN.test(value);
}
function isRecognizedLicenseRef(token) {
if (token.startsWith("LicenseRef-")) {
return isSpdxIdString(token.slice("LicenseRef-".length));
}
if (!token.startsWith("DocumentRef-")) {
return false;
}
const separatorIndex = token.indexOf(":LicenseRef-");
if (separatorIndex === -1) {
return false;
}
const documentRef = token.slice("DocumentRef-".length, separatorIndex);
const licenseRef = token.slice(separatorIndex + ":LicenseRef-".length);
return isSpdxIdString(documentRef) && isSpdxIdString(licenseRef);
}
function isRecognizedSpdxIdToken(token) {
if (isRecognizedLicenseRef(token)) {
return true;
}
if (!SPDX_ID_PATTERN.test(token)) {
return false;
}
const normalized = token.endsWith("+") ? token.slice(0, -1) : token;
return KNOWN_SPDX_IDS.has(normalized);
}
function isRecognizedSpdxExceptionToken(token) {
return isSpdxIdString(token) && KNOWN_SPDX_EXCEPTIONS.has(token);
}
function tokenizeSpdxExpression(license) {
return license
.replace(/([()])/g, " $1 ")
.trim()
.split(/\s+/)
.filter((token) => token.length > 0);
}
// Returns true only for a well-formed SPDX license expression composed of
// recognized identifiers joined by SPDX operators (optionally parenthesized).
// Anything else (proprietary strings, free text, unrecognized ids) returns false so
// the caller can warn without rejecting it — the plugin spec does not enforce SPDX.
export function isRecognizedSpdxExpression(license) {
if (typeof license !== "string") {
return false;
}
const tokens = tokenizeSpdxExpression(license);
if (tokens.length === 0) {
return false;
}
let position = 0;
function peek() {
return tokens[position];
}
function consume() {
return tokens[position++];
}
function parsePrimary() {
const token = peek();
if (token === "(") {
consume();
if (!parseOrExpression()) {
return false;
}
if (peek() !== ")") {
return false;
}
consume();
return true;
}
if (!token || token === ")" || ["AND", "OR", "WITH"].includes(token.toUpperCase())) {
return false;
}
consume();
return isRecognizedSpdxIdToken(token);
}
function parseSimpleExpression() {
const token = peek();
if (!token || token === ")" || ["AND", "OR", "WITH"].includes(token.toUpperCase())) {
return false;
}
consume();
return isRecognizedSpdxIdToken(token);
}
function parseWithExpression() {
if (peek() === "(") {
return parsePrimary() && peek()?.toUpperCase() !== "WITH";
}
if (!parseSimpleExpression()) {
return false;
}
if (peek()?.toUpperCase() !== "WITH") {
return true;
}
consume();
const exceptionToken = peek();
if (!exceptionToken || exceptionToken === ")" || ["AND", "OR", "WITH"].includes(exceptionToken.toUpperCase())) {
return false;
}
consume();
return isRecognizedSpdxExceptionToken(exceptionToken);
}
function parseAndExpression() {
if (!parseWithExpression()) {
return false;
}
while (peek()?.toUpperCase() === "AND") {
consume();
if (!parseWithExpression()) {
return false;
}
}
return true;
}
function parseOrExpression() {
if (!parseAndExpression()) {
return false;
}
while (peek()?.toUpperCase() === "OR") {
consume();
if (!parseAndExpression()) {
return false;
}
}
return true;
}
return parseOrExpression() && position === tokens.length;
}
// Canonical license validation. A non-SPDX license is a warning (never an error)
// so authors may use proprietary or non-OSS licenses. Returns collected
// errors/warnings so each caller can integrate them into its own reporting.
//
// Options:
// required — when true, a missing license is an error (default false).
// prefix — optional message prefix (e.g. "external.json[3]") for context.
export function validateLicenseField(license, options = {}) {
const { required = false } = options;
const prefix = options.prefix ? `${options.prefix}: ` : "";
const errors = [];
const warnings = [];
if (license === undefined) {
if (required) {
errors.push(`${prefix}"license" is required`);
}
return { errors, warnings };
}
if (!isNonEmptyString(license)) {
errors.push(`${prefix}"license" must be a non-empty string`);
return { errors, warnings };
}
if (!isRecognizedSpdxExpression(license)) {
warnings.push(
`${prefix}"license" value ${inlineCode(license)} is not a recognized SPDX identifier; prefer a standard SPDX id (https://spdx.org/licenses), though non-SPDX or proprietary licenses are allowed`
);
}
return { errors, warnings };
}