@daotl/traf-nx
Version:
A cli tool that wraps `@traf/core` to be used with Nx.
484 lines (476 loc) • 15.2 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/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
};