Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Be smarter about comparing lock files #912

166 changes: 115 additions & 51 deletions node-src/lib/findChangedDependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as git from '../git/git';
import { findChangedDependencies } from './findChangedDependencies';
import TestLogger from './testLogger';
import { Context } from '..';

vi.mock('snyk-nodejs-lockfile-parser');
vi.mock('yarn-or-npm');
Expand All @@ -25,25 +26,63 @@ afterEach(() => {
buildDepTree.mockReset();
});

const getContext: any = (baselineCommits: string[], options = {}) => ({
log: new TestLogger(),
git: { baselineCommits },
options,
});
const getContext = (
input: Partial<Omit<Context, 'git' | 'options'>> & {
git: Partial<Context['git']>;
options?: Partial<Context['options']>;
}
) =>
({
log: new TestLogger(),
options: {},
...input,
} as Context);

const AMetadataChanges = [{ changedFiles: ['package.json'], commit: 'A' }];

describe('findChangedDependencies', () => {
it('returns nothing given no baselines', async () => {
buildDepTree.mockResolvedValue({ dependencies: {} });
it('returns nothing given no changes', async () => {
const context = getContext({ git: { packageMetadataChanges: [] } });

await expect(findChangedDependencies(context)).resolves.toEqual([]);
expect(checkoutFile).not.toHaveBeenCalled();
expect(buildDepTreeFromFiles).not.toHaveBeenCalled();
});

it('returns nothing given no changes to found package metadata', async () => {
const context = getContext({
// Only detected metadata change is to a file that's not on disk
git: { packageMetadataChanges: [{ changedFiles: ['foo/package.json'], commit: 'A' }] },
});

await expect(findChangedDependencies(context)).resolves.toEqual([]);

expect(checkoutFile).not.toHaveBeenCalled();
expect(buildDepTreeFromFiles).not.toHaveBeenCalled();
});

it('returns nothing when dependency tree is unchanged', async () => {
checkoutFile.mockResolvedValueOnce('A.package.json');
checkoutFile.mockResolvedValueOnce('A.yarn.lock');
buildDepTree.mockResolvedValue({
dependencies: {
react: { name: 'react', version: '18.2.0', dependencies: {} },
},
});

const context = getContext({ git: { packageMetadataChanges: AMetadataChanges } });

await expect(findChangedDependencies(getContext([]))).resolves.toEqual([]);
await expect(findChangedDependencies(context)).resolves.toEqual([]);
});

it('returns nothing when dependency tree is empty', async () => {
checkoutFile.mockResolvedValueOnce('A.package.json');
checkoutFile.mockResolvedValueOnce('A.yarn.lock');
buildDepTree.mockResolvedValue({ dependencies: {} });

await expect(findChangedDependencies(getContext(['A']))).resolves.toEqual([]);
const context = getContext({ git: { packageMetadataChanges: AMetadataChanges } });

await expect(findChangedDependencies(context)).resolves.toEqual([]);
});

it('returns nothing when dependency tree is unchanged', async () => {
Expand All @@ -55,7 +94,9 @@ describe('findChangedDependencies', () => {
},
});

await expect(findChangedDependencies(getContext(['A']))).resolves.toEqual([]);
const context = getContext({ git: { packageMetadataChanges: AMetadataChanges } });

await expect(findChangedDependencies(context)).resolves.toEqual([]);
});

it('returns updated dependencies', async () => {
Expand All @@ -71,7 +112,9 @@ describe('findChangedDependencies', () => {
dependencies: { react: { name: 'react', version: '18.3.0', dependencies: {} } },
});

await expect(findChangedDependencies(getContext(['A']))).resolves.toEqual(['react']);
const context = getContext({ git: { packageMetadataChanges: AMetadataChanges } });

await expect(findChangedDependencies(context)).resolves.toEqual(['react']);
});

it('returns added/removed dependencies', async () => {
Expand All @@ -87,7 +130,9 @@ describe('findChangedDependencies', () => {
dependencies: { vue: { name: 'vue', version: '3.2.0', dependencies: {} } },
});

await expect(findChangedDependencies(getContext(['A']))).resolves.toEqual(['vue', 'react']);
const context = getContext({ git: { packageMetadataChanges: AMetadataChanges } });

await expect(findChangedDependencies(context)).resolves.toEqual(['vue', 'react']);
});

it('finds updated transient dependencies', async () => {
Expand Down Expand Up @@ -119,7 +164,9 @@ describe('findChangedDependencies', () => {
},
});

await expect(findChangedDependencies(getContext(['A']))).resolves.toEqual(['loose-envify']);
const context = getContext({ git: { packageMetadataChanges: AMetadataChanges } });

await expect(findChangedDependencies(context)).resolves.toEqual(['loose-envify']);
});

it('combines and dedupes changes for multiple baselines', async () => {
Expand Down Expand Up @@ -151,7 +198,16 @@ describe('findChangedDependencies', () => {
},
});

await expect(findChangedDependencies(getContext(['A', 'B']))).resolves.toEqual([
const context = getContext({
git: {
packageMetadataChanges: [
{ changedFiles: ['package.json'], commit: 'A' },
{ changedFiles: ['package.json'], commit: 'B' },
],
},
});

await expect(findChangedDependencies(getContext(context))).resolves.toEqual([
'react',
'lodash',
]);
Expand Down Expand Up @@ -180,7 +236,15 @@ describe('findChangedDependencies', () => {
dependencies: { lodash: { name: 'lodash', version: '4.18.0', dependencies: {} } },
});

await expect(findChangedDependencies(getContext(['A']))).resolves.toEqual(['react', 'lodash']);
const context = getContext({
git: {
packageMetadataChanges: [
{ changedFiles: ['package.json', 'subdir/package.json'], commit: 'A' },
],
},
});

await expect(findChangedDependencies(context)).resolves.toEqual(['react', 'lodash']);

// Root manifest and lock files are checked
expect(buildDepTree).toHaveBeenCalledWith('/root', 'package.json', 'yarn.lock', true);
Expand Down Expand Up @@ -210,7 +274,13 @@ describe('findChangedDependencies', () => {
checkoutFile.mockImplementation((ctx, commit, file) => Promise.resolve(`${commit}.${file}`));
buildDepTree.mockResolvedValue({ dependencies: {} });

await expect(findChangedDependencies(getContext(['A']))).resolves.toEqual([]);
const context = getContext({
git: {
packageMetadataChanges: [{ changedFiles: ['yarn.lock'], commit: 'A' }],
},
});

await expect(findChangedDependencies(context)).resolves.toEqual([]);

expect(buildDepTree).toHaveBeenCalledWith(
'/root',
Expand All @@ -220,6 +290,28 @@ describe('findChangedDependencies', () => {
);
});

it('ignores lockfile changes if metadata file is untraced', async () => {
findFiles.mockImplementation((file) => {
tmeasday marked this conversation as resolved.
Show resolved Hide resolved
if (file === 'subdir/yarn.lock') return Promise.resolve([]);
return Promise.resolve(file.startsWith('**') ? [file.replace('**', 'subdir')] : [file]);
});

const context = getContext({
git: {
// The metadata changes are filtered by untraced, but lockfile changes could still get in here
packageMetadataChanges: [{ changedFiles: ['yarn.lock'], commit: 'A' }],
},
options: {
untraced: ['package.json', 'subdir/package.json'],
},
});

await expect(findChangedDependencies(context)).resolves.toEqual([]);

expect(checkoutFile).not.to.toHaveBeenCalled();
expect(buildDepTree).not.toHaveBeenCalled();
});

it('uses package-lock.json if yarn.lock is missing', async () => {
findFiles.mockImplementation((file) => {
if (file.endsWith('yarn.lock'))
Expand All @@ -230,7 +322,13 @@ describe('findChangedDependencies', () => {
checkoutFile.mockImplementation((ctx, commit, file) => Promise.resolve(`${commit}.${file}`));
buildDepTree.mockResolvedValue({ dependencies: {} });

await expect(findChangedDependencies(getContext(['A']))).resolves.toEqual([]);
const context = getContext({
git: {
packageMetadataChanges: [{ changedFiles: ['subdir/package-lock.json'], commit: 'A' }],
},
});

await expect(findChangedDependencies(context)).resolves.toEqual([]);

expect(buildDepTree).toHaveBeenCalledWith(
'/root',
Expand All @@ -239,38 +337,4 @@ describe('findChangedDependencies', () => {
true
);
});

it('ignores manifest files matching --untraced', async () => {
// HEAD
buildDepTree.mockResolvedValueOnce({
dependencies: { react: { name: 'react', version: '18.2.0', dependencies: {} } },
});

// Baseline A
checkoutFile.mockResolvedValueOnce('A.package.json');
checkoutFile.mockResolvedValueOnce('A.yarn.lock');
buildDepTree.mockResolvedValueOnce({
dependencies: { react: { name: 'react', version: '18.3.0', dependencies: {} } },
});

const ctx = getContext(['A'], { untraced: ['package.json'] });
await expect(findChangedDependencies(ctx)).resolves.toEqual([]);
});

it('ignores lockfiles matching --untraced', async () => {
// HEAD
buildDepTree.mockResolvedValueOnce({
dependencies: { react: { name: 'react', version: '18.2.0', dependencies: {} } },
});

// Baseline A
checkoutFile.mockResolvedValueOnce('A.package.json');
checkoutFile.mockResolvedValueOnce('A.yarn.lock');
buildDepTree.mockResolvedValueOnce({
dependencies: { react: { name: 'react', version: '18.3.0', dependencies: {} } },
});

const ctx = getContext(['A'], { untraced: ['yarn.lock'] });
await expect(findChangedDependencies(ctx)).resolves.toEqual([]);
});
});
57 changes: 38 additions & 19 deletions node-src/lib/findChangedDependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ const YARN_LOCK = 'yarn.lock';
// Yields a list of dependency names which have changed since the baseline.
// E.g. ['react', 'react-dom', '@storybook/react']
export const findChangedDependencies = async (ctx: Context) => {
const { baselineCommits } = ctx.git;
const { packageMetadataChanges } = ctx.git;
const { untraced = [] } = ctx.options;

if (!baselineCommits.length) {
ctx.log.debug('No baseline commits found');
if (!packageMetadataChanges.length) {
ctx.log.debug('No package metadata changed found');
return [];
}

ctx.log.debug(
{ baselineCommits },
`Finding changed dependencies for ${baselineCommits.length} baselines`
{ packageMetadataChanges },
`Finding changed dependencies for ${packageMetadataChanges.length} baselines`
);

const rootPath = await getRepositoryRoot();
Expand All @@ -40,7 +40,7 @@ export const findChangedDependencies = async (ctx: Context) => {

// Handle monorepos with (multiple) nested package.json files.
const nestedManifestPaths = await findFiles(`**/${PACKAGE_JSON}`);
const pathPairs = await Promise.all(
const metadataPathPairs = await Promise.all(
nestedManifestPaths.map(async (manifestPath) => {
const dirname = path.dirname(manifestPath);
const [lockfilePath] = await findFiles(
Expand All @@ -53,36 +53,55 @@ export const findChangedDependencies = async (ctx: Context) => {
);

if (rootManifestPath && rootLockfilePath) {
pathPairs.unshift([rootManifestPath, rootLockfilePath]);
} else if (!pathPairs.length) {
metadataPathPairs.unshift([rootManifestPath, rootLockfilePath]);
} else if (!metadataPathPairs.length) {
throw new Error(`Could not find any pairs of ${PACKAGE_JSON} + ${PACKAGE_LOCK} / ${YARN_LOCK}`);
}

ctx.log.debug({ pathPairs }, `Found ${pathPairs.length} manifest/lockfile pairs to check`);
ctx.log.debug(
{ pathPairs: metadataPathPairs },
`Found ${metadataPathPairs.length} manifest/lockfile pairs to check`
);

// Now filter out any pairs that don't have git changes, or for which the manifest is untraced
const filteredPathPairs = metadataPathPairs
.map(([manifestPath, lockfilePath]) => {
const commits = packageMetadataChanges
.filter(({ changedFiles }) =>
changedFiles.some((file) => file === lockfilePath || file === manifestPath)
)
.map(({ commit }) => commit);
tmeasday marked this conversation as resolved.
Show resolved Hide resolved

return [manifestPath, lockfilePath, [...new Set(commits)]] as const;
})
.filter(
([manifestPath, , commits]) =>
!untraced.some((glob) => matchesFile(glob, manifestPath)) && commits.length > 0
);

const tracedPairs = pathPairs.filter(([manifestPath, lockfilePath]) => {
if (untraced.some((glob) => matchesFile(glob, manifestPath))) return false;
if (untraced.some((glob) => matchesFile(glob, lockfilePath))) return false;
return true;
});
const untracedCount = pathPairs.length - tracedPairs.length;
if (untracedCount) {
ctx.log.debug(`Skipping ${untracedCount} manifest/lockfile pairs due to --untraced`);
ctx.log.debug(
{ filteredPathPairs },
`Found ${filteredPathPairs.length} manifest/lockfile pairs to diff`
);

// Short circuit
if (filteredPathPairs.length === 0) {
return [];
}

// Use a Set so we only keep distinct package names.
const changedDependencyNames = new Set<string>();

await Promise.all(
tracedPairs.map(async ([manifestPath, lockfilePath]) => {
filteredPathPairs.map(async ([manifestPath, lockfilePath, commits]) => {
const headDependencies = await getDependencies(ctx, { rootPath, manifestPath, lockfilePath });
ctx.log.debug({ manifestPath, lockfilePath, headDependencies }, `Found HEAD dependencies`);

// Retrieve the union of dependencies which changed compared to each baseline.
// A change means either the version number is different or the dependency was added/removed.
// If a manifest or lockfile is missing on the baseline, this throws and we'll end up bailing.
await Promise.all(
baselineCommits.map(async (ref) => {
commits.map(async (ref) => {
const baselineChanges = await compareBaseline(ctx, headDependencies, {
ref,
rootPath,
Expand Down
11 changes: 7 additions & 4 deletions node-src/lib/findChangedPackageFiles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execGitCommand } from '../git/git';
import { isPackageMetadataFile } from './utils';

const isEqual = (left: unknown = {}, right: unknown = {}) => {
if (typeof left !== typeof right) {
Expand Down Expand Up @@ -77,12 +78,14 @@ const getManifestFilesWithChangedDependencies = async (manifestFiles: string[],

// Yields a list of package.json files with dependency-related changes compared to the baseline.
export const findChangedPackageFiles = async (
packageManifestChanges: { changedFiles: string[]; commit: string }[]
packageMetadataChanges: { changedFiles: string[]; commit: string }[]
) => {
const changedManifestFiles = await Promise.all(
packageManifestChanges.map(({ changedFiles, commit }) =>
getManifestFilesWithChangedDependencies(changedFiles, commit)
)
packageMetadataChanges.map(({ changedFiles, commit }) => {
const changedManifestFiles = changedFiles.filter(isPackageMetadataFile);
if (!changedManifestFiles) return [];
return getManifestFilesWithChangedDependencies(changedManifestFiles, commit);
})
);
// Remove duplicate entries (in case multiple ancestor builds changed the same package.json)
return Array.from(new Set(changedManifestFiles.flat()));
Expand Down