@traf/turbo
Version:
A cli tool that wraps `@traf/core` to be used with Turborepo.
702 lines (690 loc) • 22.6 kB
JavaScript
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
};