Repository navigation
fix(remediation): emit yarn up instead of yarn upgrade for yarn berry (#1537) #1539
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
80c0ada
cfce8c5
10a113d
7bf583f
1129697
8316db5
c9d6860
883827e
252f1eb
617e284
3056eec
d43ed40
59be078
1ac1f93
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,11 @@ | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { parse as parseYaml } from "yaml"; | ||
| import { parse as parseYarnLock } from "yarn-lockfile"; | ||
| import type { PackageRef } from "../types.js"; | ||
| import { looksLikeVersion, normalizeRawVersion } from "../utils/version.js"; | ||
| import { upsertPackage, MAX_PATHS_PER_PACKAGE } from "./utils.js"; | ||
| import { resolveWorkspacePackageJsonPaths } from "../utils/package-json.js"; | ||
|
|
||
| const MAX_PATH_DEPTH = 10; | ||
|
|
||
|
|
@@ -89,7 +91,10 @@ function readRootPackageJson(filePath: string): any | null { | |
| export function buildYarnWorkspaceMap(filePath: string): Map<string, string[]> { | ||
| const map = new Map<string, string[]>(); | ||
|
|
||
| for (const { name: wsName, pkg: wsPkg } of readWorkspacePackages(filePath)) { | ||
| const members = readWorkspacePackages(filePath); | ||
| const root = readRootPackageJson(filePath); | ||
| const manifests = root && members.length > 0 ? [{ name: ".", pkg: root }, ...members] : members; | ||
| for (const { name: wsName, pkg: wsPkg } of manifests) { | ||
| for (const depSection of ["dependencies", "optionalDependencies", "devDependencies"]) { | ||
| const deps = wsPkg[depSection]; | ||
| if (!deps || typeof deps !== "object") continue; | ||
|
|
@@ -119,35 +124,15 @@ function readWorkspacePackages(filePath: string, rootPkg?: any): { name: string; | |
| if (!patterns.length) return []; | ||
|
|
||
| const result: { name: string; pkg: any }[] = []; | ||
| for (const pattern of patterns) { | ||
| const parts = pattern.split("/"); | ||
| const lastPart = parts[parts.length - 1]; | ||
| const baseParts = parts.slice(0, -1); | ||
|
|
||
| let wsDirs: string[]; | ||
| if (lastPart === "*") { | ||
| const baseDir = path.join(dir, ...baseParts); | ||
| try { | ||
| wsDirs = fs.readdirSync(baseDir, { withFileTypes: true }) | ||
| .filter(d => d.isDirectory()) | ||
| .map(d => [...baseParts, d.name].join("/")); | ||
| } catch { | ||
| continue; | ||
| } | ||
| } else { | ||
| wsDirs = [pattern]; | ||
| } | ||
|
|
||
| for (const wsDir of wsDirs) { | ||
| let wsPkg: any; | ||
| try { | ||
| wsPkg = JSON.parse(fs.readFileSync(path.join(dir, wsDir, "package.json"), "utf8")); | ||
| } catch { | ||
| continue; | ||
| } | ||
| if (!wsPkg.name) continue; | ||
| result.push({ name: wsPkg.name, pkg: wsPkg }); | ||
| for (const packageJsonPath of resolveWorkspacePackageJsonPaths(dir, patterns)) { | ||
| let wsPkg: any; | ||
| try { | ||
| wsPkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); | ||
| } catch { | ||
| continue; | ||
| } | ||
| if (!wsPkg?.name) continue; | ||
| result.push({ name: wsPkg.name, pkg: wsPkg }); | ||
| } | ||
|
|
||
| return result; | ||
|
|
@@ -157,6 +142,96 @@ export function isYarnBerry(content: string): boolean { | |
| return content.startsWith('__metadata:') || content.includes('\n__metadata:'); | ||
| } | ||
|
|
||
| export type YarnBerryInfo = { | ||
| isBerry: boolean; | ||
| supportsRecursiveUp: boolean | "ambiguous"; | ||
| ambiguityReason?: "yarnPath" | "metadata-v4" | "missing-metadata"; | ||
| }; | ||
|
|
||
| function readPackageManagerYarnMajor(filePath: string): number | null { | ||
| const root = readRootPackageJson(filePath); | ||
| if (typeof root?.packageManager === "string") { | ||
| const match = root.packageManager.match(/^yarn@(\d+)\.(\d+)(?:\.(\d+))?/); | ||
| if (match) return parseInt(match[1], 10); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function readActiveYarnPath(projectDir: string): string | null { | ||
| try { | ||
| const rcPath = path.join(projectDir, ".yarnrc.yml"); | ||
| if (!fs.existsSync(rcPath)) return null; | ||
| const content = fs.readFileSync(rcPath, "utf8"); | ||
| const parsed = parseYaml(content); | ||
| if (!parsed || typeof parsed !== "object") return null; | ||
| if (parsed.ignorePath === true || parsed.ignorePath === "true") return null; | ||
| if (typeof parsed.yarnPath === "string" && parsed.yarnPath.trim()) { | ||
| return parsed.yarnPath.trim(); | ||
| } | ||
| } catch { | ||
| // best-effort | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function berryInfoFromMajor(major: number): YarnBerryInfo | null { | ||
| if (major >= 3) return { isBerry: true, supportsRecursiveUp: true }; | ||
| if (major === 2) return { isBerry: true, supportsRecursiveUp: false }; | ||
| return null; | ||
| } | ||
|
|
||
| export function detectYarnBerryInfo( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The version-to-capability mapping is written out twice in here, once for packageManager and once for yarnPath. A small helper would remove the repeat and bring this function under 60 lines.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| filePath: string | null | undefined, | ||
| lockContent: string, | ||
| ): YarnBerryInfo { | ||
| if (!isYarnBerry(lockContent)) { | ||
| return { isBerry: false, supportsRecursiveUp: false }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| if (filePath) { | ||
| const projectDir = path.dirname(filePath); | ||
|
|
||
| // 1. Check for active yarnPath in .yarnrc.yml | ||
| const activeYarnPath = readActiveYarnPath(projectDir); | ||
| if (activeYarnPath) { | ||
| const match = path.basename(activeYarnPath).match(/yarn-(\d+)\.(\d+)(?:\.(\d+))?/); | ||
| if (match) { | ||
| const info = berryInfoFromMajor(parseInt(match[1], 10)); | ||
| if (info) return info; | ||
| } | ||
| return { isBerry: true, supportsRecursiveUp: "ambiguous", ambiguityReason: "yarnPath" }; | ||
| } | ||
|
|
||
| // 2. When no yarnPath is configured (or ignorePath: true), check packageManager | ||
| const pmMajor = readPackageManagerYarnMajor(filePath); | ||
| if (pmMajor !== null) { | ||
| const info = berryInfoFromMajor(pmMajor); | ||
| if (info) return info; | ||
| } | ||
| } | ||
|
|
||
| // 3. Fallback to lockfile metadata version: | ||
| // __metadata: | ||
| // version: 8 | ||
| const metaMatch = lockContent.match(/__metadata:[\s\S]*?version:\s*["']?(\d+)["']?/); | ||
| if (metaMatch) { | ||
| const lockVersion = parseInt(metaMatch[1], 10); | ||
| // Version 5 and higher support recursive upgrades (yarn up -R) | ||
| if (lockVersion >= 5) { | ||
| return { isBerry: true, supportsRecursiveUp: true }; | ||
| } | ||
| // Version 4 is ambiguous: shared by Yarn 2.4.3 and Yarn 3.0.0 | ||
| if (lockVersion === 4) { | ||
| return { isBerry: true, supportsRecursiveUp: "ambiguous", ambiguityReason: "metadata-v4" }; | ||
| } | ||
| // Versions < 4 are early Berry v2 | ||
| return { isBerry: true, supportsRecursiveUp: false }; | ||
| } | ||
|
|
||
| // Without metadata version or exact version configuration, capability is ambiguous | ||
| return { isBerry: true, supportsRecursiveUp: "ambiguous", ambiguityReason: "missing-metadata" }; | ||
| } | ||
|
|
||
| function pathsEqual(left: string[], right: string[]): boolean { | ||
| if (left.length !== right.length) return false; | ||
| return left.every((value, index) => value === right[index]); | ||
|
|
@@ -177,7 +252,7 @@ function collectYarnPaths( | |
| map: Map<string, PackageRef>, | ||
| taggedPathsByKey: Map<string, TaggedPath[]>, | ||
| ): void { | ||
| const queue = seeds.map(seed => ({ key: seed.key, path: seed.path })); | ||
| const queue = seeds.map(seed => ({ key: seed.key, path: seed.path, depth: 0 })); | ||
| const visitedStates = new Set<string>(); | ||
|
|
||
| let head = 0; | ||
|
|
@@ -187,11 +262,18 @@ function collectYarnPaths( | |
| if (!ref) continue; | ||
|
|
||
| const nextPath = [...current.path, ref.name]; | ||
| // Preserve reachability before display-path de-duplication: registry and | ||
| // workspace parents can produce exactly the same printable path. | ||
| const pkg = map.get(ref.key); | ||
| if (pkg && current.depth > 0) { | ||
| const paths = pkg.yarnTransitivePaths ??= []; | ||
| if (paths.length < MAX_PATHS_PER_PACKAGE && !paths.some(p => pathsEqual(p, nextPath))) paths.push(nextPath); | ||
| } | ||
| const stateKey = `${ref.key}>${nextPath.join(">")}`; | ||
| if (visitedStates.has(stateKey)) continue; | ||
| visitedStates.add(stateKey); | ||
|
|
||
| // Only record paths for packages that exist in the lockfile map, matching prior behavior. | ||
| // Only record paths for packages that exist in the lockfile map. | ||
| if (map.has(ref.key)) addTaggedPath(taggedPathsByKey, ref.key, nextPath); | ||
|
|
||
| if (nextPath.length >= MAX_PATH_DEPTH) continue; | ||
|
|
@@ -201,7 +283,7 @@ function collectYarnPaths( | |
| } | ||
|
|
||
| for (const child of graph.get(ref.key) ?? []) { | ||
| queue.push({ key: child, path: nextPath }); | ||
| queue.push({ key: child, path: nextPath, depth: current.depth + 1 }); | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -228,6 +310,16 @@ function resolveDepSeeds( | |
| selectorToKey.get(`${depName}@${depRange}`) ?? | ||
| selectorToKey.get(`${depName}@npm:${depRange}`); | ||
| if (resolved) { | ||
| if (pathPrefix.length === 2) { | ||
| const direct = map.get(resolved); | ||
| if (direct) direct.workspaceDirect = true; | ||
| } | ||
| const direct = map.get(resolved); | ||
| if (direct) { | ||
| const workspaces = direct.yarnDirectWorkspaces ??= []; | ||
| const workspace = pathPrefix[1] ?? "."; | ||
| if (!workspaces.includes(workspace)) workspaces.push(workspace); | ||
| } | ||
| seeds.push({ key: resolved, path: pathPrefix, dev }); | ||
| continue; | ||
| } | ||
|
|
@@ -237,6 +329,11 @@ function resolveDepSeeds( | |
| const name = String(depName); | ||
| const key = `${name}@${fallbackVersion}`; | ||
| upsertPackage(map, { name, version: fallbackVersion, ecosystem: "npm", paths: [] }); | ||
| const direct = map.get(key)!; | ||
| if (pathPrefix.length === 2) direct.workspaceDirect = true; | ||
| const workspaces = direct.yarnDirectWorkspaces ??= []; | ||
| const workspace = pathPrefix[1] ?? "."; | ||
| if (!workspaces.includes(workspace)) workspaces.push(workspace); | ||
| addTaggedPath(taggedPathsByKey, key, [...pathPrefix, name]); | ||
| // Unresolved fallback prod deps are not graph nodes, so record their key as | ||
| // production-reachable directly (mirrors the resolved production roots). | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: OWASP/cve-lite-cli
Length of output: 6310
Script executed:
Repository: OWASP/cve-lite-cli
Length of output: 42560
Web query:
Yarn Berry configuration YARN_RC_FILENAME ancestor directories yarnPath ignorePath Yarn 2 3 source Configuration find rc filesResult:
Script executed:
Repository: OWASP/cve-lite-cli
Length of output: 45749
Web query:
Yarn 3.8.7 yarn up -R recursive yarnPath parent directory YARN_RC_FILENAME configuration sourceResult:
Resolve the effective Yarn configuration before skipping refreshes
If an ancestor selects Yarn 3.8 and the lockfile has metadata version 4, the planner can skip a runnable
yarn up -Rwhen no chain resolution exists. I suggest resolving Yarn’s effective configuration before deciding whether to suppress the refresh.As per path instructions, flag “vague guidance where a concrete command is possible.”