Files
OmniRoute/scripts/check/check-licenses.mjs
Diego Rodrigues de Sa e Souza 320a9d3f29 chore(repo): nest quality-gate state under config/quality, declutter root (#3896)
Move the committed quality-gate state files out of the repo root into
config/quality/ and the v3.8.24 documentation audit into docs/ops/, then
re-point every gate script, test and .gitignore entry at the new paths.
Refresh docs/architecture/REPOSITORY_MAP.md (stale since v3.8.2) to match
the current layout.

Moved -> config/quality/:
  quality-baseline.json, complexity-baseline.json, duplication-baseline.json,
  file-size-baseline.json, test-discovery-baseline.json,
  dependency-allowlist.json, .license-allowlist.json
  (generated quality-metrics.json now written here too; still gitignored)

Moved -> docs/ops/:
  DOCUMENTATION_AUDIT_REPORT.md (+ meta.json entry + fabricated-docs skip)

Path updates: check-{complexity,duplication,file-size,test-discovery,deps,
licenses,dead-code,cognitive-complexity,type-coverage}.mjs, check-quality-
ratchet.mjs, collect-metrics.mjs, check-tracked-artifacts.mjs (+ its test and
check-deps test). Also gitignore /logs/ (was untracked-not-ignored).

Tracked root files: 56 -> 48. Tool configs left in root on purpose: most are
auto-discovered there, and the tsconfig variants have location-relative
files:[] arrays that would need 46 path rewrites for a 2-file gain.
2026-06-15 11:32:45 -03:00

256 lines
8.7 KiB
JavaScript
Raw 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();
}