UNPKG

flow-typed

Version:

A repository of high quality flow type definitions

643 lines (567 loc) 18 kB
// @flow import { ensureCacheRepo, getCacheRepoDir, verifyCLIVersion, CACHE_REPO_EXPIRY, } from '../cacheRepoUtils'; import {getSignedCodeVersion, verifySignedCode} from '../codeSign'; import {getFilesInDir, isExcludedFile} from '../fileUtils'; import type {FlowVersion} from '../flowVersion'; import { disjointVersionsAll as disjointFlowVersionsAll, parseDirString as parseFlowDirString, toSemverString as flowVersionToSemver, } from '../flowVersion'; import {findLatestFileCommitHash} from '../git'; import {fs, path} from '../node'; import { getRangeLowerBound, getRangeUpperBound, versionToString, type Version, } from '../semver'; import semver from 'semver'; import got from 'got'; import {ValidationError} from '../ValidationError'; import {TEST_FILE_NAME_RE} from '../libDefs'; const P = Promise; export type NpmLibDef = { scope: null | string, name: string, version: string, flowVersion: FlowVersion, path: string, testFilePaths: Array<string>, depVersions: { [deps: string]: Array<string>, } | null, }; export type NpmLibDefFilter = { type: 'exact', pkgName: string, pkgVersion: string, flowVersion?: FlowVersion, }; /** * When in a nested directory of npm libdefs such as package/libdef dir * find and return the root npm dir */ export const getNpmLibDefDirFromNested = (path: string): string => { const npmDefsDir = '/npm/'; return path.substring(0, path.indexOf(npmDefsDir) + npmDefsDir.length); }; async function extractLibDefsFromNpmPkgDir( pkgDirPath: string, scope: null | string, pkgNameVer: string, validating?: boolean, ): Promise<Array<NpmLibDef>> { const parsedPkgNameVer = parsePkgNameVer(pkgNameVer); if (parsedPkgNameVer === null) { return []; } const {pkgName, pkgVersion} = parsedPkgNameVer; const pkgVersionStr = versionToString(pkgVersion); const libDefFileName = `${pkgName}_${pkgVersionStr}.js`; const pkgDirItems = await fs.readdir(pkgDirPath); if (validating) { const fullPkgName = `${scope === null ? '' : scope + '/'}${pkgName}`; try { await _npmExists(fullPkgName); } catch (e) { throw new ValidationError( `Package \`${fullPkgName}\` does not exist on npm, is this meant to be an environment instead?`, ); } } const commonTestFiles = []; const parsedFlowDirs: Array<[string, FlowVersion]> = []; pkgDirItems.forEach(pkgDirItem => { const pkgDirItemPath = path.join(pkgDirPath, pkgDirItem); const pkgDirItemStat = fs.statSync(pkgDirItemPath); if (pkgDirItemStat.isFile()) { const isValidTestFile = TEST_FILE_NAME_RE.test(pkgDirItem); if (isValidTestFile) commonTestFiles.push(pkgDirItemPath); } else if (pkgDirItemStat.isDirectory()) { const parsedFlowDir = parseFlowDirString(pkgDirItem); parsedFlowDirs.push([pkgDirItemPath, parsedFlowDir]); } else { throw new ValidationError('Unexpected directory item'); } }); if (!disjointFlowVersionsAll(parsedFlowDirs.map(([_, ver]) => ver))) { throw new ValidationError('Flow versions not disjoint!'); } if (parsedFlowDirs.length === 0) { throw new ValidationError('No libdef files found!'); } const libDefs = []; await P.all( parsedFlowDirs.map(async ([flowDirPath, flowVersion]) => { const testFilePaths = [].concat(commonTestFiles); let libDefFilePath: null | string = null; const depVersions: { [key: string]: Array<string>, } = {}; (await fs.readdir(flowDirPath)).forEach(flowDirItem => { const flowDirItemPath = path.join(flowDirPath, flowDirItem); const flowDirItemStat = fs.statSync(flowDirItemPath); if (flowDirItemStat.isFile()) { if (path.extname(flowDirItem) === '.swp') { return; } // Is this the libdef file? if (flowDirItem === libDefFileName) { libDefFilePath = path.join(flowDirPath, flowDirItem); return; } // Is this a test file? const isValidTestFile = TEST_FILE_NAME_RE.test(flowDirItem); if (isValidTestFile) { testFilePaths.push(flowDirItemPath); return; } // Here we need to look at the deps and add it to the npmLibDef. // Later if installing this libdef // try to install the dependencies if not already installed elsewhere if (flowDirItem === 'config.json') { const deps = JSON.parse( fs.readFileSync(path.join(flowDirPath, flowDirItem), 'utf-8'), ).deps; if (deps) { Object.keys(deps).forEach(dep => { depVersions[dep] = [...deps[dep]]; }); } return; } throw new ValidationError( `Unexpected file: ${libDefFileName}. This directory can only contain test files ` + `or a libdef file named ${'`' + libDefFileName + '`'}.`, ); } else { throw new ValidationError( `Unexpected sub-directory. This directory can only contain test ` + `files or a libdef file named ${'`' + libDefFileName + '`'}.`, ); } }); if (libDefFilePath === null) { libDefFilePath = path.join(flowDirPath, libDefFileName); throw new ValidationError( `No libdef file found. Looking for a file named ${libDefFileName}`, ); } libDefs.push({ scope, name: pkgName, version: pkgVersionStr, flowVersion, path: libDefFilePath, testFilePaths, depVersions: Object.keys(depVersions).length > 0 ? depVersions : null, }); }), ); return libDefs; } export async function getCacheNpmLibDefs( cacheExpiry: number, skipCache: boolean = false, ): Promise<Array<NpmLibDef>> { if (!skipCache) { await ensureCacheRepo(cacheExpiry); } await verifyCLIVersion(); return await getNpmLibDefs(path.join(getCacheRepoDir(), 'definitions')); } const PKG_NAMEVER_RE = /^(.*)_v\^?([0-9]+)\.([0-9]+|x)\.([0-9]+|x)(-.*)?$/; const PKG_GIT_RE = /^([\w\-]+)@([\w\.]+):([\w\-]+)\/([\w\-]+)(?:\.git)$/; function parsePkgNameVer( pkgNameVer: string, ): {| pkgName: string, pkgVersion: Version, |} { const pkgNameVerMatches = pkgNameVer.match(PKG_NAMEVER_RE); const pkgNameGitMatches = pkgNameVer.match(PKG_GIT_RE); if (pkgNameGitMatches != null) { return { pkgName: pkgNameGitMatches[4], pkgVersion: {major: 0, minor: 0, patch: 0, prerel: ''}, }; } if (pkgNameVerMatches == null) { throw new ValidationError( `Malformed npm package name! ` + `Expected the name to be formatted as <PKGNAME>_v<MAJOR>.<MINOR>.<PATCH> but instead got ${pkgNameVer}`, ); } const [_, pkgName, majorStr, minorStr, patchStr, prerel] = pkgNameVerMatches; const major = validateVersionNumPart(majorStr, 'major'); const minor = validateVersionPart(minorStr, 'minor'); const patch = validateVersionPart(patchStr, 'patch'); return { pkgName, pkgVersion: { major, minor, patch, prerel: prerel != null ? prerel.substr(1) : prerel, }, }; } /** * Given a number-or-wildcard part of a version string (i.e. a `minor` or * `patch` part), parse the string into either a number or 'x'. */ function validateVersionPart(part: string, partName: string): number | 'x' { if (part === 'x') { return part; } return validateVersionNumPart(part, partName); } /** * Given a number-only part of a version string (i.e. the `major` part), parse * the string into a number. */ function validateVersionNumPart(part: string, partName: string): number { const num = parseInt(part, 10); if (String(num) !== part) { throw new ValidationError( `Invalid ${partName} number: '${part}'. Expected a number.`, ); } return num; } export function pkgVersionMatch( pkgSemverRaw: string, libDefSemverRaw: string, ): boolean { // The package version should be treated as a semver implicitly prefixed by // `^` or `~`. Depending on whether or not the minor value is defined. // i.e.: "foo_v2.2.x" is the same range as "~2.2.x" // and "foo_v2.x.x" is the same range as "^2.x.x" // UNLESS it is prefixed by the equals character (i.e. "foo_=v2.2.x") const libDefSemver = (() => { const versionSplit = libDefSemverRaw.split('.'); if (libDefSemverRaw[0] !== '=' && libDefSemverRaw[0] !== '^') { if (versionSplit[1] !== 'x') { return '~' + libDefSemverRaw; } return '^' + libDefSemverRaw; } return libDefSemverRaw; })(); const pkgSemver = semver.coerce(pkgSemverRaw)?.version ?? pkgSemverRaw; if (semver.valid(pkgSemver)) { // Test the single package version against the LibDef range return semver.satisfies(pkgSemver, libDefSemver); } if (semver.valid(libDefSemver)) { // Test the single LibDef version against the package range return semver.satisfies(libDefSemver, pkgSemver); } if (!(semver.validRange(pkgSemver) && semver.validRange(libDefSemver))) { return false; } const pkgRange = new semver.Range(pkgSemver); const libDefRange = new semver.Range(libDefSemver); if (libDefRange.set[0].length !== 2) { throw new Error( 'Invalid npm libdef version! It appears to be a non-contiguous range.', ); } const libDefLower = getRangeLowerBound(libDefRange); const libDefUpper = getRangeUpperBound(libDefRange); const pkgBelowLower = semver.gtr(libDefLower, pkgSemver); const pkgAboveUpper = semver.ltr(libDefUpper, pkgSemver); if (pkgBelowLower || pkgAboveUpper) { return false; } const pkgLower = pkgRange.set[0][0].semver.version; return libDefRange.test(pkgLower); } function filterLibDefs( defs: Array<NpmLibDef>, filter: NpmLibDefFilter, ): Array<NpmLibDef> { return defs .filter(def => { let filterMatch = false; switch (filter.type) { case 'exact': const fullName = def.scope ? `${def.scope}/${def.name}` : def.name; filterMatch = filter.pkgName.toLowerCase() === fullName.toLowerCase() && pkgVersionMatch(filter.pkgVersion, def.version); break; default: (filter: empty); } if (!filterMatch) { return false; } const filterFlowVersion = filter.flowVersion; if (filterFlowVersion !== undefined) { const {flowVersion} = def; switch (flowVersion.kind) { case 'all': return true; case 'ranged': case 'specific': return semver.satisfies( flowVersionToSemver(filterFlowVersion), flowVersionToSemver(def.flowVersion), ); default: (flowVersion: empty); } } return true; }) .sort((a, b) => { const aZeroed = a.version.replace(/x/g, '0'); const bZeroed = b.version.replace(/x/g, '0'); return semver.gt(aZeroed, bZeroed) ? -1 : 1; }); } async function _npmExists(pkgName: string): Promise<Function> { const pkgUrl = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}`; return got(pkgUrl, {method: 'HEAD'}); } export async function findNpmLibDef( pkgName: string, pkgVersion: string, flowVersion: FlowVersion, useCacheUntil: number = CACHE_REPO_EXPIRY, skipCache?: boolean = false, extLibDefs?: Array<NpmLibDef>, ): Promise<null | NpmLibDef> { const libDefs = extLibDefs ?? (await getCacheNpmLibDefs(useCacheUntil, skipCache)); const filteredLibDefs = filterLibDefs(libDefs, { type: 'exact', pkgName, pkgVersion, flowVersion, }); return filteredLibDefs.length === 0 ? null : filteredLibDefs[0]; } type InstalledNpmLibDef = | {|kind: 'LibDef', libDef: NpmLibDef|} | {|kind: 'Stub', name: string|}; type ParsedSignedCodeVersion = | {| kind: 'LibDef', libDef: $Diff<NpmLibDef, {|path: string|}>, |} | {|kind: 'Stub', name: string|}; export function parseSignedCodeVersion( signedCodeVer: string, ): ?ParsedSignedCodeVersion { if (signedCodeVer === null) { return null; } if (signedCodeVer.startsWith('<<STUB>>/')) { return { kind: 'Stub', name: signedCodeVer.substring('<<STUB>>/'.length), }; } const matches = signedCodeVer.match( /([^\/]+)\/(@[^\/]+\/)?([^\/]+)\/([^\/]+)/, ); if (matches == null) { return null; } const scope = matches[2] == null ? null : matches[2].substr(0, matches[2].length - 1); const nameVer = matches[3]; if (nameVer === null) { return null; } const pkgNameVer = parsePkgNameVer(nameVer); if (pkgNameVer === null) { return null; } const {pkgName, pkgVersion} = pkgNameVer; const flowVerMatches = matches[4].match( /^flow_(>=|<=)?(v[^ ]+) ?(<=(v.+))?$/, ); const flowVerStr = flowVerMatches == null ? matches[3] : flowVerMatches[3] == null ? flowVerMatches[2] : `${flowVerMatches[2]}-${flowVerMatches[4]}`; const flowDirStr = `flow_${flowVerStr}`; const flowVer = flowVerMatches == null ? parseFlowDirString(flowDirStr) : parseFlowDirString(flowDirStr); return { kind: 'LibDef', libDef: { scope, name: pkgName, version: versionToString(pkgVersion), flowVersion: flowVer, testFilePaths: [], depVersions: null, }, }; } async function getInstalledNpmLibDef( flowProjectRootDir: string, fullFilePath: string, ): Promise<?[string, InstalledNpmLibDef]> { const terseFilePath = path.relative(flowProjectRootDir, fullFilePath); const fileStat = await fs.stat(fullFilePath); if (fileStat.isFile()) { const fileContent = await fs.readFile(fullFilePath, 'utf8'); if (verifySignedCode(fileContent)) { const signedCodeVer = getSignedCodeVersion(fileContent); if (signedCodeVer === null) { return null; } const parsed = parseSignedCodeVersion(signedCodeVer); if (!parsed) { return null; } return [ terseFilePath, parsed.kind === 'LibDef' ? { kind: 'LibDef', libDef: { ...parsed.libDef, path: terseFilePath, }, } : parsed, ]; } } } export async function getInstalledNpmLibDefs( flowProjectRootDir: string, libdefDir?: string, ): Promise<Map<string, InstalledNpmLibDef>> { const typedefDir = libdefDir || 'flow-typed'; const libDefDirPath = path.join(flowProjectRootDir, typedefDir, 'npm'); if (!(await fs.exists(libDefDirPath))) return new Map(); const filesInNpmDir = await getFilesInDir(libDefDirPath, true); return new Map( ( await P.all( [...filesInNpmDir].map(fileName => getInstalledNpmLibDef( flowProjectRootDir, path.join(libDefDirPath, fileName), ), ), ) ).filter(Boolean), ); } /** * Retrieve single libdef. */ async function getSingleLibdef( itemName: string, npmDefsDirPath: string, validating?: boolean, ): Promise<Array<NpmLibDef>> { const itemPath = path.join(npmDefsDirPath, itemName); const itemStat = await fs.stat(itemPath); if (itemStat.isDirectory()) { if (itemName[0] === '@') { // This must be a scoped npm package, so go one directory deeper const scope = itemName; const scopeDirItems = await fs.readdir(itemPath); const settled = await P.all( scopeDirItems .filter(item => !isExcludedFile(item)) .map(async itemName => { const itemPath = path.join(npmDefsDirPath, scope, itemName); const itemStat = await fs.stat(itemPath); if (itemStat.isDirectory()) { return await extractLibDefsFromNpmPkgDir( itemPath, scope, itemName, validating, ); } else { throw new ValidationError( `Expected only sub-directories in this dir!`, ); } }), ); return [].concat(...settled); } else { // itemPath must be a package dir return await extractLibDefsFromNpmPkgDir( itemPath, null, // No scope itemName, validating, ); } } else { throw new ValidationError( `Expected only directories to be present in this directory.`, ); } } /** * Retrieve a list of *all* npm libdefs. */ export async function getNpmLibDefs( defsDirPath: string, validating?: boolean, ): Promise<Array<NpmLibDef>> { const npmDefsDirPath = path.join(defsDirPath, 'npm'); const dirItems = await fs.readdir(npmDefsDirPath); const errors = []; const proms = dirItems.map(async itemName => { if (isExcludedFile(itemName)) return; try { return await getSingleLibdef(itemName, npmDefsDirPath, validating); } catch (e) { errors.push(e); } }); const settled = await P.all(proms); if (errors.length) { throw errors; } return [].concat(...settled).filter(Boolean); } export async function getNpmLibDefVersionHash( repoDirPath: string, libDef: NpmLibDef, ): Promise<string> { const latestCommitHash = await findLatestFileCommitHash( repoDirPath, path.relative(repoDirPath, libDef.path), ); return ( `${latestCommitHash.substr(0, 10)}/` + (libDef.scope === null ? '' : `${libDef.scope}/`) + `${libDef.name}_${libDef.version}/` + `flow_${flowVersionToSemver(libDef.flowVersion)}` ); } export function getScopedPackageName(libDef: NpmLibDef): string { return (libDef.scope === null ? '' : `${libDef.scope}/`) + `${libDef.name}`; } export { extractLibDefsFromNpmPkgDir as _extractLibDefsFromNpmPkgDir, parsePkgNameVer as _parsePkgNameVer, validateVersionNumPart as _validateVersionNumPart, validateVersionPart as _validateVersionPart, };