import { isEmptyObject, isNonEmptyArray, isNonEmptyObject, isString, } from '@sindresorhus/is'; import upath from 'upath'; import { logger } from '../../../logger/index.ts'; import { readLocalFile } from '../../../util/fs/index.ts'; import { minimatch } from '../../../util/minimatch.ts'; import { api as semver } from '../../versioning/deno/index.ts'; import type { PackageDependency, PackageFile } from '../types.ts'; import { detectNodeCompatWorkspaces, extractDenoCompatiblePackageJson, } from './compat.ts'; import { DenoLock } from './schema.ts'; import type { DenoManagerData, LockFile, WorkspaceContext } from './types.ts'; import { denoLandRegex, depValueRegex } from './utils.ts'; export async function getDenoLock(filePath: string): Promise { const lockfileContent = await readLocalFile(filePath, 'utf8'); if (!lockfileContent) { logger.debug({ filePath }, 'Deno: unable to read lockfile'); return { lockedVersions: {} }; } const parsedLockfile = DenoLock.safeParse(lockfileContent); if (!parsedLockfile.success) { logger.debug( { filePath, err: parsedLockfile.error }, 'Deno: unable to parse lockfile', ); return { lockedVersions: {} }; } if (parsedLockfile.data.lockfileVersion < 5) { logger.warn( { filePath }, 'Deno: unsupported lockfile version. Please update the lock file on your own.', ); return { lockedVersions: {} }; } return parsedLockfile.data; } export function getLockedVersion( deps: PackageDependency, lockFileContent: LockFile, ): string | undefined | null { if (isEmptyObject(lockFileContent)) { return null; } const { datasource, currentRawValue, currentValue, depName } = deps; if (datasource === 'deno') { // "remote": { // "https://deno.land/std@0.223.0/fs/mod.ts": "c25e6802cbf27f3050f60b26b00c2d8dba1cb7fcdafe34c66006a7473b7b34d4", // }, if ( lockFileContent.remoteVersions && lockFileContent.remoteVersions.size > 0 && currentRawValue && lockFileContent.remoteVersions.has(currentRawValue) ) { const match = denoLandRegex.exec(currentRawValue); return match?.groups?.currentValue; } // "redirects": { // "https://deno.land/std": "https://deno.land/std@0.224.0" // }, const key = currentValue && depName ? `${depName}@${currentValue}` : depName; if ( lockFileContent.redirectVersions && isNonEmptyObject(lockFileContent.redirectVersions) && key && lockFileContent.redirectVersions[key] ) { const match = denoLandRegex.exec( // SAFETY: checked above lockFileContent.redirectVersions[key], ); return match?.groups?.currentValue; } } // jsr and npm datasource if (datasource === 'jsr' || datasource === 'npm') { if ( !lockFileContent.lockedVersions || isEmptyObject(lockFileContent.lockedVersions) ) { return null; } // find "jsr:@scope/name@1.2.3" from "jsr:@scope/name@1.2.3" if (currentRawValue && lockFileContent.lockedVersions[currentRawValue]) { return lockFileContent.lockedVersions[currentRawValue]; } // find "jsr:@scope/name@*" from "jsr:@scope/name" if ( currentRawValue && lockFileContent.lockedVersions[`${currentRawValue}@*`] ) { return lockFileContent.lockedVersions[`${currentRawValue}@*`]; } for (const [key, value] of Object.entries(lockFileContent.lockedVersions)) { const match = depValueRegex.exec(key); // find "jsr:@scope/name@1" intersects "jsr:@scope/name@^1.0.0" if ( isString(depName) && match?.groups?.depName === depName && match?.groups?.datasource === datasource && currentValue && match?.groups?.currentValue && // SAFETY: npm semver define it semver.intersects!(match.groups.currentValue, currentValue) ) { return value; } } return null; } return null; } export async function collectPackageJsonAsWorkspaceMember( packageFiles: PackageFile[], ): Promise { // detect package.json as members of a deno workspace const workspaceRoots = packageFiles.filter( (pkg) => isNonEmptyArray(pkg.managerData?.workspaces) && upath.basename(pkg.packageFile).startsWith('deno.json'), ); for (const workspaceRoot of workspaceRoots) { const result = await detectNodeCompatWorkspaces(workspaceRoot); const { packagePaths } = result; for (const packagePath of packagePaths) { const packageFile = await extractDenoCompatiblePackageJson(packagePath); if (packageFile) { const pkg = { ...packageFile, lockFiles: workspaceRoot.lockFiles, }; packageFiles.push(pkg); } } } } export function normalizeWorkspace( packageFiles: PackageFile[], ): void { // determine workspace root to collect lock files of workspace members const workspaceContexts: WorkspaceContext[] = []; // create reference map for packageFile object const packageMap: Record> = {}; for (const pkg of packageFiles) { packageMap[pkg.packageFile] = pkg; } for (const pkg of packageFiles) { const workspaces = pkg.managerData?.workspaces; if (isNonEmptyArray(workspaces)) { const rootDir = upath.dirname(pkg.packageFile); const matchers = workspaces.map((pattern) => // allow ./sub/* to match sub minimatch(upath.normalize(pattern), { dot: true, partial: true, }), ); workspaceContexts.push({ lockFiles: pkg.lockFiles, rootDir, packageFile: pkg.packageFile, matchers, }); } } // remove nested workspace // if the workspace is a subdirectory of another workspace, the nested is invalid const validContexts: typeof workspaceContexts = []; const invalidPackageFiles = new Set(); for (const [i, currentContext] of workspaceContexts.entries()) { let isNested = false; for (const [j, otherContext] of workspaceContexts.entries()) { if (i === j) { continue; } const found = otherContext.matchers.some((matcher) => matcher.match(currentContext.rootDir), ); if (found) { isNested = true; invalidPackageFiles.add(currentContext.packageFile); break; } } if (!isNested) { validContexts.push(currentContext); } } for (const packageFile of invalidPackageFiles) { const pkg = packageMap[packageFile]; // remove invalid workspace delete pkg?.managerData?.workspaces; } // supply lock files to workspace members from their root const workspaceRootFiles = new Set(); for (const pkg of packageFiles) { const workspaces = pkg.managerData?.workspaces; if (isNonEmptyArray(workspaces)) { workspaceRootFiles.add(pkg.packageFile); } } for (const pkg of packageFiles) { if (workspaceRootFiles.has(pkg.packageFile)) { continue; } for (const context of workspaceContexts) { const { rootDir, matchers, lockFiles } = context; const pkgRelativePath = upath.relative(rootDir, pkg.packageFile); const pkgDir = upath.dirname(pkgRelativePath); const isMatch = matchers.some((matcher) => matcher.match(pkgDir)); if (isMatch) { pkg.lockFiles = lockFiles; break; } } } } async function applyLockedVersion( packageFiles: PackageFile[], ): Promise { // apply locked versions from lock files // use cache to avoid reading the same lock file multiple times const lockFileCache: Record = {}; for (const pkg of packageFiles) { if (!isNonEmptyArray(pkg.lockFiles)) { continue; } const lockFile = pkg.lockFiles[0]; let lockFileContent = lockFileCache[lockFile]; if (!lockFileContent) { lockFileContent = await getDenoLock(lockFile); lockFileCache[lockFile] = lockFileContent; } const withLockedVersionDeps = pkg.deps.map((dep) => { const lockedVersion = getLockedVersion(dep, lockFileContent); return lockedVersion ? { ...dep, lockedVersion, } : dep; }); pkg.deps = withLockedVersionDeps; } } export async function postExtract( packageFiles: PackageFile[], ): Promise { await collectPackageJsonAsWorkspaceMember(packageFiles); normalizeWorkspace(packageFiles); await applyLockedVersion(packageFiles); }