Files
2026-07-13 13:39:12 +08:00

256 lines
8.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// scripts/check/check-licenses.mjs
// Gate de license compliance — PLANO-QUALITY-GATES-FASE7.md, Task 20.
//
// Política: OmniRoute é MIT. Dependências de PRODUÇÃO com licença fora da allowlist SPDX
// e sem exceção registrada em .license-allowlist.json => FALHA (policy violation).
// devDependencies com licença não-padrão => advisory (impressas, não falham).
//
// Ferramenta: license-checker-rseidelsohn v4+ (node_modules/.bin/license-checker-rseidelsohn).
//
// Uso:
// node scripts/check/check-licenses.mjs # modo normal
// node scripts/check/check-licenses.mjs --verbose # lista todos os pacotes classificados
// node scripts/check/check-licenses.mjs --json # emite o raw JSON do license-checker
//
// Sair com código 0 = tudo OK (ou apenas advisory).
// Sair com código 1 = violação de política em dep de produção.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const ALLOWLIST_PATH = path.join(ROOT, "config/quality/.license-allowlist.json");
const CHECKER_BIN = path.join(ROOT, "node_modules", ".bin", "license-checker-rseidelsohn");
const VERBOSE = process.argv.includes("--verbose");
const PRINT_JSON = process.argv.includes("--json");
// ---------------------------------------------------------------------------
// Allowlist loading
// ---------------------------------------------------------------------------
/**
* Loads and returns the license allowlist from .license-allowlist.json.
*
* @returns {{ allowed: string[], allowedExpressions: string[], exceptions: Record<string, {license:string, justification:string, risk:string}> }}
*/
export function loadAllowlist() {
if (!fs.existsSync(ALLOWLIST_PATH)) {
throw new Error(
`Allowlist not found: ${ALLOWLIST_PATH}. Create .license-allowlist.json first.`
);
}
const raw = fs.readFileSync(ALLOWLIST_PATH, "utf-8");
const parsed = JSON.parse(raw);
return {
allowed: parsed.allowed ?? [],
allowedExpressions: parsed.allowedExpressions ?? [],
exceptions: parsed.exceptions ?? {},
};
}
// ---------------------------------------------------------------------------
// Classification logic (pure, testable)
// ---------------------------------------------------------------------------
/**
* Classifies a package+license against the allowlist.
*
* @param {string} packageName - Package name without version, e.g. "lightningcss"
* @param {string} license - License string from license-checker, e.g. "MPL-2.0"
* @param {{ allowed: string[], allowedExpressions: string[], exceptions: Record<string,any> }} allowlist
* @returns {{ status: "allowed" | "exception" | "denied", reason: string }}
*/
export function classifyLicense(packageName, license, allowlist) {
const { allowed, allowedExpressions, exceptions } = allowlist;
// 1. Direct SPDX match
if (allowed.includes(license)) {
return { status: "allowed", reason: `SPDX match: ${license}` };
}
// 2. Expression match (e.g. "(MIT OR Apache-2.0)")
if (allowedExpressions.includes(license)) {
return { status: "allowed", reason: `allowed expression: ${license}` };
}
// 3. Per-package exception (strip version suffix for lookup)
const baseName = stripVersion(packageName);
if (exceptions[baseName]) {
const exc = exceptions[baseName];
return {
status: "exception",
reason: `exception: ${exc.justification} [risk=${exc.risk}]`,
};
}
// 4. Denied
return {
status: "denied",
reason: `license '${license}' not in allowlist and no exception registered for '${baseName}'`,
};
}
/**
* Strips the @version suffix from a package key returned by license-checker.
* e.g. "lightningcss@1.32.0" => "lightningcss"
* "@img/sharp-libvips-linux-x64@1.2.4" => "@img/sharp-libvips-linux-x64"
*
* @param {string} pkgKey - Package key as returned by license-checker
* @returns {string}
*/
export function stripVersion(pkgKey) {
// Handle scoped packages: @scope/name@version
const scopedMatch = pkgKey.match(/^(@[^/]+\/[^@]+)(?:@.*)?$/);
if (scopedMatch) return scopedMatch[1];
// Regular: name@version
const regularMatch = pkgKey.match(/^([^@]+)(?:@.*)?$/);
if (regularMatch) return regularMatch[1];
return pkgKey;
}
// ---------------------------------------------------------------------------
// Runner
// ---------------------------------------------------------------------------
/**
* Runs license-checker-rseidelsohn --production and returns parsed JSON.
*
* @returns {Record<string, { licenses: string, path: string }>}
*/
function runLicenseChecker() {
if (!fs.existsSync(CHECKER_BIN)) {
throw new Error(
`license-checker-rseidelsohn not found at ${CHECKER_BIN}.\n` +
`Install it: npm install --save-dev license-checker-rseidelsohn`
);
}
const output = execFileSync(CHECKER_BIN, ["--production", "--json"], {
cwd: ROOT,
encoding: "utf-8",
maxBuffer: 32 * 1024 * 1024, // 32 MB
});
return JSON.parse(output);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
/** @type {Record<string, { licenses: string, path: string }>} */
let licenseData;
try {
licenseData = runLicenseChecker();
} catch (err) {
console.error("[check-licenses] Falha ao rodar license-checker-rseidelsohn:");
console.error(err.message ?? err);
process.exitCode = 1;
return;
}
if (PRINT_JSON) {
console.log(JSON.stringify(licenseData, null, 2));
return;
}
/** @type {{ allowed: string[], allowedExpressions: string[], exceptions: Record<string,any> }} */
let allowlist;
try {
allowlist = loadAllowlist();
} catch (err) {
console.error("[check-licenses]", err.message);
process.exitCode = 1;
return;
}
const violations = [];
const exceptions = [];
const advisory = [];
let allowedCount = 0;
for (const [pkgKey, info] of Object.entries(licenseData)) {
const license = info.licenses ?? "UNKNOWN";
const result = classifyLicense(pkgKey, license, allowlist);
if (result.status === "allowed") {
allowedCount++;
if (VERBOSE) {
console.log(` ✓ ${pkgKey}: ${license}`);
}
} else if (result.status === "exception") {
exceptions.push({ pkgKey, license, reason: result.reason });
} else {
violations.push({ pkgKey, license, reason: result.reason });
}
}
const total = Object.keys(licenseData).length;
// Print summary
console.log(`[check-licenses] Escaneados ${total} pacotes de produção.`);
console.log(` Permitidos: ${allowedCount}`);
console.log(` Exceções registradas: ${exceptions.length}`);
console.log(` Violações de política: ${violations.length}`);
// Print exceptions (informational)
if (exceptions.length > 0) {
console.log(
"\n[check-licenses] Exceções registradas (não bloqueantes, revisar periodicamente):"
);
for (const { pkgKey, license } of exceptions) {
const baseName = stripVersion(pkgKey);
const exc = allowlist.exceptions[baseName];
const riskTag = exc?.risk === "medium" ? " ⚠️ RISK=medium" : "";
console.log(` ⚑ ${pkgKey}: ${license}${riskTag}`);
if (exc?.risk === "medium") {
console.log(` → ${exc.justification}`);
}
}
}
// Print advisory (devDep non-standard — empty here since we run --production)
if (advisory.length > 0) {
console.log("\n[check-licenses] Advisory (devDeps com licença não-padrão):");
for (const { pkgKey, license } of advisory) {
console.log(` ${pkgKey}: ${license}`);
}
}
// Print violations and fail
if (violations.length > 0) {
console.error(
"\n[check-licenses] ❌ VIOLAÇÕES DE POLÍTICA — deps de produção com licença não permitida:"
);
for (const { pkgKey, license, reason } of violations) {
console.error(` ✗ ${pkgKey}: ${license}`);
console.error(` → ${reason}`);
}
console.error(
"\nAdicione a licença à allowlist 'allowed' em .license-allowlist.json (se SPDX-permissiva)\n" +
"ou registre uma exceção por-pacote em 'exceptions' com justificativa e 'reviewAt'.\n" +
"NÃO mascare copyleft forte sem registrar a justificativa. Ver PLANO-QUALITY-GATES-FASE7.md § Task 20."
);
process.exitCode = 1;
return;
}
console.log(
"\n[check-licenses] ✅ Todos os pacotes de produção estão em conformidade com a política de licenças."
);
}
// Run only when invoked directly (not when imported by tests)
const isMain =
process.argv[1] === pathToFileURL(import.meta.url).pathname ||
process.argv[1]?.endsWith("check-licenses.mjs");
if (isMain) {
main();
}