UNPKG

@traf/turbo

Version:

A cli tool that wraps `@traf/core` to be used with Turborepo.

702 lines (690 loc) 22.6 kB
import { dirname as __dirname__ } from 'path';import { fileURLToPath } from 'url';import { createRequire as topLevelCreateRequire } from 'module';const require = topLevelCreateRequire(import.meta.url);const __filename = fileURLToPath(import.meta.url);const __dirname = __dirname__(__filename); // libs/turbo/src/cli.ts import { resolve as resolve5 } from "path"; import chalk2 from "chalk"; import { spawn } from "node:child_process"; import { hideBin } from "yargs/helpers"; import yargs from "yargs/yargs"; // libs/core/src/true-affected.ts import { existsSync as existsSync2 } from "fs"; import { join as join3, resolve as resolve3 } from "path"; import { Project, SyntaxKind as SyntaxKind2 } from "ts-morph"; import chalk from "chalk"; // libs/core/src/git.ts import { execSync } from "node:child_process"; import { resolve } from "node:path"; var TEN_MEGABYTES = 1024 * 1e4; function getMergeBase({ cwd, base, head = "HEAD" }) { try { return execSync(`git merge-base "${base}" "${head}"`, { maxBuffer: TEN_MEGABYTES, stdio: "pipe", cwd }).toString().trim(); } catch { try { return execSync(`git merge-base --fork-point "${base}" "${head}"`, { maxBuffer: TEN_MEGABYTES, stdio: "pipe", cwd }).toString().trim(); } catch { return base; } } } function getDiff({ base, cwd }) { try { const diffCommand = cwd != null ? `git diff ${base} --unified=0 --relative -- ${resolve(cwd)}` : `git diff ${base} --unified=0 `; return execSync(diffCommand, { maxBuffer: TEN_MEGABYTES, cwd, stdio: "pipe" }).toString().trim(); } catch (e) { throw new Error( `Unable to get diff for base: "${base}". are you using the correct base?` ); } } function getFileFromRevision({ base, filePath, cwd }) { try { return execSync(`git show ${base}:${filePath}`, { maxBuffer: TEN_MEGABYTES, cwd, stdio: "pipe" }).toString().trim(); } catch (e) { throw new Error( `Unable to get file "${filePath}" for base: "${base}". are you using the correct base?` ); } } function getChangedFiles({ base, cwd }) { const mergeBase = getMergeBase({ base, cwd }); const diff2 = getDiff({ base: mergeBase, cwd }); return diff2.split(/^diff --git/gm).slice(1).map((file) => { const filePath = (file.match(/(?<=["\s]a\/).*(?=["\s]b\/)/g)?.[0] ?? "").replace('"', "").trim(); const changedLines = file.match(/(?<=@@ -.* \+)\d*(?=.* @@)/g)?.map((line) => +line) ?? []; return { filePath, changedLines }; }); } // libs/core/src/utils.ts import { SyntaxKind } from "ts-morph"; var findRootNode = (node) => { if (node == null) return; if (node.getParent()?.getKind() === SyntaxKind.SourceFile) return node; return findRootNode(node.getParent()); }; var getPackageNameByPath = (path, projects) => { return projects.find(({ sourceRoot }) => path.includes(sourceRoot))?.name; }; var findNodeAtLine = (sourceFile, line) => { const lineStartPos = sourceFile.compilerNode.getPositionOfLineAndCharacter( line - 1, 0 ); return sourceFile.getDescendantAtPos(lineStartPos); }; // libs/core/src/assets.ts import { basename, dirname, join, relative, resolve as resolve2 } from "path"; import { fastFindInFiles } from "fast-find-in-files"; import { existsSync } from "fs"; function findNonSourceAffectedFiles(cwd, changedFilePaths, excludeFolderPaths) { if (changedFilePaths.length === 0) return []; const fileNames = changedFilePaths.map((path) => basename(path)); const files = fastFindInFiles({ directory: cwd, needle: new RegExp(fileNames.join("|").replaceAll(".", "\\.")), excludeFolderPaths: excludeFolderPaths.map( (path) => typeof path === "string" ? join(cwd, path) : path ) }); const relevantFiles = filterRelevantFiles(cwd, files, changedFilePaths); return relevantFiles; } function filterRelevantFiles(cwd, files, changedFilePaths) { return changedFilePaths.flatMap((changedFilePath) => { const fileName = basename(changedFilePath); const regExp = new RegExp(`['"\`](?<relFilePath>.*${fileName})['"\`]`); return files.map(({ filePath: foundFilePath, queryHits }) => ({ filePath: relative(cwd, foundFilePath), changedLines: queryHits.filter( ({ line }) => isRelevantLine(line, regExp, cwd, foundFilePath, changedFilePath) ).map(({ lineNumber }) => lineNumber) })).filter(({ changedLines }) => changedLines.length > 0); }); } function isRelevantLine(line, regExp, cwd, foundFilePath, changedFilePath) { const match = regExp.exec(line); const { relFilePath } = match?.groups ?? {}; if (relFilePath == null) return false; const changedFile = resolve2(cwd, changedFilePath); const relatedFilePath = resolve2( cwd, relative(cwd, join(dirname(foundFilePath), relFilePath)) ); return relatedFilePath === changedFile && existsSync(relatedFilePath); } // libs/core/src/lock-files.ts import { readFileSync } from "fs"; import diff from "microdiff"; import { fastFindInFiles as fastFindInFiles2 } from "fast-find-in-files"; import { join as join2, relative as relative2 } from "path"; import { getLockFileName, getLockFileNodes } from "nx/src/plugins/js/lock-file/lock-file.js"; import { detectPackageManager } from "nx/src/utils/package-manager.js"; // libs/core/src/find-direct-deps.ts import { execSync as execSync2 } from "node:child_process"; import { readModulePackageJson } from "nx/src/utils/package-json.js"; function npmFindDirectDeps(cwd, packages) { const pattern = packages.length > 1 ? `{${packages.join(",")}}` : packages; const result = execSync2(`npm list -a --json --package-lock-only ${pattern}`, { cwd, encoding: "utf-8" }); const { dependencies = {} } = JSON.parse(result); return Object.keys(dependencies); } function yarnFindDirectDeps(cwd, packages) { const pkg = readModulePackageJson(cwd).packageJson; const deps = [ ...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {}) ]; const direct = deps.filter((dep) => packages.includes(dep)); const transitive = deps.filter((dep) => !packages.includes(dep)); if (transitive.length > 0) { console.warn( "INFO: detected yarn & affected transitive deps. unfortunately yarn list does not return direct dependencies from transitive dependencies. only top level dependencies are returned atm. PRs are welcome!" ); } return direct; } function pnpmFindDirectDeps(cwd, packages) { const pattern = packages.length > 1 ? `{${packages.join(",")}}` : packages; const result = execSync2(`pnpm ls ${pattern} --depth Infinity --json`, { cwd, encoding: "utf-8" }); const [{ dependencies = {}, devDependencies = {} }] = JSON.parse( result ); return [...Object.keys(dependencies), ...Object.keys(devDependencies)]; } function findDirectDeps(packageManager2, cwd, packages) { if (packages.length === 0) return []; switch (packageManager2) { case "npm": return npmFindDirectDeps(cwd, packages); case "yarn": return yarnFindDirectDeps(cwd, packages); case "pnpm": return pnpmFindDirectDeps(cwd, packages); } } // libs/core/src/lock-files.ts var packageManager = detectPackageManager(); var lockFileName = getLockFileName(packageManager); function findAffectedModules(cwd, base) { const lock = readFileSync(lockFileName, "utf-8"); let prevLock = "{}"; try { prevLock = getFileFromRevision({ base, filePath: lockFileName, cwd }); } catch (e) { } const nodes = getLockFileNodes(packageManager, lock, "lock"); const prevNodes = getLockFileNodes(packageManager, prevLock, "prevLock"); const changes = diff(prevNodes, nodes); const captureModuleName = new RegExp(/npm:(@?[\w-/]+)/); const changedModules = Array.from( new Set( changes.map( ({ path }) => captureModuleName.exec(path[0].toString())?.[1] ?? path[0].toString() ) ) ); return findDirectDeps(packageManager, cwd, changedModules); } function hasLockfileChanged(changedFiles) { return changedFiles.some(({ filePath }) => filePath === lockFileName); } function findAffectedFilesByLockfile(cwd, base, excludePaths) { const dependencies = findAffectedModules(cwd, base); const excludeFolderPaths = excludePaths.map( (path) => typeof path === "string" ? join2(cwd, path) : path ); const files = dependencies.flatMap( (dep) => fastFindInFiles2({ directory: cwd, needle: dep, excludeFolderPaths }) ); const relevantFiles = filterRelevantFiles2(cwd, files, dependencies.join("|")); return relevantFiles; } function filterRelevantFiles2(cwd, files, libName) { const regExp = new RegExp(`['"\`](?<lib>${libName})(?:/.*)?['"\`]`); return files.map(({ filePath: foundFilePath, queryHits }) => ({ filePath: relative2(cwd, foundFilePath), changedLines: queryHits.filter(({ line }) => isRelevantLine2(line, regExp)).map(({ lineNumber }) => lineNumber) })).filter(({ changedLines }) => changedLines.length > 0); } function isRelevantLine2(line, regExp) { const match = regExp.exec(line); const { lib } = match?.groups ?? {}; return lib != null; } // libs/core/src/true-affected.ts var ignoredRootNodeTypes = [ SyntaxKind2.ImportDeclaration, SyntaxKind2.ExportDeclaration, SyntaxKind2.ModuleDeclaration, SyntaxKind2.ExpressionStatement, // iife, SyntaxKind2.IfStatement ]; var DEFAULT_INCLUDE_TEST_FILES = /\.(spec|test)\.(ts|js)x?/; var DEFAULT_LOGGER = { ...console, debug: process.env["DEBUG"] === "true" ? console.debug : () => { } }; var trueAffected = async ({ cwd, rootTsConfig, base = "origin/main", projects, include = [DEFAULT_INCLUDE_TEST_FILES], logger = DEFAULT_LOGGER, compilerOptions = {}, ignoredPaths = ["./node_modules", "./dist", "./build", "./.git"], __experimentalLockfileCheck = false }) => { logger.debug("Getting affected projects"); if (rootTsConfig != null) { logger.debug( `Creating project with root tsconfig from ${chalk.bold( resolve3(cwd, rootTsConfig) )}` ); } const project = new Project({ compilerOptions: { allowJs: true, ...compilerOptions }, ...rootTsConfig == null ? {} : { tsConfigFilePath: resolve3(cwd, rootTsConfig), skipAddingFilesFromTsConfig: true } }); projects.forEach( ({ name, sourceRoot, tsConfig = join3(sourceRoot, "tsconfig.json") }) => { const tsConfigPath = resolve3(cwd, tsConfig); if (existsSync2(tsConfigPath)) { logger.debug( `Adding source files for project ${chalk.bold( name )} from tsconfig at ${chalk.bold(tsConfigPath)}` ); project.addSourceFilesFromTsConfig(tsConfigPath); } else { logger.debug( `Could not find a tsconfig for project ${chalk.bold( name )}, adding source files paths in ${chalk.bold( resolve3(cwd, sourceRoot) )}` ); project.addSourceFilesAtPaths( join3(resolve3(cwd, sourceRoot), "**/*.{ts,js}") ); } } ); const changedFiles = getChangedFiles({ base, cwd }); logger.debug(`Found ${chalk.bold(changedFiles.length)} changed files`); const sourceChangedFiles = changedFiles.filter( ({ filePath }) => project.getSourceFile(resolve3(cwd, filePath)) != null ); const nonSourceChangedFilesPaths = changedFiles.filter( ({ filePath }) => !filePath.match(/.*\.(ts|js)x?$/g) && !filePath.endsWith(lockFileName) && project.getSourceFile(resolve3(cwd, filePath)) == null ).map(({ filePath }) => filePath); let nonSourceChangedFiles = []; if (nonSourceChangedFilesPaths.length > 0) { logger.debug( `Finding non-source affected files for ${chalk.bold( nonSourceChangedFilesPaths.join(", ") )}` ); nonSourceChangedFiles = findNonSourceAffectedFiles( cwd, nonSourceChangedFilesPaths, ignoredPaths ); if (nonSourceChangedFiles.length > 0) { logger.debug( `Found ${chalk.bold( nonSourceChangedFiles.length )} non-source affected files` ); logger.debug( "Mapping non-source affected files imports to actual references" ); nonSourceChangedFiles = nonSourceChangedFiles.flatMap( ({ filePath, changedLines }) => { const file = project.getSourceFile(resolve3(cwd, filePath)); if (file == null) return []; return changedLines.reduce( (acc, line) => { const changedNode = findNodeAtLine(file, line); const rootNode = findRootNode(changedNode); if (!rootNode) return acc; if (!rootNode.isKind(SyntaxKind2.ImportDeclaration)) { return { ...acc, changedLines: [...acc.changedLines, ...changedLines] }; } logger.debug( `Found changed node ${chalk.bold( rootNode?.getText() ?? "undefined" )} at line ${chalk.bold(line)} in ${chalk.bold(filePath)}` ); const identifier = rootNode.getFirstChildByKind(SyntaxKind2.Identifier) ?? rootNode.getFirstDescendantByKind(SyntaxKind2.Identifier); if (identifier == null) return acc; logger.debug( `Found identifier ${chalk.bold( identifier.getText() )} in ${chalk.bold(filePath)}` ); const refs = identifier.findReferencesAsNodes(); logger.debug( `Found ${chalk.bold( refs.length )} references for identifier ${chalk.bold( identifier.getText() )}` ); return { ...acc, changedLines: [ ...acc.changedLines, ...refs.map((node) => node.getStartLineNumber()) ] }; }, { filePath, changedLines: [] } ); } ); } } let changedFilesByLockfile = []; if (__experimentalLockfileCheck && hasLockfileChanged(changedFiles)) { logger.debug("Lockfile has changed, finding affected files"); changedFilesByLockfile = findAffectedFilesByLockfile( cwd, base, ignoredPaths ).filter( ({ filePath }) => project.getSourceFile(resolve3(cwd, filePath)) != null ); } const filteredChangedFiles = [ ...sourceChangedFiles, ...nonSourceChangedFiles, ...changedFilesByLockfile ]; const changedIncludedFilesPackages = changedFiles.filter( ({ filePath }) => include.some( (file) => typeof file === "string" ? filePath.endsWith(file) : filePath.match(file) ) ).map(({ filePath }) => getPackageNameByPath(filePath, projects)).filter((v) => v != null); if (changedIncludedFilesPackages.length > 0) { logger.debug( `Found ${chalk.bold( changedIncludedFilesPackages.length )} affected packages from included files` ); } const affectedPackages = new Set(changedIncludedFilesPackages); const visitedIdentifiers = /* @__PURE__ */ new Map(); const findReferencesLibs = (node) => { const rootNode = findRootNode(node); if (rootNode == null) { logger.debug( `Could not find root node for ${chalk.bold(node.getText())}` ); return; } if (ignoredRootNodeTypes.find((type) => rootNode.isKind(type))) { logger.debug( `Ignoring root node ${chalk.bold( rootNode.getText() )} of type ${chalk.bold(rootNode.getKindName())}` ); return; } const identifier = rootNode.getFirstChildByKind(SyntaxKind2.Identifier) ?? rootNode.getFirstDescendantByKind(SyntaxKind2.Identifier); if (identifier == null) return; const identifierName = identifier.getText(); const path = rootNode.getSourceFile().getFilePath(); logger.debug( `Found identifier ${chalk.bold(identifierName)} in ${chalk.bold(path)}` ); if (identifierName && path) { if (!visitedIdentifiers.has(path)) { visitedIdentifiers.set(path, /* @__PURE__ */ new Set()); } const visited = visitedIdentifiers.get(path); if (visited.has(identifierName)) { logger.debug( `Already visited ${chalk.bold(identifierName)} in ${chalk.bold(path)}` ); return; } visited.add(identifierName); logger.debug( `Visiting ${chalk.bold(identifierName)} in ${chalk.bold(path)}` ); } const refs = identifier.findReferencesAsNodes(); refs.forEach((node2) => { const sourceFile = node2.getSourceFile(); const pkg = getPackageNameByPath(sourceFile.getFilePath(), projects); if (pkg) { affectedPackages.add(pkg); logger.debug(`Added package ${chalk.bold(pkg)} to affected packages`); } findReferencesLibs(node2); }); }; filteredChangedFiles.forEach(({ filePath, changedLines }) => { const sourceFile = project.getSourceFile(resolve3(cwd, filePath)); if (sourceFile == null) return; changedLines.forEach((line) => { try { const changedNode = findNodeAtLine(sourceFile, line); if (!changedNode) return; const pkg = getPackageNameByPath(sourceFile.getFilePath(), projects); if (pkg) { affectedPackages.add(pkg); logger.debug( `Added package ${chalk.bold( pkg )} to affected packages for changed line ${chalk.bold( line )} in ${chalk.bold(filePath)}` ); } findReferencesLibs(changedNode); } catch { return; } }); }); const implicitDeps = projects.filter( ({ implicitDependencies = [] }) => implicitDependencies.length > 0 ).reduce( (acc, { name, implicitDependencies }) => acc.set(name, implicitDependencies), /* @__PURE__ */ new Map() ); affectedPackages.forEach((pkg) => { const deps = Array.from(implicitDeps.entries()).filter(([, deps2]) => deps2.includes(pkg)).map(([name]) => name); if (deps.length > 0) { logger.debug( `Adding implicit dependencies ${chalk.bold( deps.join(", ") )} to ${chalk.bold(pkg)}` ); } deps.forEach((dep) => affectedPackages.add(dep)); }); return Array.from(affectedPackages); }; // libs/turbo/src/turbo.ts import { dirname as dirname2, join as join4, relative as relative3, resolve as resolve4 } from "path"; import { parse } from "yaml"; import { readFile } from "fs/promises"; import { globby } from "globby"; import { existsSync as existsSync3 } from "fs"; async function getWorkspaces(cwd) { const pnpmFile = resolve4(cwd, "pnpm-workspace.yaml"); if (existsSync3(pnpmFile)) { const workspace = parse(await readFile(pnpmFile, "utf-8")); return workspace.packages; } const pkgJson = JSON.parse( await readFile(resolve4(cwd, "package.json"), "utf-8") ); return pkgJson.workspaces ?? []; } async function getTurboTrueAffectedProjects(cwd) { const workspaces = await getWorkspaces(cwd); const ignoredWorkspaces = workspaces.filter( (workspace) => workspace.startsWith("!") ); const staticIgnores = [ "node_modules", "**/node_modules", "dist", ".git", ...ignoredWorkspaces ]; const packageGlobPatterns = workspaces.filter((workspace) => !workspace.startsWith("!")).map((workspace) => { return join4(workspace, "package.json"); }); const combinedPackageGlobPattern = `{${packageGlobPatterns.join(",")},}`; const files = await globby(combinedPackageGlobPattern, { ignore: staticIgnores, absolute: true, cwd, dot: true, suppressErrors: true, gitignore: true }); const projectFiles = []; for (const file of files) { const project = JSON.parse(await readFile(resolve4(cwd, file), "utf-8")); projectFiles.push({ name: project.name, sourceRoot: relative3(cwd, dirname2(file)) }); } return projectFiles; } // libs/turbo/src/cli.ts var color = "#ff0083"; var log = (message) => console.log( ` ${chalk2.hex(color)(">")} ${chalk2.bgHex(color).bold(" TRAF ")} ${message}` ); var affectedAction = async ({ cwd, action = "log", base = "origin/main", json, restArgs }) => { const projects = await getTurboTrueAffectedProjects(cwd); const affected = await trueAffected({ cwd, base, projects }); if (json) { console.log(JSON.stringify(affected)); return; } if (affected.length === 0) { log("No affected projects"); return; } switch (action) { case "log": { log(`Affected projects: ${affected.map((l) => ` - ${l}`).join("\n")}`); break; } default: { const command = `npx turbo run ${action} ${affected.map((p) => `--filter=${p}`).join(" ")} ${restArgs.join(" ")}`; log(`Running command: ${command}`); const child = spawn(command, { stdio: "inherit", shell: true }); child.on("exit", (code) => { process.exit(code ?? 0); }); break; } } }; var affectedCommand = { command: "affected [action]", describe: "Run a command on affected projects using true-affected", builder: { cwd: { desc: "Current working directory", default: process.cwd(), coerce: (value) => resolve5(process.cwd(), value) }, action: { desc: "Action to run on affected projects", default: "log" }, base: { desc: "Base branch to compare against", default: "origin/main" }, json: { desc: "Output affected projects as JSON", default: false } }, handler: async ({ cwd, action, base, json, // eslint-disable-next-line @typescript-eslint/no-unused-vars $0, // eslint-disable-next-line @typescript-eslint/no-unused-vars _, ...rest }) => { await affectedAction({ cwd, action, base, json, restArgs: Object.entries(rest).map(([key, value]) => `--${key}=${value}`) }); } }; async function run() { await yargs(hideBin(process.argv)).usage("Usage: $0 <command> [options]").parserConfiguration({ "strip-dashed": true, "strip-aliased": true }).command(affectedCommand).demandCommand().strictCommands().argv; } run(); export { run };