Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 30 additions & 23 deletions packages/cli/src/commands/run-affected-command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { detectLockfile, findAffectedPackages } from '@lockfile-affected/core';
import {
detectLockfile,
findAffectedPackages,
loadWorkspaceManifests,
} from '@lockfile-affected/core';
import {
isSupportedFormat,
lockfileParsers,
Expand All @@ -11,41 +15,44 @@ import { readLockfileContent } from './read-lockfile-content.js';

/**
* Runs the full affected-packages resolution pipeline:
* 1. Detect or resolve the lockfile parser
* 2. Read both lockfile snapshots
* 3. Delegate to findAffectedPackages (core)
* 4. Format and return output
* 1. Read both lockfiles
* 2. Detect format from content (or use --format flag)
* 3. Parse lockfile content into snapshots
* 4. Build workspace graph from manifests
* 5. Resolve affected packages
* 6. Format output
*/
export async function runAffectedCommand(options: CliOptions): Promise<string> {
const format =
options.format ?? (await detectLockfile(options.workspaceRoot, lockfileParsers)).format;
const [beforeContent, afterContent] = await Promise.all([
readLockfileContent(options.lockfileBefore),
readLockfileContent(options.lockfileAfter),
]);

const format = options.format ?? detectLockfile(beforeContent, lockfileParsers);

if (!isSupportedFormat(format)) {
throw new Error(`No parser registered for format: ${format}`);
}

const parser = lockfileParsersByFormat[format];

const [beforeContent, afterContent] = await Promise.all([
readLockfileContent(options.lockfileBefore),
readLockfileContent(options.lockfileAfter),
const [snapshotBefore, snapshotAfter, manifests] = await Promise.all([
parser.parse(beforeContent),
parser.parse(afterContent),
loadWorkspaceManifests(options.workspaceRoot),
]);

const filter = toDependencyFilter(options);
const hasFilter =
filter.dependencies ||
filter.devDependencies ||
filter.peerDependencies ||
filter.optionalDependencies;
const findOptions = {
beforeContent,
afterContent,
parser,
workspaceRoot: options.workspaceRoot,
...(hasFilter && { filter }),
const affected = findAffectedPackages({
snapshotBefore,
snapshotAfter,
manifests,
...((filter.dependencies ||
filter.devDependencies ||
filter.peerDependencies ||
filter.optionalDependencies) && { filter }),
...(options.rootDepsAffectAll && { rootDepsAffectAll: true }),
};
const affected = await findAffectedPackages(findOptions);
});

const sortedAffected = Array.from(affected).sort();
return formatAffectedOutput(sortedAffected, options.output);
Expand Down
131 changes: 55 additions & 76 deletions packages/core/src/affected/find-affected-packages.test.ts
Original file line number Diff line number Diff line change
@@ -1,103 +1,82 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomBytes } from 'node:crypto';
import { beforeEach, describe, expect, it } from 'vitest';
import type { LockfileParser, LockfileSnapshot } from '../types/lockfile.js';
import { describe, expect, it } from 'vitest';
import { findAffectedPackages } from './find-affected-packages.js';

function makeTempDir(): string {
return join(tmpdir(), `find-affected-test-${randomBytes(6).toString('hex')}`);
function makeSnapshot(...entries: string[]): Map<string, ReadonlyMap<string, string>> {
const snapshot = new Map<string, ReadonlyMap<string, string>>();
const rootPackages = new Map<string, string>();
for (const entry of entries) {
const [name, version] = entry.split('@');
if (name && version) rootPackages.set(name, version);
}
snapshot.set('.', rootPackages);
return snapshot;
}

/** A fake parser that treats content as newline-separated "name@version" pairs. */
function makeParser(): LockfileParser {
return {
format: 'fake',
lockfileNames: ['fake.lock'],
parse: async (content: string): Promise<LockfileSnapshot> => {
const snapshot = new Map<string, ReadonlyMap<string, string>>();
const rootPackages = new Map<string, string>();
for (const line of content.split('\n').filter(Boolean)) {
const [name, version] = line.split('@');
if (name && version) rootPackages.set(name, version);
}
snapshot.set('.', rootPackages);
return snapshot;
},
};
}

function lockfile(...entries: string[]): string {
return entries.join('\n');
function makeManifests(
...manifests: { name: string; deps?: Record<string, string>; devDeps?: Record<string, string> }[]
) {
return manifests.map((m) => ({
name: m.name,
dependencies: m.deps,
devDependencies: m.devDeps,
peerDependencies: undefined,
optionalDependencies: undefined,
}));
}

describe('findAffectedPackages', () => {
let dir: string;

beforeEach(async () => {
dir = makeTempDir();
await mkdir(dir, { recursive: true });
});

it('returns empty set when no packages depend on changed deps', async () => {
await mkdir(join(dir, 'packages', 'pkg-a'), { recursive: true });
await writeFile(
join(dir, 'packages', 'pkg-a', 'package.json'),
JSON.stringify({ name: 'pkg-a', dependencies: { lodash: '^4.0.0' } }),
);

const result = await findAffectedPackages({
beforeContent: lockfile('react@18.0.0'),
afterContent: lockfile('react@18.1.0'),
parser: makeParser(),
workspaceRoot: dir,
it('returns empty set when no packages depend on changed deps', () => {
const snapshotBefore = makeSnapshot('react@18.0.0');
const snapshotAfter = makeSnapshot('react@18.1.0');
const manifests = makeManifests({ name: 'pkg-a', deps: { lodash: '^4.0.0' } });

const result = findAffectedPackages({
snapshotBefore,
snapshotAfter,
manifests,
});

expect(result.size).toBe(0);
});

it('returns packages that depend on a changed dep', async () => {
await mkdir(join(dir, 'packages', 'pkg-a'), { recursive: true });
await writeFile(
join(dir, 'packages', 'pkg-a', 'package.json'),
JSON.stringify({ name: 'pkg-a', dependencies: { react: '^18.0.0' } }),
);
it('returns packages that depend on a changed dep', () => {
const snapshotBefore = makeSnapshot('react@18.0.0');
const snapshotAfter = makeSnapshot('react@18.1.0');
const manifests = makeManifests({ name: 'pkg-a', deps: { react: '^18.0.0' } });

const result = await findAffectedPackages({
beforeContent: lockfile('react@18.0.0'),
afterContent: lockfile('react@18.1.0'),
parser: makeParser(),
workspaceRoot: dir,
const result = findAffectedPackages({
snapshotBefore,
snapshotAfter,
manifests,
});

expect(result).toContain('pkg-a');
});

it('respects the dependency filter', async () => {
await mkdir(join(dir, 'packages', 'pkg-a'), { recursive: true });
await writeFile(
join(dir, 'packages', 'pkg-a', 'package.json'),
JSON.stringify({ name: 'pkg-a', devDependencies: { react: '^18.0.0' } }),
);
it('respects the dependency filter', () => {
const snapshotBefore = makeSnapshot('react@18.0.0');
const snapshotAfter = makeSnapshot('react@18.1.0');
const manifests = makeManifests({ name: 'pkg-a', devDeps: { react: '^18.0.0' } });

const result = await findAffectedPackages({
beforeContent: lockfile('react@18.0.0'),
afterContent: lockfile('react@18.1.0'),
parser: makeParser(),
workspaceRoot: dir,
filter: { dependencies: true }, // only prod deps — devDeps excluded
const result = findAffectedPackages({
snapshotBefore,
snapshotAfter,
manifests,
filter: { dependencies: true },
});

expect(result.size).toBe(0);
});

it('returns a ReadonlySet', async () => {
const result = await findAffectedPackages({
beforeContent: lockfile('react@18.0.0'),
afterContent: lockfile('react@18.0.0'),
parser: makeParser(),
workspaceRoot: dir,
it('returns a ReadonlySet', () => {
const snapshotBefore = makeSnapshot('react@18.0.0');
const snapshotAfter = makeSnapshot('react@18.0.0');
const manifests: { name: string }[] = [];

const result = findAffectedPackages({
snapshotBefore,
snapshotAfter,
manifests,
});

expect(result).toBeInstanceOf(Set);
Expand Down
37 changes: 14 additions & 23 deletions packages/core/src/affected/find-affected-packages.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,34 @@
import {
allDependencyTypesEnabled,
type DependencyFilter,
type LockfileParser,
type LockfileSnapshot,
} from '../types/lockfile.js';
import { diffLockfileSnapshots } from '../diff/diff-lockfile-snapshots.js';
import { resolveAffectedPackages } from './resolve-affected-packages.js';
import { buildWorkspaceGraph } from '../workspace/build-workspace-graph.js';
import { loadWorkspaceManifests } from '../workspace/load-workspace-manifests.js';
import type { PackageManifest } from '../workspace/build-workspace-graph.js';

export type FindAffectedOptions = {
/** Raw content of the "before" lockfile snapshot */
readonly beforeContent: string;
/** Raw content of the "after" lockfile snapshot */
readonly afterContent: string;
/** Parser for the lockfile format */
readonly parser: LockfileParser;
/** Root directory to search for workspace package.json files */
readonly workspaceRoot: string;
/** Parsed "before" lockfile snapshot */
readonly snapshotBefore: LockfileSnapshot;
/** Parsed "after" lockfile snapshot */
readonly snapshotAfter: LockfileSnapshot;
/** Workspace package manifests */
readonly manifests: readonly PackageManifest[];
/** Which dependency types to consider. When omitted, all types are included. */
readonly filter?: DependencyFilter;
/** When enabled, root dependency changes affect all workspace packages */
readonly rootDepsAffectAll?: boolean;
};

/**
* High-level entry point: parses two lockfile snapshots, diffs them,
* and returns the names of workspace packages affected by the changes.
* High-level entry point: diffs two lockfile snapshots and resolves
* affected workspace packages based on the dependency graph.
* Pure function: caller is responsible for parsing and loading manifests.
*/
export async function findAffectedPackages(
options: FindAffectedOptions,
): Promise<ReadonlySet<string>> {
const [snapshotBefore, snapshotAfter, manifests] = await Promise.all([
options.parser.parse(options.beforeContent),
options.parser.parse(options.afterContent),
loadWorkspaceManifests(options.workspaceRoot),
]);

const diff = diffLockfileSnapshots(snapshotBefore, snapshotAfter);
const workspaceGraph = buildWorkspaceGraph(manifests);
export function findAffectedPackages(options: FindAffectedOptions): ReadonlySet<string> {
const diff = diffLockfileSnapshots(options.snapshotBefore, options.snapshotAfter);
const workspaceGraph = buildWorkspaceGraph(options.manifests);

if (options.rootDepsAffectAll) {
const resolveOptions = {
Expand Down
Loading
Loading