Skip to content

Commit

Permalink
feat: add lockfile direct/transitive dep affected detection (#24)
Browse files Browse the repository at this point in the history
* feat: add lockfile direct dep affected detection

* feat: use package manager list to get direct deps by transitive deps

yarn is not supported atm :(
  • Loading branch information
EladBezalel authored Dec 21, 2023
1 parent a25470c commit ceba107
Show file tree
Hide file tree
Showing 15 changed files with 999 additions and 175 deletions.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"language": "en",
"words": [
"iife",
"microdiff",
"nxignore",
"traf",
"turborepo"
Expand Down
95 changes: 95 additions & 0 deletions libs/core/src/assets.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { fastFindInFiles } from 'fast-find-in-files';
import { existsSync } from 'fs';
import * as path from 'path';
import { findNonSourceAffectedFiles } from './assets';

jest.mock('fast-find-in-files');
jest.mock('fs');

describe('findNonSourceAffectedFiles', () => {
beforeEach(() => {
jest.resetAllMocks();
});

it('should return relevant files', () => {
const cwd = '/project';
const changedFilePath = '/project/src/file.ts';
const excludeFolderPaths = ['node_modules', 'dist', '.git'];

(fastFindInFiles as jest.Mock).mockReturnValue([
{
filePath: '/project/src/file.ts',
queryHits: [{ lineNumber: 1, line: `"file.ts"` }],
},
]);
(existsSync as jest.Mock).mockReturnValue(true);

const result = findNonSourceAffectedFiles(
cwd,
changedFilePath,
excludeFolderPaths
);

expect(result).toEqual([{ filePath: 'src/file.ts', changedLines: [1] }]);
expect(fastFindInFiles).toHaveBeenCalledWith({
directory: cwd,
needle: path.basename(changedFilePath),
excludeFolderPaths: excludeFolderPaths.map((folder) =>
path.join(cwd, folder)
),
});
});

it('should return empty array if no relevant files found', () => {
const cwd = '/project';
const changedFilePath = '/project/src/file.ts';
const excludeFolderPaths = ['node_modules', 'dist', '.git'];

(fastFindInFiles as jest.Mock).mockReturnValue([]);
(existsSync as jest.Mock).mockReturnValue(true);

const result = findNonSourceAffectedFiles(
cwd,
changedFilePath,
excludeFolderPaths
);

expect(result).toEqual([]);
expect(fastFindInFiles).toHaveBeenCalledWith({
directory: cwd,
needle: path.basename(changedFilePath),
excludeFolderPaths: excludeFolderPaths.map((folder) =>
path.join(cwd, folder)
),
});
});

it("should still work even if found file didn't have a match", () => {
const cwd = '/project';
const changedFilePath = '/project/src/file.ts';
const excludeFolderPaths = ['node_modules', 'dist', '.git'];

(fastFindInFiles as jest.Mock).mockReturnValue([
{
filePath: '/project/src/file.ts',
queryHits: [{ lineNumber: 1, line: `console.log('hi')` }],
},
]);
(existsSync as jest.Mock).mockReturnValue(true);

const result = findNonSourceAffectedFiles(
cwd,
changedFilePath,
excludeFolderPaths
);

expect(result).toEqual([]);
expect(fastFindInFiles).toHaveBeenCalledWith({
directory: cwd,
needle: path.basename(changedFilePath),
excludeFolderPaths: excludeFolderPaths.map((folder) =>
path.join(cwd, folder)
),
});
});
});
63 changes: 63 additions & 0 deletions libs/core/src/assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { basename, dirname, join, relative, resolve } from 'path';
import { ChangedFiles } from './git';
import { FastFindInFiles, fastFindInFiles } from 'fast-find-in-files';
import { existsSync } from 'fs';

export function findNonSourceAffectedFiles(
cwd: string,
changedFilePath: string,
excludeFolderPaths: string[]
): ChangedFiles[] {
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: string,
files: FastFindInFiles[],
changedFilePath: string
): ChangedFiles[] {
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: string,
regExp: RegExp,
cwd: string,
foundFilePath: string,
changedFilePath: string
): boolean {
const match = regExp.exec(line);
const { relFilePath } = match?.groups ?? {};

if (relFilePath == null) return false;

const changedFile = resolve(cwd, changedFilePath);
const relatedFilePath = resolve(
cwd,
relative(cwd, join(dirname(foundFilePath), relFilePath))
);

return relatedFilePath === changedFile && existsSync(relatedFilePath);
}
71 changes: 71 additions & 0 deletions libs/core/src/find-direct-deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { execSync } from 'node:child_process';
import { readModulePackageJson } from 'nx/src/utils/package-json.js';
import type { PackageManager } from 'nx/src/utils/package-manager.js';

interface NpmListJson {
dependencies: Record<string, unknown>;
}

export function npmFindDirectDeps(cwd: string, packages: string[]): string[] {
const pattern = packages.length > 1 ? `{${packages.join(',')}}` : packages;
const result = execSync(`npm list -a --json --package-lock-only ${pattern}`, {
cwd,
encoding: 'utf-8',
});
const { dependencies = {} } = JSON.parse(result) as NpmListJson;

return Object.keys(dependencies);
}

export function yarnFindDirectDeps(cwd: string, packages: string[]): string[] {
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;
}

interface PnpmListJson {
dependencies: Record<string, unknown>;
devDependencies: Record<string, unknown>;
}

export function pnpmFindDirectDeps(cwd: string, packages: string[]): string[] {
// pnpm ls {fast-glob,loader-utils} --depth Infinity --json
const pattern = packages.length > 1 ? `{${packages.join(',')}}` : packages;
const result = execSync(`pnpm ls ${pattern} --depth Infinity --json`, {
cwd,
encoding: 'utf-8',
});
const [{ dependencies = {}, devDependencies = {} }] = JSON.parse(
result
) as PnpmListJson[];

return [...Object.keys(dependencies), ...Object.keys(devDependencies)];
}

export function findDirectDeps(
packageManager: PackageManager,
cwd: string,
packages: string[]
): string[] {
switch (packageManager) {
case 'npm':
return npmFindDirectDeps(cwd, packages);
case 'yarn':
return yarnFindDirectDeps(cwd, packages);
case 'pnpm':
return pnpmFindDirectDeps(cwd, packages);
}
}
Loading

0 comments on commit ceba107

Please sign in to comment.