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
65 changes: 65 additions & 0 deletions review-enrichment/src/analyzers/license-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX license policy analyzer (#1475). For each dependency a PR adds/upgrades, resolves its SPDX license via
// deps.dev (free, no key, covers npm/PyPI/Go) and flags the ones a maintainer should eyeball: copyleft (may be
// incompatible with a permissive project) or unresolved/unknown. Permissive licenses (MIT/BSD/Apache/…) are not
// flagged. The no-checkout reviewer can't resolve a dependency's published license — this can.
import type { EnrichRequest, LicenseFinding } from "../types.js";
import { extractDependencyChanges } from "./dependency-scan.js";

// REES ecosystem label → deps.dev system path segment.
const SYSTEM: Record<string, string> = { npm: "npm", PyPI: "pypi", Go: "go" };

// Strong/weak copyleft families worth a compatibility check against a permissive project.
const COPYLEFT = /^(A?GPL|LGPL|MPL|EPL|CDDL|EUPL|OSL|SSPL|CPAL|CECILL)/i;

function classify(licenses: string[]): LicenseFinding["classification"] | null {
const resolved = licenses.filter(
(license) => license && !/^NOASSERTION$/i.test(license),
);
if (!resolved.length) return "unknown";
if (resolved.some((license) => COPYLEFT.test(license))) return "copyleft";
return null; // permissive / otherwise-known → not flagged
}

// null ⇒ couldn't determine (don't flag); [] / ["NOASSERTION"] ⇒ resolved-but-unknown (flag).
async function fetchLicenses(
system: string,
name: string,
version: string,
fetchImpl: typeof fetch,
): Promise<string[] | null> {
const url = `https://api.deps.dev/v3/systems/${system}/packages/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`;
const response = await fetchImpl(url);
if (!response.ok) return null;
const data = (await response.json()) as { licenses?: string[] };
return Array.isArray(data.licenses) ? data.licenses : [];
}

/** Analyzer entrypoint: changed deps → deps.dev license → only the copyleft/unknown ones. */
export async function scanLicenses(
req: EnrichRequest,
fetchImpl: typeof fetch = fetch,
): Promise<LicenseFinding[]> {
const findings: LicenseFinding[] = [];
for (const change of extractDependencyChanges(req.files ?? [])) {
const system = SYSTEM[change.ecosystem];
if (!system) continue;
const licenses = await fetchLicenses(
system,
change.package,
change.to,
fetchImpl,
);
if (licenses === null) continue; // resolution failed — don't false-flag
const classification = classify(licenses);
if (classification) {
findings.push({
ecosystem: change.ecosystem,
package: change.package,
version: change.to,
licenses,
classification,
});
}
}
return findings;
}
2 changes: 2 additions & 0 deletions review-enrichment/src/brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
} from "./types.js";
import { scanDependencies } from "./analyzers/dependency-scan.js";
import { scanSecrets } from "./analyzers/secret-scan.js";
import { scanLicenses } from "./analyzers/license-check.js";
import { renderBrief } from "./render.js";

type AnalyzerFn = (req: EnrichRequest) => Promise<unknown>;
Expand All @@ -17,6 +18,7 @@ type AnalyzerFn = (req: EnrichRequest) => Promise<unknown>;
const ANALYZERS: Record<keyof BriefFindings, AnalyzerFn> = {
dependency: (req) => scanDependencies(req),
secret: (req) => scanSecrets(req),
license: (req) => scanLicenses(req),
};

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
Expand Down
10 changes: 10 additions & 0 deletions review-enrichment/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ export function renderBrief(
}
}

const licenses = findings.license ?? [];
if (licenses.length) {
lines.push("### Dependency licenses (verify compatibility)");
for (const lic of licenses) {
lines.push(
`- \`${lic.package}@${lic.version}\` (${lic.ecosystem}): ${lic.licenses.join("/") || "none"} — **${lic.classification}**`,
);
}
}

if (!lines.length) return { promptSection: "", systemSuffix: "" };

const header =
Expand Down
12 changes: 11 additions & 1 deletion review-enrichment/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,20 @@ export interface SecretFinding {
confidence: "high" | "medium";
}

/** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1475/#1477/#1478). */
/** A newly-added/upgraded dependency whose license warrants a compatibility check. */
export interface LicenseFinding {
ecosystem: string;
package: string;
version: string;
licenses: string[];
classification: "copyleft" | "unknown";
}

/** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */
export interface BriefFindings {
dependency?: DependencyFinding[];
secret?: SecretFinding[];
license?: LicenseFinding[];
}

export type AnalyzerStatus = "ok" | "degraded" | "skipped";
Expand Down
63 changes: 63 additions & 0 deletions review-enrichment/test/enrichment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ import {
import { renderBrief } from "../dist/render.js";
import { buildBrief } from "../dist/brief.js";
import { scanPatch, scanSecrets } from "../dist/analyzers/secret-scan.js";
import { scanLicenses } from "../dist/analyzers/license-check.js";

const licFetch =
(licenses, ok = true) =>
async () => ({
ok,
json: async () => ({ licenses }),
});
const pkgPatch = (name) => ({
repoFullName: "o/r",
prNumber: 1,
files: [{ path: "package.json", patch: `+ "${name}": "1.0.0",` }],
});

const okFetch = (vulns) => async () => ({
ok: true,
Expand Down Expand Up @@ -254,3 +267,53 @@ test("buildBrief: dependency + secret analyzers both run", async () => {
globalThis.fetch = realFetch;
}
});

test("scanLicenses: flags copyleft + unknown, skips permissive + fetch-fail", async () => {
const gpl = await scanLicenses(
pkgPatch("gpl-pkg"),
licFetch(["GPL-3.0-or-later"]),
);
assert.equal(gpl.length, 1);
assert.equal(gpl[0].classification, "copyleft");
const mit = await scanLicenses(pkgPatch("mit-pkg"), licFetch(["MIT"]));
assert.equal(mit.length, 0);
const unknown = await scanLicenses(pkgPatch("nolic"), licFetch([]));
assert.equal(unknown[0].classification, "unknown");
const na = await scanLicenses(pkgPatch("na"), licFetch(["NOASSERTION"]));
assert.equal(na[0].classification, "unknown");
const failed = await scanLicenses(pkgPatch("x"), licFetch([], false));
assert.equal(failed.length, 0);
});

test("renderBrief: renders the license block", () => {
const r = renderBrief({
license: [
{
ecosystem: "npm",
package: "g",
version: "1",
licenses: ["GPL-3.0"],
classification: "copyleft",
},
],
});
assert.match(r.promptSection, /Dependency licenses/);
assert.match(r.promptSection, /`g@1` \(npm\): GPL-3\.0 — \*\*copyleft\*\*/);
});

test("buildBrief: license analyzer runs alongside the others", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = async (url) =>
String(url).includes("deps.dev")
? { ok: true, json: async () => ({ licenses: ["AGPL-3.0"] }) }
: { ok: true, json: async () => ({ vulns: [] }) };
try {
const brief = await buildBrief(pkgPatch("agpl-pkg"));
assert.equal(brief.analyzerStatus.license, "ok");
assert.equal(brief.findings.license.length, 1);
assert.equal(brief.findings.license[0].classification, "copyleft");
assert.match(brief.promptSection, /AGPL-3.0/);
} finally {
globalThis.fetch = realFetch;
}
});