From a305565e186d1148e363c5cca546f1c1bec2033e Mon Sep 17 00:00:00 2001 From: coder-Yash886 Date: Mon, 1 Jun 2026 19:32:35 +0530 Subject: [PATCH 1/7] feat: add --create-pr flag to open GitHub PR after --fix Apply OSV-validated direct fixes, commit lockfile changes, and open a structured PR via gh. Supports --base and respects --fail-on threshold. Closes #367 --- src/cli/args.ts | 16 +++ src/cli/help.ts | 2 + src/cli/validate.ts | 12 ++ src/index.ts | 40 +++++- src/types.ts | 2 + src/utils/create-pr.ts | 229 ++++++++++++++++++++++++++++++++++ tests/cli-integration.test.ts | 22 +++- tests/create-pr.test.ts | 87 +++++++++++++ 8 files changed, 405 insertions(+), 5 deletions(-) create mode 100644 src/utils/create-pr.ts create mode 100644 tests/create-pr.test.ts diff --git a/src/cli/args.ts b/src/cli/args.ts index 705c6fd..37aad25 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -121,6 +121,22 @@ export function parseArgs(argv: string[]): { options.fix = true; continue; } + if (arg === "--create-pr") { + options.createPr = true; + continue; + } + if (arg === "--base") { + const val = argv[++i]; + if (!val) throw new Error("--base requires a branch name"); + options.prBase = val; + continue; + } + if (arg.startsWith("--base=")) { + const val = arg.slice("--base=".length); + if (!val) throw new Error("--base requires a branch name"); + options.prBase = val; + continue; + } if (arg === "--prod-only") { options.prodOnly = true; continue; diff --git a/src/cli/help.ts b/src/cli/help.ts index ae1b72d..1bf1717 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -39,6 +39,8 @@ export function printHelp(): void { " --cdx Write CycloneDX 1.4 SBOM to a timestamped .cdx.json file", " --no-open Don't auto-open the report in the browser", " --fix Apply validated direct dependency fixes and rescan", + " --create-pr After --fix, commit changes and open a GitHub pull request (requires gh)", + " --base Base branch for --create-pr (default: main)", " --osv-url Use a custom OSV-compatible advisory endpoint", " --ca-cert Path to a CA certificate file for corporate SSL proxies", " --debug Write verbose runtime/network diagnostics to a timestamped log file", diff --git a/src/cli/validate.ts b/src/cli/validate.ts index 46f456b..fff3da9 100644 --- a/src/cli/validate.ts +++ b/src/cli/validate.ts @@ -22,6 +22,18 @@ export function validateOptions(options: ParsedOptions): void { throw new Error("--fix cannot be used with --json"); } + if (options.createPr && !options.fix) { + throw new Error("--create-pr requires --fix"); + } + + if (options.createPr && options.json) { + throw new Error("--create-pr cannot be used with --json"); + } + + if (options.prBase && !options.createPr) { + throw new Error("--base can only be used with --create-pr"); + } + if (options.report && options.json) { throw new Error("--report cannot be used with --json"); } diff --git a/src/index.ts b/src/index.ts index 5308269..b9eb068 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,10 @@ import { } from "./utils/fix-runner.js"; import { hasRootLockfile, findNestedLockfiles } from "./parsers/multi-package.js"; import { handleMultiFolderScan } from "./scan/multi-folder-scan.js"; +import { + createPullRequestForFixes, + findingsMeetFailOnThreshold, +} from "./utils/create-pr.js"; let parsedArgs: ReturnType | null = null; try { parsedArgs = parseArgs(process.argv.slice(2)); @@ -181,7 +185,7 @@ if (parsedArgs) { return; } - let advisorySourceLine: string; + let advisorySourceLine = ""; let advisoryDbFreshnessLine: string | null = null; let advisoryDbWarning: string | null = null; try { @@ -219,7 +223,9 @@ if (parsedArgs) { if (options.offline || options.offlineDb) { console.log(chalk.gray("Offline mode:") + " " + chalk.yellow("enabled") + " " + chalk.gray("(no external advisory calls will be made)")); } - console.log(`${chalk.gray("Advisory source:")} ${formatAdvisorySourceLine(advisorySourceLine)}`); + if (advisorySourceLine) { + console.log(`${chalk.gray("Advisory source:")} ${formatAdvisorySourceLine(advisorySourceLine)}`); + } if (advisoryDbFreshnessLine) { console.log(`${chalk.gray("Advisory DB freshness:")} ${advisoryDbFreshnessLine}`); } @@ -270,7 +276,8 @@ if (parsedArgs) { projectPath, debugLog, }); - const findingsBeforeFix = scanState.sorted.length; + const findingsBeforeFixList = scanState.sorted; + const findingsBeforeFix = findingsBeforeFixList.length; let fixResult: FixExecutionResult | null = null; if (options.fix) { @@ -310,6 +317,33 @@ if (parsedArgs) { findingsAfterFix: scanState.sorted.length, remainingBySeverity: countBySeverity(scanState.sorted), }); + + if (options.createPr && fixResult) { + if (!findingsMeetFailOnThreshold(findingsBeforeFixList, options.failOn)) { + logWarn( + "Skipping pull request creation: no findings met the --fail-on severity threshold.", + options, + ); + } else { + console.log(""); + console.log(chalk.bold.cyan("Creating pull request (--create-pr)")); + const prResult = await createPullRequestForFixes({ + projectPath, + baseBranch: options.prBase ?? "main", + fixResult, + findingsBeforeFix: findingsBeforeFixList, + findingsAfterFix: scanState.sorted, + }); + if (prResult.skipped) { + logWarn(prResult.skipReason ?? "Pull request was not created.", options); + } else if (prResult.prUrl) { + console.log(`${chalk.gray("Pull request:")} ${chalk.cyan(prResult.prUrl)}`); + console.log(`${chalk.gray("Branch:")} ${chalk.cyan(prResult.branchName)}`); + } else { + logWarn(`Branch ${prResult.branchName} was pushed, but no pull request URL was returned.`, options); + } + } + } } else { await writeOutputs(options, { sorted: scanState.sorted, diff --git a/src/types.ts b/src/types.ts index df49889..9003ce7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -161,6 +161,8 @@ export type ParsedOptions = { debug?: boolean; verbose?: boolean; fix?: boolean; + createPr?: boolean; + prBase?: string; prodOnly?: boolean; failOn: string; batchSize: string; diff --git a/src/utils/create-pr.ts b/src/utils/create-pr.ts new file mode 100644 index 0000000..c22432b --- /dev/null +++ b/src/utils/create-pr.ts @@ -0,0 +1,229 @@ +import { spawn } from "node:child_process"; +import type { Finding } from "../types.js"; +import { severityOrder } from "../constants.js"; +import { normalizeSeverity } from "../osv/severity.js"; +import { pluralize } from "./string.js"; +import type { FixExecutionResult } from "./fix-runner.js"; + +export type CreatePullRequestParams = { + projectPath: string; + baseBranch: string; + fixResult: FixExecutionResult; + findingsBeforeFix: Finding[]; + findingsAfterFix: Finding[]; +}; + +export type CreatePullRequestResult = { + branchName: string; + prUrl: string | null; + skipped: boolean; + skipReason?: string; +}; + +type CommandResult = { + stdout: string; + stderr: string; + status: number | null; + error: Error | null; +}; + +export function findingsMeetFailOnThreshold(findings: Finding[], failOn: string): boolean { + const failLevel = normalizeSeverity(failOn); + return findings.some(finding => severityOrder[finding.severity] >= severityOrder[failLevel]); +} + +export function defaultFixBranchName(date = new Date()): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + return `cve-lite/fix-${year}-${month}-${day}`; +} + +export function buildPullRequestTitle(appliedCount: number): string { + return `fix: resolve ${appliedCount} vulnerable ${pluralize(appliedCount, "dependency", "dependencies")}`; +} + +export function collectAdvisoryIdsForPackage(findings: Finding[], packageName: string): string[] { + const ids = new Set(); + for (const finding of findings) { + if (finding.pkg.name !== packageName) continue; + for (const vuln of finding.vulnerabilities) { + ids.add(vuln.id); + for (const alias of vuln.aliases ?? []) { + if (alias.startsWith("CVE-")) { + ids.add(alias); + } + } + } + } + return [...ids].sort(); +} + +export function buildPullRequestBody(params: { + fixResult: FixExecutionResult; + findingsBeforeFix: Finding[]; + findingsAfterFix: Finding[]; +}): string { + const lines: string[] = [ + "## Summary", + "", + "Automated security fixes applied by [CVE Lite CLI](https://github.com/OWASP/cve-lite-cli) using OSV-validated target versions.", + "", + "## Fixed packages", + "", + ]; + + for (const item of params.fixResult.applied) { + const advisoryIds = collectAdvisoryIdsForPackage(params.findingsBeforeFix, item.package); + const advisoryText = advisoryIds.length > 0 ? advisoryIds.join(", ") : "n/a"; + lines.push(`- **${item.package}**: \`${item.from}\` → \`${item.to}\``); + lines.push(` - Advisories: ${advisoryText}`); + } + + lines.push( + "", + "## Scan results", + "", + `- Findings before fix: **${params.findingsBeforeFix.length}**`, + `- Findings after fix: **${params.findingsAfterFix.length}**`, + "", + "## Notes", + "", + "- Only validated **direct** dependency upgrades are included in this PR.", + "- Transitive vulnerabilities may still require parent package upgrades or upstream releases.", + ); + + return lines.join("\n"); +} + +async function runCommand(command: string, args: string[], cwd: string): Promise { + return await new Promise(resolve => { + let stdout = ""; + let stderr = ""; + const child = spawn(command, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout?.on("data", chunk => { + stdout += String(chunk); + }); + child.stderr?.on("data", chunk => { + stderr += String(chunk); + }); + + child.on("error", error => { + resolve({ stdout, stderr, status: null, error }); + }); + child.on("close", code => { + resolve({ stdout, stderr, status: code, error: null }); + }); + }); +} + +async function assertGitRepository(projectPath: string): Promise { + const result = await runCommand("git", ["rev-parse", "--is-inside-work-tree"], projectPath); + if (result.error || result.status !== 0 || result.stdout.trim() !== "true") { + throw new Error("--create-pr requires a git repository. Run this command from your project root."); + } +} + +async function assertGhAvailable(): Promise { + const version = await runCommand("gh", ["--version"], process.cwd()); + if (version.error || version.status !== 0) { + throw new Error( + "--create-pr requires the GitHub CLI (gh). Install it from https://cli.github.com/ or set GITHUB_TOKEN and ensure gh is on PATH.", + ); + } + + const auth = await runCommand("gh", ["auth", "status"], process.cwd()); + if (auth.status !== 0 && !process.env.GITHUB_TOKEN) { + throw new Error( + "--create-pr requires GitHub authentication. Run `gh auth login` or set the GITHUB_TOKEN environment variable.", + ); + } +} + +async function hasStagedChanges(projectPath: string): Promise { + const status = await runCommand("git", ["status", "--porcelain"], projectPath); + if (status.error || status.status !== 0) { + throw new Error(`Failed to inspect git status: ${status.stderr.trim() || status.error?.message || "unknown error"}`); + } + return status.stdout.trim().length > 0; +} + +function extractPullRequestUrl(output: string): string | null { + const match = output.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/); + return match?.[0] ?? null; +} + +export async function createPullRequestForFixes( + params: CreatePullRequestParams, +): Promise { + const branchName = defaultFixBranchName(); + + if (params.fixResult.appliedFixCount === 0) { + return { + branchName, + prUrl: null, + skipped: true, + skipReason: "No direct dependency fixes were applied, so no pull request was created.", + }; + } + + await assertGitRepository(params.projectPath); + await assertGhAvailable(); + + if (!(await hasStagedChanges(params.projectPath))) { + return { + branchName, + prUrl: null, + skipped: true, + skipReason: "No lockfile or manifest changes were detected after applying fixes.", + }; + } + + const title = buildPullRequestTitle(params.fixResult.appliedFixCount); + const body = buildPullRequestBody({ + fixResult: params.fixResult, + findingsBeforeFix: params.findingsBeforeFix, + findingsAfterFix: params.findingsAfterFix, + }); + + const checkout = await runCommand("git", ["checkout", "-b", branchName], params.projectPath); + if (checkout.error || checkout.status !== 0) { + const message = checkout.stderr.trim() || checkout.error?.message || "unknown error"; + throw new Error(`Failed to create branch ${branchName}: ${message}`); + } + + const stage = await runCommand("git", ["add", "-u"], params.projectPath); + if (stage.error || stage.status !== 0) { + throw new Error(`Failed to stage dependency changes: ${stage.stderr.trim() || stage.error?.message || "unknown error"}`); + } + + const commit = await runCommand("git", ["commit", "-m", title], params.projectPath); + if (commit.error || commit.status !== 0) { + throw new Error(`Failed to commit dependency changes: ${commit.stderr.trim() || commit.error?.message || "unknown error"}`); + } + + const push = await runCommand("git", ["push", "-u", "origin", branchName], params.projectPath); + if (push.error || push.status !== 0) { + throw new Error(`Failed to push branch ${branchName}: ${push.stderr.trim() || push.error?.message || "unknown error"}`); + } + + const create = await runCommand( + "gh", + ["pr", "create", "--base", params.baseBranch, "--head", branchName, "--title", title, "--body", body], + params.projectPath, + ); + if (create.error || create.status !== 0) { + throw new Error(`Failed to open pull request: ${create.stderr.trim() || create.error?.message || "unknown error"}`); + } + + const prUrl = extractPullRequestUrl(`${create.stdout}\n${create.stderr}`); + return { + branchName, + prUrl, + skipped: false, + }; +} diff --git a/tests/cli-integration.test.ts b/tests/cli-integration.test.ts index 98e3c4e..6f59eec 100644 --- a/tests/cli-integration.test.ts +++ b/tests/cli-integration.test.ts @@ -81,13 +81,12 @@ jest.unstable_mockModule("../src/advisory/osv-sync.js", () => ({ })); jest.unstable_mockModule("../src/output/formatters.js", () => ({ - formatAdvisorySourceLine: (value: string) => value, + formatAdvisorySourceLine: jest.fn((sourceLabel: string) => sourceLabel), logInfo: logInfoMock, logWarn: logWarnMock, printCacheSummary: printCacheSummaryMock, serializeFinding: serializeFindingMock, sortFindingsForOutput: sortFindingsForOutputMock, - formatAdvisorySourceLine: jest.fn(sourceLabel => sourceLabel), getRecommendedAction: jest.fn(() => "Upgrade to latest"), })); @@ -905,6 +904,25 @@ describe("CLI integration", () => { expect(result.stderr.join("\n")).toContain("--fix cannot be used with --json"); }); + it("fails fast when --create-pr is used without --fix", async () => { + parseArgsMock.mockReturnValue({ + command: "scan", + options: { + createPr: true, + failOn: "critical", + batchSize: "100", + searchDepth: "4", + minSeverity: "medium", + }, + projectArg: ".", + }); + + const result = await runIndexModule(); + + expect(result.exitCode).toBe(1); + expect(result.stderr.join("\n")).toContain("--create-pr requires --fix"); + }); + it("throws when --no-cache is used with --offline", async () => { parseArgsMock.mockReturnValue({ command: "scan", diff --git a/tests/create-pr.test.ts b/tests/create-pr.test.ts new file mode 100644 index 0000000..394684f --- /dev/null +++ b/tests/create-pr.test.ts @@ -0,0 +1,87 @@ +import { + buildPullRequestBody, + buildPullRequestTitle, + collectAdvisoryIdsForPackage, + defaultFixBranchName, + findingsMeetFailOnThreshold, +} from "../src/utils/create-pr.js"; +import type { Finding, OsvVuln } from "../src/types.js"; +import { validateOptions } from "../src/cli/validate.js"; +import { parseArgs } from "../src/cli/args.js"; + +function createFinding(overrides?: Partial): Finding { + const vuln: OsvVuln = { + id: "GHSA-abc", + aliases: ["CVE-2026-0001"], + summary: "Example", + }; + + return { + pkg: { name: "lodash", version: "4.17.20", ecosystem: "npm" }, + vulnerabilities: [vuln], + severity: "high", + cveAliases: ["CVE-2026-0001"], + dependencyPaths: [["project", "lodash"]], + relationship: "direct", + firstFixedVersion: "4.17.21", + ...overrides, + }; +} + +describe("create-pr helpers", () => { + it("builds a dated branch name", () => { + expect(defaultFixBranchName(new Date("2026-05-30T12:00:00Z"))).toBe("cve-lite/fix-2026-05-30"); + }); + + it("builds a pull request title for applied fixes", () => { + expect(buildPullRequestTitle(1)).toBe("fix: resolve 1 vulnerable dependency"); + expect(buildPullRequestTitle(3)).toBe("fix: resolve 3 vulnerable dependencies"); + }); + + it("collects OSV and CVE identifiers for a package", () => { + const findings = [createFinding()]; + expect(collectAdvisoryIdsForPackage(findings, "lodash")).toEqual(["CVE-2026-0001", "GHSA-abc"]); + }); + + it("builds a markdown body with fixes and scan counts", () => { + const body = buildPullRequestBody({ + fixResult: { + appliedFixCount: 1, + skippedCount: 0, + skippedTransitiveCount: 0, + skippedNoValidatedTargetCount: 0, + applied: [{ package: "lodash", from: "4.17.20", to: "4.17.21" }], + note: null, + }, + findingsBeforeFix: [createFinding(), createFinding({ pkg: { name: "express", version: "4.0.0", ecosystem: "npm" } })], + findingsAfterFix: [createFinding()], + }); + + expect(body).toContain("lodash"); + expect(body).toContain("4.17.20"); + expect(body).toContain("4.17.21"); + expect(body).toContain("CVE-2026-0001"); + expect(body).toContain("Findings before fix: **2**"); + expect(body).toContain("Findings after fix: **1**"); + }); + + it("checks fail-on threshold against initial findings", () => { + expect(findingsMeetFailOnThreshold([createFinding({ severity: "high" })], "critical")).toBe(false); + expect(findingsMeetFailOnThreshold([createFinding({ severity: "critical" })], "critical")).toBe(true); + }); +}); + +describe("create-pr CLI options", () => { + it("parses --create-pr and --base", () => { + const result = parseArgs([".", "--fix", "--create-pr", "--base", "develop"]); + expect(result.options.fix).toBe(true); + expect(result.options.createPr).toBe(true); + expect(result.options.prBase).toBe("develop"); + }); + + it("requires --fix for --create-pr", () => { + expect(() => validateOptions({ failOn: "critical", batchSize: "100", searchDepth: "4", createPr: true })).toThrow( + "--create-pr requires --fix", + ); + }); +}); From 9f4be61c51605a5821860aef16440bb42a07bf04 Mon Sep 17 00:00:00 2001 From: coder-Yash886 Date: Tue, 2 Jun 2026 18:16:46 +0530 Subject: [PATCH 2/7] fix(create-pr): stage only dependency files and handle branch name collisions --- src/utils/create-pr.ts | 78 +++++++++++++++++++++++++++++++++++------ tests/create-pr.test.ts | 7 ++++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/utils/create-pr.ts b/src/utils/create-pr.ts index c22432b..d110261 100644 --- a/src/utils/create-pr.ts +++ b/src/utils/create-pr.ts @@ -27,6 +27,15 @@ type CommandResult = { error: Error | null; }; +const DEPENDENCY_FILES_TO_STAGE = [ + "package.json", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lock", + "npm-shrinkwrap.json", +] as const; + export function findingsMeetFailOnThreshold(findings: Finding[], failOn: string): boolean { const failLevel = normalizeSeverity(failOn); return findings.some(finding => severityOrder[finding.severity] >= severityOrder[failLevel]); @@ -91,6 +100,8 @@ export function buildPullRequestBody(params: { "", "- Only validated **direct** dependency upgrades are included in this PR.", "- Transitive vulnerabilities may still require parent package upgrades or upstream releases.", + "", + "Closes #367", ); return lines.join("\n"); @@ -144,7 +155,7 @@ async function assertGhAvailable(): Promise { } } -async function hasStagedChanges(projectPath: string): Promise { +async function hasAnyChanges(projectPath: string): Promise { const status = await runCommand("git", ["status", "--porcelain"], projectPath); if (status.error || status.status !== 0) { throw new Error(`Failed to inspect git status: ${status.stderr.trim() || status.error?.message || "unknown error"}`); @@ -152,6 +163,47 @@ async function hasStagedChanges(projectPath: string): Promise { return status.stdout.trim().length > 0; } +export async function stageDependencyFilesOnly(projectPath: string): Promise { + for (const filePath of DEPENDENCY_FILES_TO_STAGE) { + const add = await runCommand("git", ["add", "--", filePath], projectPath); + if (add.error || add.status !== 0) { + throw new Error(`Failed to stage dependency file ${filePath}: ${add.stderr.trim() || add.error?.message || "unknown error"}`); + } + } +} + +export async function hasStagedDependencyChanges(projectPath: string): Promise { + const diff = await runCommand("git", ["diff", "--cached", "--name-only"], projectPath); + if (diff.error || diff.status !== 0) { + throw new Error(`Failed to inspect staged changes: ${diff.stderr.trim() || diff.error?.message || "unknown error"}`); + } + const stagedFiles = diff.stdout + .split("\n") + .map(line => line.trim()) + .filter(Boolean); + return stagedFiles.some(filePath => + DEPENDENCY_FILES_TO_STAGE.some(target => filePath === target || filePath.endsWith(`/${target}`)), + ); +} + +async function branchExists(projectPath: string, branchName: string): Promise { + const result = await runCommand("git", ["rev-parse", "--verify", branchName], projectPath); + return result.status === 0; +} + +export async function selectAvailableBranchName(projectPath: string, baseName: string): Promise { + if (!(await branchExists(projectPath, baseName))) { + return baseName; + } + for (let suffix = 2; suffix <= 99; suffix++) { + const candidate = `${baseName}-${suffix}`; + if (!(await branchExists(projectPath, candidate))) { + return candidate; + } + } + throw new Error(`Failed to allocate a unique branch name based on ${baseName}`); +} + function extractPullRequestUrl(output: string): string | null { const match = output.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/); return match?.[0] ?? null; @@ -160,11 +212,11 @@ function extractPullRequestUrl(output: string): string | null { export async function createPullRequestForFixes( params: CreatePullRequestParams, ): Promise { - const branchName = defaultFixBranchName(); + const baseBranchName = defaultFixBranchName(); if (params.fixResult.appliedFixCount === 0) { return { - branchName, + branchName: baseBranchName, prUrl: null, skipped: true, skipReason: "No direct dependency fixes were applied, so no pull request was created.", @@ -174,9 +226,9 @@ export async function createPullRequestForFixes( await assertGitRepository(params.projectPath); await assertGhAvailable(); - if (!(await hasStagedChanges(params.projectPath))) { + if (!(await hasAnyChanges(params.projectPath))) { return { - branchName, + branchName: baseBranchName, prUrl: null, skipped: true, skipReason: "No lockfile or manifest changes were detected after applying fixes.", @@ -190,17 +242,23 @@ export async function createPullRequestForFixes( findingsAfterFix: params.findingsAfterFix, }); + await stageDependencyFilesOnly(params.projectPath); + if (!(await hasStagedDependencyChanges(params.projectPath))) { + return { + branchName: baseBranchName, + prUrl: null, + skipped: true, + skipReason: "No dependency manifest or lockfile changes were detected after applying fixes.", + }; + } + + const branchName = await selectAvailableBranchName(params.projectPath, baseBranchName); const checkout = await runCommand("git", ["checkout", "-b", branchName], params.projectPath); if (checkout.error || checkout.status !== 0) { const message = checkout.stderr.trim() || checkout.error?.message || "unknown error"; throw new Error(`Failed to create branch ${branchName}: ${message}`); } - const stage = await runCommand("git", ["add", "-u"], params.projectPath); - if (stage.error || stage.status !== 0) { - throw new Error(`Failed to stage dependency changes: ${stage.stderr.trim() || stage.error?.message || "unknown error"}`); - } - const commit = await runCommand("git", ["commit", "-m", title], params.projectPath); if (commit.error || commit.status !== 0) { throw new Error(`Failed to commit dependency changes: ${commit.stderr.trim() || commit.error?.message || "unknown error"}`); diff --git a/tests/create-pr.test.ts b/tests/create-pr.test.ts index 394684f..1978659 100644 --- a/tests/create-pr.test.ts +++ b/tests/create-pr.test.ts @@ -4,6 +4,7 @@ import { collectAdvisoryIdsForPackage, defaultFixBranchName, findingsMeetFailOnThreshold, + selectAvailableBranchName, } from "../src/utils/create-pr.js"; import type { Finding, OsvVuln } from "../src/types.js"; import { validateOptions } from "../src/cli/validate.js"; @@ -63,12 +64,18 @@ describe("create-pr helpers", () => { expect(body).toContain("CVE-2026-0001"); expect(body).toContain("Findings before fix: **2**"); expect(body).toContain("Findings after fix: **1**"); + expect(body).toContain("Closes #367"); }); it("checks fail-on threshold against initial findings", () => { expect(findingsMeetFailOnThreshold([createFinding({ severity: "high" })], "critical")).toBe(false); expect(findingsMeetFailOnThreshold([createFinding({ severity: "critical" })], "critical")).toBe(true); }); + + it("adds numeric suffix when branch already exists", async () => { + const branch = await selectAvailableBranchName(process.cwd(), "cve-lite/fix-2026-06-02"); + expect(branch).toMatch(/^cve-lite\/fix-2026-06-02(?:-\d+)?$/); + }); }); describe("create-pr CLI options", () => { From b2f7c0ff72683d0a81814e911cc1351bc2fa6611 Mon Sep 17 00:00:00 2001 From: coder-Yash886 Date: Wed, 3 Jun 2026 19:34:17 +0530 Subject: [PATCH 3/7] fix(create-pr): remove hardcoded issue reference and gate PR creation on applied fixes --- src/index.ts | 7 +--- src/utils/create-pr.ts | 2 - tests/create-pr.test.ts | 2 +- tests/example-fixtures.test.ts | 68 ++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 tests/example-fixtures.test.ts diff --git a/src/index.ts b/src/index.ts index b9eb068..257edb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -319,11 +319,8 @@ if (parsedArgs) { }); if (options.createPr && fixResult) { - if (!findingsMeetFailOnThreshold(findingsBeforeFixList, options.failOn)) { - logWarn( - "Skipping pull request creation: no findings met the --fail-on severity threshold.", - options, - ); + if (fixResult.appliedFixCount === 0) { + logWarn("Skipping pull request creation: no direct fixes were applied.", options); } else { console.log(""); console.log(chalk.bold.cyan("Creating pull request (--create-pr)")); diff --git a/src/utils/create-pr.ts b/src/utils/create-pr.ts index d110261..bc4e9bd 100644 --- a/src/utils/create-pr.ts +++ b/src/utils/create-pr.ts @@ -100,8 +100,6 @@ export function buildPullRequestBody(params: { "", "- Only validated **direct** dependency upgrades are included in this PR.", "- Transitive vulnerabilities may still require parent package upgrades or upstream releases.", - "", - "Closes #367", ); return lines.join("\n"); diff --git a/tests/create-pr.test.ts b/tests/create-pr.test.ts index 1978659..4957727 100644 --- a/tests/create-pr.test.ts +++ b/tests/create-pr.test.ts @@ -64,7 +64,7 @@ describe("create-pr helpers", () => { expect(body).toContain("CVE-2026-0001"); expect(body).toContain("Findings before fix: **2**"); expect(body).toContain("Findings after fix: **1**"); - expect(body).toContain("Closes #367"); + expect(body).not.toContain("Closes #367"); }); it("checks fail-on threshold against initial findings", () => { diff --git a/tests/example-fixtures.test.ts b/tests/example-fixtures.test.ts new file mode 100644 index 0000000..15f5453 --- /dev/null +++ b/tests/example-fixtures.test.ts @@ -0,0 +1,68 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadPackages } from "../src/parsers/index.js"; +import { buildSuggestedFixCommandPlan } from "../src/remediation/fix-commands.js"; +import { scanPackages } from "../src/scanner.js"; +import { readDirectDependencyNames } from "../src/utils/package-json.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +describe("crafted example fixtures", () => { + it("yarn-within-range reconstructs deep paths and suggests yarn upgrade js-cookie", async () => { + const fixtureDir = path.join(repoRoot, "examples", "yarn-within-range"); + const scanInput = loadPackages(fixtureDir, false, 4); + const directDependencyNames = readDirectDependencyNames(fixtureDir, false); + + expect(scanInput.source).toBe("yarn-lock"); + + const jsCookie = scanInput.packages.find(pkg => pkg.name === "js-cookie" && pkg.version === "3.0.6"); + expect(jsCookie?.paths).toEqual( + expect.arrayContaining([ + ["project", "aws-amplify", "@aws-amplify/core", "js-cookie"], + ]), + ); + + const findings = await scanPackages( + scanInput.packages, + 100, + { failOn: "critical", batchSize: "100" }, + { + directDependencyNames, + scanSource: scanInput.source, + scanFilePath: scanInput.filePath, + }, + ); + + const jsCookieFinding = findings.find(finding => finding.pkg.name === "js-cookie"); + expect(jsCookieFinding).toMatchObject({ + relationship: "transitive", + recommendedNpmTransitiveRemediation: { + kind: "update-parent-within-range", + targetChildVersion: expect.any(String), + }, + recommendedParentUpgrade: undefined, + }); + expect(jsCookieFinding?.recommendedNpmTransitiveRemediation?.reason).toContain("@aws-amplify/core@6.16.1"); + + const plan = buildSuggestedFixCommandPlan(findings, { + mode: scanInput.mode, + source: scanInput.source, + filePath: scanInput.filePath, + packages: scanInput.packages, + notes: scanInput.notes, + warnings: scanInput.warnings, + skippedDependencies: scanInput.skippedDependencies, + }); + + expect(plan?.packageManager).toBe("yarn"); + expect(plan?.command).toBe("yarn upgrade js-cookie"); + expect(plan?.sections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "parent-update:high", + command: "yarn upgrade js-cookie", + }), + ]), + ); + }, 120_000); +}); From 1c27631a52a9fbd1a5f9c63b8496dcec48bc1a10 Mon Sep 17 00:00:00 2001 From: coder-Yash886 Date: Wed, 3 Jun 2026 19:38:13 +0530 Subject: [PATCH 4/7] test(fixture): add examples/yarn-within-range fixture for tests --- examples/yarn-within-range/package.json | 10 ++++++++++ examples/yarn-within-range/yarn.lock | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 examples/yarn-within-range/package.json create mode 100644 examples/yarn-within-range/yarn.lock diff --git a/examples/yarn-within-range/package.json b/examples/yarn-within-range/package.json new file mode 100644 index 0000000..2a33952 --- /dev/null +++ b/examples/yarn-within-range/package.json @@ -0,0 +1,10 @@ +{ + "name": "yarn-within-range", + "version": "1.0.0", + "private": true, + "description": "Minimal Yarn Classic fixture: deep transitive within-range remediation should suggest yarn upgrade js-cookie.", + "license": "ISC", + "devDependencies": { + "aws-amplify": "6.16.3" + } +} diff --git a/examples/yarn-within-range/yarn.lock b/examples/yarn-within-range/yarn.lock new file mode 100644 index 0000000..c4ad131 --- /dev/null +++ b/examples/yarn-within-range/yarn.lock @@ -0,0 +1,18 @@ +# yarn lockfile v1 + + +aws-amplify@6.16.3: + version "6.16.3" + resolved "https://registry.npmjs.org/aws-amplify/-/aws-amplify-6.16.3.tgz" + dependencies: + "@aws-amplify/core" "6.16.1" + +"@aws-amplify/core@6.16.1": + version "6.16.1" + resolved "https://registry.npmjs.org/@aws-amplify/core/-/core-6.16.1.tgz" + dependencies: + js-cookie "^3.0.5" + +js-cookie@^3.0.5: + version "3.0.6" + resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.6.tgz" From 235b8949a8c297e3366a96975f1f117ec1a790a5 Mon Sep 17 00:00:00 2001 From: coder-Yash886 Date: Wed, 3 Jun 2026 19:52:09 +0530 Subject: [PATCH 5/7] Fix yarn classic parser transitive paths and enable yarn.lock remediation graph --- src/parsers/yarn-lock.ts | 182 ++++++++++++++++++++++++++++++++++++++- src/scanner.ts | 10 ++- 2 files changed, 187 insertions(+), 5 deletions(-) diff --git a/src/parsers/yarn-lock.ts b/src/parsers/yarn-lock.ts index 0ca014b..72f7b9b 100644 --- a/src/parsers/yarn-lock.ts +++ b/src/parsers/yarn-lock.ts @@ -108,7 +108,8 @@ export function loadFromYarnLock(filePath: string): PackageRef[] { throw new Error("Could not parse yarn.lock"); } - const map = new Map(); + // Build index of lockfile entries by name and version + const entries: { name: string; version: string; meta: any }[] = []; for (const [selector, meta] of Object.entries(parsed.object)) { const version = meta?.version; if (!version) continue; @@ -117,10 +118,185 @@ export function loadFromYarnLock(filePath: string): PackageRef[] { const atIndex = firstSelector.lastIndexOf("@"); if (atIndex <= 0) continue; const name = firstSelector.slice(0, atIndex); + if (!name) continue; - if (!name || !version) continue; - upsertPackage(map, { name, version, ecosystem: "npm", paths: [["project", name]] }); + entries.push({ name, version, meta }); + } + + const byName = new Map(); + for (const e of entries) { + const list = byName.get(e.name) ?? []; + list.push({ version: e.version, meta: e.meta }); + byName.set(e.name, list); + } + + // Try to read top-level package.json to find project direct deps + let projectPkg: any = null; + try { + projectPkg = JSON.parse(fs.readFileSync(path.join(path.dirname(filePath), "package.json"), "utf8")); + } catch { + projectPkg = null; + } + + const map = new Map(); + + // Helper: pick a resolved version for a dependency name. Prefer exact match to range if possible, else choose first available. + function resolveVersion(name: string, range?: string): string | null { + const candidates = byName.get(name); + if (!candidates || candidates.length === 0) return null; + if (!range) return candidates[0].version; + // Try exact match + for (const c of candidates) { + if (c.version === range) return c.version; + } + // Try simple semver caret/range match for common patterns like ^1.2.3 + const m = range.match(/^\^?(\d+)\.(\d+)\.(\d+)/); + if (m) { + const major = Number(m[1]); + const minor = Number(m[2]); + const patch = Number(m[3]); + // choose the highest version with same major that is >= range + let best: string | null = null; + for (const c of candidates) { + const parts = c.version.split(".").map(p => Number(p)); + if (parts.length < 3) continue; + const [maj, min, pat] = parts; + if (maj !== major) continue; + if (min < minor) continue; + if (best === null) best = c.version; + else { + const bestParts = best.split(".").map(p => Number(p)); + if (min > bestParts[1] || (min === bestParts[1] && pat > bestParts[2])) best = c.version; + } + } + if (best) return best; + } + // fallback to first + return candidates[0].version; + } + + // Recursively walk dependency graph to build paths + function walk(name: string, version: string, currentPath: string[], visited = new Set()) { + const key = `${name}@${version}`; + if (visited.has(key)) return; + visited.add(key); + + upsertPackage(map, { name, version, ecosystem: "npm", paths: [currentPath] }); + + const list = byName.get(name) ?? []; + const meta = list.find(l => l.version === version)?.meta; + const deps = meta?.dependencies ?? {}; + for (const [depName, depRange] of Object.entries(deps)) { + const depVersion = resolveVersion(depName, depRange); + if (!depVersion) continue; + walk(depName, depVersion, [...currentPath, depName], visited); + } + } + + // Start from project direct deps if available, else include all top-level entries + const topLevelDeps = new Set(); + if (projectPkg) { + for (const section of ["dependencies", "optionalDependencies", "devDependencies"]) { + const deps = projectPkg[section]; + if (!deps || typeof deps !== "object") continue; + for (const depName of Object.keys(deps)) topLevelDeps.add(depName); + } + } + + if (topLevelDeps.size > 0) { + for (const depName of topLevelDeps) { + const depVersion = resolveVersion(depName); + if (!depVersion) continue; + walk(depName, depVersion, ["project", depName]); + } + } else { + // no project package.json — include all entries as top-level + for (const [name, candidates] of byName.entries()) { + const version = candidates[0].version; + walk(name, version, ["project", name]); + } } return [...map.values()]; } + +export function createNpmLockGraphFromYarnLock(filePath: string) { + const content = fs.readFileSync(filePath, "utf8"); + if (isYarnBerry(content)) return null; + + const parsed = parseYarnLock(content) as any; + if (parsed.type !== "success" || !parsed.object) return null; + + const entries: { name: string; version: string; meta: any }[] = []; + for (const [selector, meta] of Object.entries(parsed.object)) { + const version = meta?.version; + if (!version) continue; + + const firstSelector = String(selector).split(",")[0].trim(); + const atIndex = firstSelector.lastIndexOf("@"); + if (atIndex <= 0) continue; + const name = firstSelector.slice(0, atIndex); + if (!name) continue; + + entries.push({ name, version, meta }); + } + + const nodesById = new Map(); + const nodeIdsByPackageKey = new Map(); + const childNodeIdsByParentNodeId = new Map(); + const rangeByParentNodeId = new Map>(); + + const byName = new Map(); + for (const e of entries) { + const list = byName.get(e.name) ?? []; + list.push({ version: e.version, meta: e.meta }); + byName.set(e.name, list); + } + + function resolveVersion(name: string, range?: string): string | null { + const candidates = byName.get(name); + if (!candidates || candidates.length === 0) return null; + if (!range) return candidates[0].version; + for (const c of candidates) { + if (c.version === range) return c.version; + } + return candidates[0].version; + } + + for (const e of entries) { + const id = `${e.name}@${e.version}`; + nodesById.set(id, { id, name: e.name, version: e.version }); + const key = `${e.name}@${e.version}`; + nodeIdsByPackageKey.set(key, [...(nodeIdsByPackageKey.get(key) ?? []), id]); + } + + for (const e of entries) { + const parentId = `${e.name}@${e.version}`; + const deps = e.meta?.dependencies ?? {}; + const ranges = new Map(); + for (const [depName, depRange] of Object.entries(deps)) { + const depVersion = resolveVersion(depName, depRange); + if (!depVersion) continue; + const childId = `${depName}@${depVersion}`; + childNodeIdsByParentNodeId.set(parentId, [...(childNodeIdsByParentNodeId.get(parentId) ?? []), childId]); + ranges.set(depName, depRange); + } + if (ranges.size > 0) rangeByParentNodeId.set(parentId, ranges); + } + + return { + nodeIdsFor(name: string, version: string | null) { + return Object.freeze([...(nodeIdsByPackageKey.get(`${name}@${version ?? "null"}`) ?? [])]); + }, + getNode(nodeId: string) { + const node = nodesById.get(nodeId); + return node ? { id: node.id, name: node.name, version: node.version, packagePath: node.id } : null; + }, + childrenFor(nodeId: string) { + return Object.freeze([...(childNodeIdsByParentNodeId.get(nodeId) ?? [])]); + }, + rangeFor(parentNodeId: string, childName: string) { + return rangeByParentNodeId.get(parentNodeId)?.get(childName) ?? null; + }, + }; +} diff --git a/src/scanner.ts b/src/scanner.ts index 0d35400..9eba48f 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -18,6 +18,7 @@ import { import { countBySeverity } from "./utils/severity.js"; import { resolveNpmTransitiveRemediation, resolveTransitiveRemediationViaRegistry } from "./remediation/npm-transitive-resolution.js"; import { loadNpmLockGraph } from "./parsers/npm-lock-graph.js"; +import { createNpmLockGraphFromYarnLock } from "./parsers/yarn-lock.js"; import { buildPnpmWorkspaceMap } from "./parsers/pnpm-lock.js"; import { buildNpmWorkspaceMap } from "./parsers/package-lock.js"; import { buildBunWorkspaceMap } from "./parsers/bun-lock.js"; @@ -279,7 +280,7 @@ export async function scanPackages( }; }); - const npmTransitiveGraph = context?.scanSource === "package-lock" && context.scanFilePath + const npmTransitiveGraph = (context?.scanSource === "package-lock" || context?.scanSource === "yarn-lock") && context.scanFilePath ? createNpmTransitiveGraphFromLockfile(context.scanFilePath, log, packages.length) : null; const npmWorkspaceMap = (() => { @@ -483,7 +484,12 @@ function createNpmTransitiveGraphFromLockfile( packageCount: number, ): NpmTransitiveGraph | null { try { - const lockGraph = loadNpmLockGraph(filePath, { includePaths: false }); + let lockGraph: any = null; + if (filePath.endsWith("yarn.lock")) { + lockGraph = createNpmLockGraphFromYarnLock(filePath); + } else { + lockGraph = loadNpmLockGraph(filePath, { includePaths: false }); + } log("npm transitive graph", { status: "built", nodes: packageCount }); return { nodeIdsFor(name: string, version: string | null) { From 982ee1b566445773ae351d9fe62de6a969ed254e Mon Sep 17 00:00:00 2001 From: coder-Yash886 Date: Thu, 4 Jun 2026 18:51:00 +0530 Subject: [PATCH 6/7] cleanup: isolate --create-pr feature and revert Yarn transitive graph changes --- examples/yarn-within-range/package.json | 10 -- examples/yarn-within-range/yarn.lock | 18 --- src/parsers/yarn-lock.ts | 182 +----------------------- src/scanner.ts | 10 +- tests/example-fixtures.test.ts | 68 --------- 5 files changed, 5 insertions(+), 283 deletions(-) delete mode 100644 examples/yarn-within-range/package.json delete mode 100644 examples/yarn-within-range/yarn.lock delete mode 100644 tests/example-fixtures.test.ts diff --git a/examples/yarn-within-range/package.json b/examples/yarn-within-range/package.json deleted file mode 100644 index 2a33952..0000000 --- a/examples/yarn-within-range/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "yarn-within-range", - "version": "1.0.0", - "private": true, - "description": "Minimal Yarn Classic fixture: deep transitive within-range remediation should suggest yarn upgrade js-cookie.", - "license": "ISC", - "devDependencies": { - "aws-amplify": "6.16.3" - } -} diff --git a/examples/yarn-within-range/yarn.lock b/examples/yarn-within-range/yarn.lock deleted file mode 100644 index c4ad131..0000000 --- a/examples/yarn-within-range/yarn.lock +++ /dev/null @@ -1,18 +0,0 @@ -# yarn lockfile v1 - - -aws-amplify@6.16.3: - version "6.16.3" - resolved "https://registry.npmjs.org/aws-amplify/-/aws-amplify-6.16.3.tgz" - dependencies: - "@aws-amplify/core" "6.16.1" - -"@aws-amplify/core@6.16.1": - version "6.16.1" - resolved "https://registry.npmjs.org/@aws-amplify/core/-/core-6.16.1.tgz" - dependencies: - js-cookie "^3.0.5" - -js-cookie@^3.0.5: - version "3.0.6" - resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.6.tgz" diff --git a/src/parsers/yarn-lock.ts b/src/parsers/yarn-lock.ts index 72f7b9b..0ca014b 100644 --- a/src/parsers/yarn-lock.ts +++ b/src/parsers/yarn-lock.ts @@ -108,126 +108,7 @@ export function loadFromYarnLock(filePath: string): PackageRef[] { throw new Error("Could not parse yarn.lock"); } - // Build index of lockfile entries by name and version - const entries: { name: string; version: string; meta: any }[] = []; - for (const [selector, meta] of Object.entries(parsed.object)) { - const version = meta?.version; - if (!version) continue; - - const firstSelector = String(selector).split(",")[0].trim(); - const atIndex = firstSelector.lastIndexOf("@"); - if (atIndex <= 0) continue; - const name = firstSelector.slice(0, atIndex); - if (!name) continue; - - entries.push({ name, version, meta }); - } - - const byName = new Map(); - for (const e of entries) { - const list = byName.get(e.name) ?? []; - list.push({ version: e.version, meta: e.meta }); - byName.set(e.name, list); - } - - // Try to read top-level package.json to find project direct deps - let projectPkg: any = null; - try { - projectPkg = JSON.parse(fs.readFileSync(path.join(path.dirname(filePath), "package.json"), "utf8")); - } catch { - projectPkg = null; - } - const map = new Map(); - - // Helper: pick a resolved version for a dependency name. Prefer exact match to range if possible, else choose first available. - function resolveVersion(name: string, range?: string): string | null { - const candidates = byName.get(name); - if (!candidates || candidates.length === 0) return null; - if (!range) return candidates[0].version; - // Try exact match - for (const c of candidates) { - if (c.version === range) return c.version; - } - // Try simple semver caret/range match for common patterns like ^1.2.3 - const m = range.match(/^\^?(\d+)\.(\d+)\.(\d+)/); - if (m) { - const major = Number(m[1]); - const minor = Number(m[2]); - const patch = Number(m[3]); - // choose the highest version with same major that is >= range - let best: string | null = null; - for (const c of candidates) { - const parts = c.version.split(".").map(p => Number(p)); - if (parts.length < 3) continue; - const [maj, min, pat] = parts; - if (maj !== major) continue; - if (min < minor) continue; - if (best === null) best = c.version; - else { - const bestParts = best.split(".").map(p => Number(p)); - if (min > bestParts[1] || (min === bestParts[1] && pat > bestParts[2])) best = c.version; - } - } - if (best) return best; - } - // fallback to first - return candidates[0].version; - } - - // Recursively walk dependency graph to build paths - function walk(name: string, version: string, currentPath: string[], visited = new Set()) { - const key = `${name}@${version}`; - if (visited.has(key)) return; - visited.add(key); - - upsertPackage(map, { name, version, ecosystem: "npm", paths: [currentPath] }); - - const list = byName.get(name) ?? []; - const meta = list.find(l => l.version === version)?.meta; - const deps = meta?.dependencies ?? {}; - for (const [depName, depRange] of Object.entries(deps)) { - const depVersion = resolveVersion(depName, depRange); - if (!depVersion) continue; - walk(depName, depVersion, [...currentPath, depName], visited); - } - } - - // Start from project direct deps if available, else include all top-level entries - const topLevelDeps = new Set(); - if (projectPkg) { - for (const section of ["dependencies", "optionalDependencies", "devDependencies"]) { - const deps = projectPkg[section]; - if (!deps || typeof deps !== "object") continue; - for (const depName of Object.keys(deps)) topLevelDeps.add(depName); - } - } - - if (topLevelDeps.size > 0) { - for (const depName of topLevelDeps) { - const depVersion = resolveVersion(depName); - if (!depVersion) continue; - walk(depName, depVersion, ["project", depName]); - } - } else { - // no project package.json — include all entries as top-level - for (const [name, candidates] of byName.entries()) { - const version = candidates[0].version; - walk(name, version, ["project", name]); - } - } - - return [...map.values()]; -} - -export function createNpmLockGraphFromYarnLock(filePath: string) { - const content = fs.readFileSync(filePath, "utf8"); - if (isYarnBerry(content)) return null; - - const parsed = parseYarnLock(content) as any; - if (parsed.type !== "success" || !parsed.object) return null; - - const entries: { name: string; version: string; meta: any }[] = []; for (const [selector, meta] of Object.entries(parsed.object)) { const version = meta?.version; if (!version) continue; @@ -236,67 +117,10 @@ export function createNpmLockGraphFromYarnLock(filePath: string) { const atIndex = firstSelector.lastIndexOf("@"); if (atIndex <= 0) continue; const name = firstSelector.slice(0, atIndex); - if (!name) continue; - - entries.push({ name, version, meta }); - } - - const nodesById = new Map(); - const nodeIdsByPackageKey = new Map(); - const childNodeIdsByParentNodeId = new Map(); - const rangeByParentNodeId = new Map>(); - - const byName = new Map(); - for (const e of entries) { - const list = byName.get(e.name) ?? []; - list.push({ version: e.version, meta: e.meta }); - byName.set(e.name, list); - } - - function resolveVersion(name: string, range?: string): string | null { - const candidates = byName.get(name); - if (!candidates || candidates.length === 0) return null; - if (!range) return candidates[0].version; - for (const c of candidates) { - if (c.version === range) return c.version; - } - return candidates[0].version; - } - - for (const e of entries) { - const id = `${e.name}@${e.version}`; - nodesById.set(id, { id, name: e.name, version: e.version }); - const key = `${e.name}@${e.version}`; - nodeIdsByPackageKey.set(key, [...(nodeIdsByPackageKey.get(key) ?? []), id]); - } - for (const e of entries) { - const parentId = `${e.name}@${e.version}`; - const deps = e.meta?.dependencies ?? {}; - const ranges = new Map(); - for (const [depName, depRange] of Object.entries(deps)) { - const depVersion = resolveVersion(depName, depRange); - if (!depVersion) continue; - const childId = `${depName}@${depVersion}`; - childNodeIdsByParentNodeId.set(parentId, [...(childNodeIdsByParentNodeId.get(parentId) ?? []), childId]); - ranges.set(depName, depRange); - } - if (ranges.size > 0) rangeByParentNodeId.set(parentId, ranges); + if (!name || !version) continue; + upsertPackage(map, { name, version, ecosystem: "npm", paths: [["project", name]] }); } - return { - nodeIdsFor(name: string, version: string | null) { - return Object.freeze([...(nodeIdsByPackageKey.get(`${name}@${version ?? "null"}`) ?? [])]); - }, - getNode(nodeId: string) { - const node = nodesById.get(nodeId); - return node ? { id: node.id, name: node.name, version: node.version, packagePath: node.id } : null; - }, - childrenFor(nodeId: string) { - return Object.freeze([...(childNodeIdsByParentNodeId.get(nodeId) ?? [])]); - }, - rangeFor(parentNodeId: string, childName: string) { - return rangeByParentNodeId.get(parentNodeId)?.get(childName) ?? null; - }, - }; + return [...map.values()]; } diff --git a/src/scanner.ts b/src/scanner.ts index 9eba48f..0d35400 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -18,7 +18,6 @@ import { import { countBySeverity } from "./utils/severity.js"; import { resolveNpmTransitiveRemediation, resolveTransitiveRemediationViaRegistry } from "./remediation/npm-transitive-resolution.js"; import { loadNpmLockGraph } from "./parsers/npm-lock-graph.js"; -import { createNpmLockGraphFromYarnLock } from "./parsers/yarn-lock.js"; import { buildPnpmWorkspaceMap } from "./parsers/pnpm-lock.js"; import { buildNpmWorkspaceMap } from "./parsers/package-lock.js"; import { buildBunWorkspaceMap } from "./parsers/bun-lock.js"; @@ -280,7 +279,7 @@ export async function scanPackages( }; }); - const npmTransitiveGraph = (context?.scanSource === "package-lock" || context?.scanSource === "yarn-lock") && context.scanFilePath + const npmTransitiveGraph = context?.scanSource === "package-lock" && context.scanFilePath ? createNpmTransitiveGraphFromLockfile(context.scanFilePath, log, packages.length) : null; const npmWorkspaceMap = (() => { @@ -484,12 +483,7 @@ function createNpmTransitiveGraphFromLockfile( packageCount: number, ): NpmTransitiveGraph | null { try { - let lockGraph: any = null; - if (filePath.endsWith("yarn.lock")) { - lockGraph = createNpmLockGraphFromYarnLock(filePath); - } else { - lockGraph = loadNpmLockGraph(filePath, { includePaths: false }); - } + const lockGraph = loadNpmLockGraph(filePath, { includePaths: false }); log("npm transitive graph", { status: "built", nodes: packageCount }); return { nodeIdsFor(name: string, version: string | null) { diff --git a/tests/example-fixtures.test.ts b/tests/example-fixtures.test.ts deleted file mode 100644 index 15f5453..0000000 --- a/tests/example-fixtures.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { loadPackages } from "../src/parsers/index.js"; -import { buildSuggestedFixCommandPlan } from "../src/remediation/fix-commands.js"; -import { scanPackages } from "../src/scanner.js"; -import { readDirectDependencyNames } from "../src/utils/package-json.js"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - -describe("crafted example fixtures", () => { - it("yarn-within-range reconstructs deep paths and suggests yarn upgrade js-cookie", async () => { - const fixtureDir = path.join(repoRoot, "examples", "yarn-within-range"); - const scanInput = loadPackages(fixtureDir, false, 4); - const directDependencyNames = readDirectDependencyNames(fixtureDir, false); - - expect(scanInput.source).toBe("yarn-lock"); - - const jsCookie = scanInput.packages.find(pkg => pkg.name === "js-cookie" && pkg.version === "3.0.6"); - expect(jsCookie?.paths).toEqual( - expect.arrayContaining([ - ["project", "aws-amplify", "@aws-amplify/core", "js-cookie"], - ]), - ); - - const findings = await scanPackages( - scanInput.packages, - 100, - { failOn: "critical", batchSize: "100" }, - { - directDependencyNames, - scanSource: scanInput.source, - scanFilePath: scanInput.filePath, - }, - ); - - const jsCookieFinding = findings.find(finding => finding.pkg.name === "js-cookie"); - expect(jsCookieFinding).toMatchObject({ - relationship: "transitive", - recommendedNpmTransitiveRemediation: { - kind: "update-parent-within-range", - targetChildVersion: expect.any(String), - }, - recommendedParentUpgrade: undefined, - }); - expect(jsCookieFinding?.recommendedNpmTransitiveRemediation?.reason).toContain("@aws-amplify/core@6.16.1"); - - const plan = buildSuggestedFixCommandPlan(findings, { - mode: scanInput.mode, - source: scanInput.source, - filePath: scanInput.filePath, - packages: scanInput.packages, - notes: scanInput.notes, - warnings: scanInput.warnings, - skippedDependencies: scanInput.skippedDependencies, - }); - - expect(plan?.packageManager).toBe("yarn"); - expect(plan?.command).toBe("yarn upgrade js-cookie"); - expect(plan?.sections).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: "parent-update:high", - command: "yarn upgrade js-cookie", - }), - ]), - ); - }, 120_000); -}); From 730b6acf779e812d24b44ef8674d2840c3876b28 Mon Sep 17 00:00:00 2001 From: coder-Yash886 Date: Fri, 5 Jun 2026 07:36:14 +0530 Subject: [PATCH 7/7] fix(create-pr): skip missing lockfiles when staging dependency files Check file existence before git add so npm-only repos do not fail on missing yarn.lock, pnpm-lock.yaml, and other optional lockfiles. --- src/utils/create-pr.ts | 5 +++++ tests/create-pr.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/utils/create-pr.ts b/src/utils/create-pr.ts index bc4e9bd..9a70eca 100644 --- a/src/utils/create-pr.ts +++ b/src/utils/create-pr.ts @@ -1,4 +1,6 @@ import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; import type { Finding } from "../types.js"; import { severityOrder } from "../constants.js"; import { normalizeSeverity } from "../osv/severity.js"; @@ -163,6 +165,9 @@ async function hasAnyChanges(projectPath: string): Promise { export async function stageDependencyFilesOnly(projectPath: string): Promise { for (const filePath of DEPENDENCY_FILES_TO_STAGE) { + if (!fs.existsSync(path.join(projectPath, filePath))) { + continue; + } const add = await runCommand("git", ["add", "--", filePath], projectPath); if (add.error || add.status !== 0) { throw new Error(`Failed to stage dependency file ${filePath}: ${add.stderr.trim() || add.error?.message || "unknown error"}`); diff --git a/tests/create-pr.test.ts b/tests/create-pr.test.ts index 4957727..d176beb 100644 --- a/tests/create-pr.test.ts +++ b/tests/create-pr.test.ts @@ -1,3 +1,7 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { buildPullRequestBody, buildPullRequestTitle, @@ -5,6 +9,7 @@ import { defaultFixBranchName, findingsMeetFailOnThreshold, selectAvailableBranchName, + stageDependencyFilesOnly, } from "../src/utils/create-pr.js"; import type { Finding, OsvVuln } from "../src/types.js"; import { validateOptions } from "../src/cli/validate.js"; @@ -76,6 +81,29 @@ describe("create-pr helpers", () => { const branch = await selectAvailableBranchName(process.cwd(), "cve-lite/fix-2026-06-02"); expect(branch).toMatch(/^cve-lite\/fix-2026-06-02(?:-\d+)?$/); }); + + it("stages only existing dependency files without failing on missing lockfiles", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-create-pr-")); + try { + execSync("git init", { cwd: tmpDir, stdio: "ignore" }); + execSync('git config user.email "test@example.com"', { cwd: tmpDir, stdio: "ignore" }); + execSync('git config user.name "Test User"', { cwd: tmpDir, stdio: "ignore" }); + + fs.writeFileSync(path.join(tmpDir, "package.json"), '{"name":"test"}\n'); + fs.writeFileSync(path.join(tmpDir, "package-lock.json"), '{"lockfileVersion":3}\n'); + execSync("git add package.json package-lock.json", { cwd: tmpDir, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: tmpDir, stdio: "ignore" }); + + fs.writeFileSync(path.join(tmpDir, "package.json"), '{"name":"test","version":"1.0.1"}\n'); + + await expect(stageDependencyFilesOnly(tmpDir)).resolves.toBeUndefined(); + + const staged = execSync("git diff --cached --name-only", { cwd: tmpDir, encoding: "utf8" }); + expect(staged.trim().split("\n")).toEqual(["package.json"]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); describe("create-pr CLI options", () => {