UNPKG

@daotl/traf-nx

Version:

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

484 lines (476 loc) 15.2 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/nx/src/cli.ts import { resolve as resolve5 } from "path"; import chalk 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 join2, resolve as resolve3 } from "path"; import { Project, SyntaxKind as SyntaxKind2 } from "ts-morph"; // 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 getChangedFiles({ base, cwd }) { const mergeBase = getMergeBase({ base, cwd }); const diff = getDiff({ base: mergeBase, cwd }); return diff.split(/^diff --git/gm).slice(1).map((file) => { const filePath = file.match(/(?<= a\/).*(?= b\/)/g)?.[0] ?? ""; const changedLines = file.match(/(?<=@@ -.* \+)\d*(?=.* @@)/g)?.map((line) => +line) ?? []; return { filePath, changedLines }; }); } // libs/core/src/utils.ts import { basename, dirname, join, relative, resolve as resolve2 } from "path"; import { fastFindInFiles } from "fast-find-in-files"; import { existsSync } from "fs"; 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, includesRoot = false) => { return projects.map(({ name, sourceRoot }) => ({ name, root: includesRoot ? sourceRoot.substring(0, sourceRoot.lastIndexOf("/")) : sourceRoot })).sort((a, b) => b.root.length - a.root.length).find( ({ root }) => path.includes(root) )?.name; }; function findNonSourceAffectedFiles(cwd, changedFilePath, excludeFolderPaths) { const fileName = basename(changedFilePath); const files = fastFindInFiles({ directory: cwd, needle: fileName, excludeFolderPaths: excludeFolderPaths.map((path) => join(cwd, path)) }); const relevantFiles = filterRelevantFiles(cwd, files, changedFilePath); return relevantFiles; } function filterRelevantFiles(cwd, files, 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 foundFileDir = resolve2(dirname(foundFilePath)); const changedFileDir = resolve2(cwd, dirname(changedFilePath)); const relatedFilePath = resolve2( cwd, relative(cwd, join(dirname(foundFilePath), relFilePath)) ); return foundFileDir === changedFileDir && existsSync(relatedFilePath); } // 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 trueAffected = async ({ cwd, rootTsConfig, base = "origin/main", projects, include = [DEFAULT_INCLUDE_TEST_FILES] }) => { const project = new Project({ compilerOptions: { allowJs: true }, ...rootTsConfig == null ? {} : { tsConfigFilePath: resolve3(cwd, rootTsConfig), skipAddingFilesFromTsConfig: true } }); const implicitDeps = projects.filter( ({ implicitDependencies = [] }) => implicitDependencies.length > 0 ).reduce( (acc, { name, implicitDependencies }) => acc.set(name, implicitDependencies), /* @__PURE__ */ new Map() ); projects.forEach( ({ sourceRoot, tsConfig = join2(sourceRoot, "tsconfig.json") }) => { const tsConfigPath = resolve3(cwd, tsConfig); if (existsSync2(tsConfigPath)) { project.addSourceFilesFromTsConfig(tsConfigPath); } else { project.addSourceFilesAtPaths( join2(resolve3(cwd, sourceRoot), "**/*.{ts,js}") ); } } ); const changedFiles = getChangedFiles({ base, cwd }); const sourceChangedFiles = changedFiles.filter( ({ filePath }) => project.getSourceFile(resolve3(cwd, filePath)) != null ); const depAndTaskDeclarationFiles = ["package.json", "nx.json", "project.json"]; const ignoredPaths = ["./node_modules", "./dist", "./.git"]; const affectedPackages = /* @__PURE__ */ new Set(); const nonSourceChangedFiles = changedFiles.filter( function({ filePath }) { if (depAndTaskDeclarationFiles.includes(filePath.substring(filePath.lastIndexOf("/") + 1))) { const pkg = getPackageNameByPath(resolve3(cwd, filePath), projects, true); if (pkg) affectedPackages.add(pkg); } return !filePath.match(/.*\.(ts|js)x?$/g) && project.getSourceFile(resolve3(cwd, filePath)) == null; } ).flatMap( ({ filePath: changedFilePath }) => findNonSourceAffectedFiles(cwd, changedFilePath, ignoredPaths) ); const filteredChangedFiles = [ ...sourceChangedFiles, ...nonSourceChangedFiles ]; 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); changedIncludedFilesPackages.forEach((pkg) => affectedPackages.add(pkg)); const visitedIdentifiers = /* @__PURE__ */ new Map(); const findReferencesLibs = (node) => { const rootNode = findRootNode(node); if (rootNode == null) return; if (ignoredRootNodeTypes.find((type) => rootNode.isKind(type))) return; const identifier = rootNode.getFirstChildByKind(SyntaxKind2.Identifier) ?? rootNode.getFirstDescendantByKind(SyntaxKind2.Identifier); if (identifier == null) return; const refs = identifier.findReferencesAsNodes(); const identifierName = identifier.getText(); const path = rootNode.getSourceFile().getFilePath(); if (identifierName && path) { const visited = visitedIdentifiers.get(identifierName) ?? []; if (visited.includes(path)) return; visitedIdentifiers.set(identifierName, [...visited, path]); } refs.forEach((node2) => { const sourceFile = node2.getSourceFile(); const pkg = getPackageNameByPath(sourceFile.getFilePath(), projects); if (pkg) affectedPackages.add(pkg); findReferencesLibs(node2); }); }; filteredChangedFiles.forEach(({ filePath, changedLines }) => { const sourceFile = project.getSourceFile(resolve3(cwd, filePath)); if (sourceFile == null) return; changedLines.forEach((line) => { try { const lineStartPos = sourceFile.compilerNode.getPositionOfLineAndCharacter(line - 1, 0); const changedNode = sourceFile.getDescendantAtPos(lineStartPos); if (!changedNode) return; const pkg = getPackageNameByPath(sourceFile.getFilePath(), projects); if (pkg) affectedPackages.add(pkg); findReferencesLibs(changedNode); } catch { return; } }); }); affectedPackages.forEach((pkg) => { const deps = Array.from(implicitDeps.entries()).filter(([, deps2]) => deps2.includes(pkg)).map(([name]) => name); deps.forEach((dep) => affectedPackages.add(dep)); }); return Array.from(affectedPackages); }; // libs/nx/src/nx.ts import { join as join3, resolve as resolve4 } from "path"; import { readFile } from "fs/promises"; import { globby } from "globby"; import { existsSync as existsSync3 } from "fs"; async function getNxProjects(cwd) { const nxProjects = await getNxProjectJsonProjects(cwd); const workspaceProjects = await getNxWorkspaceProjects(cwd); const relevantWorkspaceProjects = workspaceProjects.filter( (proj) => nxProjects.find((nested) => nested.name === proj.name) === void 0 ); return [...nxProjects, ...relevantWorkspaceProjects]; } async function getNxWorkspaceProjects(cwd) { try { const path = resolve4(cwd, "workspace.json"); const file = await readFile(path, "utf-8"); const workspace = JSON.parse(file); return Object.entries(workspace.projects).filter(([, project]) => typeof project === "object").map(([name, project]) => ({ name, project })); } catch (e) { return []; } } async function getNxProjectJsonProjects(cwd) { try { const staticIgnores = ["node_modules", "**/node_modules", "dist", ".git"]; const projectGlobPatterns = [`project.json`, `**/project.json`]; const combinedProjectGlobPattern = "{" + projectGlobPatterns.join(",") + "}"; const files = await globby(combinedProjectGlobPattern, { ignore: staticIgnores, ignoreFiles: [".nxignore"], 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, project }); } return projectFiles; } catch (e) { return []; } } async function getNxTrueAffectedProjects(cwd) { const projects = await getNxProjects(cwd); return projects.map(({ name, project }) => { let tsConfig = project.targets?.build?.options?.tsConfig; if (!tsConfig) { const projectRoot = join3(project.sourceRoot, ".."); if (project.projectType === "library") { tsConfig = join3(projectRoot, "tsconfig.lib.json"); } else { tsConfig = join3(projectRoot, "tsconfig.app.json"); } if (!existsSync3(resolve4(cwd, tsConfig))) { tsConfig = join3(projectRoot, "tsconfig.json"); } } return { name, sourceRoot: project.sourceRoot, implicitDependencies: project.implicitDependencies ?? [], tsConfig, targets: Object.keys(project.targets ?? {}) }; }); } // libs/nx/src/cli.ts var color = "#ff0083"; var log = (message) => console.log( ` ${chalk.hex(color)(">")} ${chalk.bgHex(color).bold(" TRAF ")} ${message}` ); var affectedAction = async ({ cwd, action = "log", all = false, base = "origin/main", json, restArgs, tsConfigFilePath, includeFiles, target }) => { let projects = await getNxTrueAffectedProjects(cwd); if (target.length) { projects = projects.filter( (project) => !!project.targets?.some( (projectTarget) => target.includes(projectTarget) ) ); } const affected = all ? projects.map((p) => p.name) : await trueAffected({ cwd, rootTsConfig: tsConfigFilePath, base, projects, include: [...includeFiles, DEFAULT_INCLUDE_TEST_FILES] }); 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 nx run-many --target=${action} --projects=${affected.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) }, tsConfigFilePath: { desc: "Path to the root tsconfig.json file", default: "tsconfig.base.json" }, action: { desc: "Action to run on affected projects", default: "log" }, all: { desc: "Outputs all available projects regardless of changes", default: false }, base: { desc: "Base branch to compare against", default: "origin/main" }, json: { desc: "Output affected projects as JSON", default: false }, includeFiles: { desc: "Comma separated list of files to include", type: "array", default: [], coerce: (array) => { return array.flatMap((v) => v.split(",")); } }, target: { desc: "Comma separate list of targets to filter affected projects by", type: "array", default: [], coerce: (array) => { return array.flatMap((v) => v.split(",")).map((v) => v.trim()); } } }, handler: async ({ cwd, tsConfigFilePath, all, action, base, json, includeFiles, target, // eslint-disable-next-line @typescript-eslint/no-unused-vars $0, // eslint-disable-next-line @typescript-eslint/no-unused-vars _, ...rest }) => { await affectedAction({ cwd, tsConfigFilePath, all, action, base, json, includeFiles, target, restArgs: Object.entries(rest).map( /* istanbul ignore next */ ([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; } if (process.env["JEST_WORKER_ID"] == null) { run(); } export { affectedAction, log, run };