From 7952fc54a92e46e07162706cec628a25380fb709 Mon Sep 17 00:00:00 2001 From: Henri Lahtinen Date: Wed, 1 Apr 2026 09:46:22 +0300 Subject: [PATCH] feat: implement content-based lockfile detection - Add detect method to each lockfile parser (pnpm, npm, yarn, bun) - Change detectLockfile to accept content instead of directory path - Refactor FindAffectedOptions to accept pre-parsed snapshots and manifests - Move I/O to CLI boundary, keeping core functions pure - Update tests for content-based detection --- .../cli/src/commands/run-affected-command.ts | 53 ++++--- .../affected/find-affected-packages.test.ts | 131 ++++++++---------- .../src/affected/find-affected-packages.ts | 37 ++--- .../core/src/lockfile/detect-lockfile.test.ts | 113 ++++++++------- packages/core/src/lockfile/detect-lockfile.ts | 31 ++--- packages/core/src/types/lockfile.ts | 4 +- .../lockfile-bun/src/parse-bun-lockfile.ts | 5 + .../lockfile-npm/src/parse-npm-lockfile.ts | 10 ++ .../lockfile-pnpm/src/parse-pnpm-lockfile.ts | 5 + .../lockfile-yarn/src/parse-yarn-lockfile.ts | 10 ++ 10 files changed, 196 insertions(+), 203 deletions(-) diff --git a/packages/cli/src/commands/run-affected-command.ts b/packages/cli/src/commands/run-affected-command.ts index 8fecc2d..3faaae2 100644 --- a/packages/cli/src/commands/run-affected-command.ts +++ b/packages/cli/src/commands/run-affected-command.ts @@ -1,4 +1,8 @@ -import { detectLockfile, findAffectedPackages } from '@lockfile-affected/core'; +import { + detectLockfile, + findAffectedPackages, + loadWorkspaceManifests, +} from '@lockfile-affected/core'; import { isSupportedFormat, lockfileParsers, @@ -11,14 +15,20 @@ 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 { - 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}`); @@ -26,26 +36,23 @@ export async function runAffectedCommand(options: CliOptions): Promise { 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); diff --git a/packages/core/src/affected/find-affected-packages.test.ts b/packages/core/src/affected/find-affected-packages.test.ts index ec69da8..304adc1 100644 --- a/packages/core/src/affected/find-affected-packages.test.ts +++ b/packages/core/src/affected/find-affected-packages.test.ts @@ -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> { + const snapshot = new Map>(); + const rootPackages = new Map(); + 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 => { - const snapshot = new Map>(); - const rootPackages = new Map(); - 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; devDeps?: Record }[] +) { + 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); diff --git a/packages/core/src/affected/find-affected-packages.ts b/packages/core/src/affected/find-affected-packages.ts index b6b5ca3..5dbb4d5 100644 --- a/packages/core/src/affected/find-affected-packages.ts +++ b/packages/core/src/affected/find-affected-packages.ts @@ -1,22 +1,20 @@ 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 */ @@ -24,20 +22,13 @@ export type FindAffectedOptions = { }; /** - * 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> { - 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 { + const diff = diffLockfileSnapshots(options.snapshotBefore, options.snapshotAfter); + const workspaceGraph = buildWorkspaceGraph(options.manifests); if (options.rootDepsAffectAll) { const resolveOptions = { diff --git a/packages/core/src/lockfile/detect-lockfile.test.ts b/packages/core/src/lockfile/detect-lockfile.test.ts index 2154af5..229f6ca 100644 --- a/packages/core/src/lockfile/detect-lockfile.test.ts +++ b/packages/core/src/lockfile/detect-lockfile.test.ts @@ -1,97 +1,92 @@ -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 { describe, expect, it } from 'vitest'; import type { LockfileParser, LockfileSnapshot } from '../types/lockfile.js'; import { detectLockfile } from './detect-lockfile.js'; -function makeTempDir(): string { - return join(tmpdir(), `detect-lockfile-test-${randomBytes(6).toString('hex')}`); -} - -function makeParser(format: string, lockfileNames: readonly string[]): LockfileParser { +function makeParser(format: string, detect: (content: string) => boolean): LockfileParser { return { format, - lockfileNames, + detect, parse: async (_content: string): Promise => new Map(), }; } describe('detectLockfile', () => { - let dir: string; - - beforeEach(async () => { - dir = makeTempDir(); - await mkdir(dir, { recursive: true }); - }); - - it('detects a lockfile matching the first parser', async () => { - await writeFile(join(dir, 'pnpm-lock.yaml'), ''); + it('detects pnpm lockfile content', () => { + const content = ` +importers: + .: + dependencies: + react: ^18.0.0 +`; const parsers = [ - makeParser('pnpm', ['pnpm-lock.yaml']), - makeParser('npm', ['package-lock.json']), + makeParser('pnpm', (c) => c.includes('importers:')), + makeParser('npm', (c) => c.includes('"packages"')), ]; - const result = await detectLockfile(dir, parsers); + const result = detectLockfile(content, parsers); - expect(result.format).toBe('pnpm'); - expect(result.path).toBe(join(dir, 'pnpm-lock.yaml')); + expect(result).toBe('pnpm'); }); - it('detects a lockfile matching the second parser when first is absent', async () => { - await writeFile(join(dir, 'package-lock.json'), ''); + it('detects npm lockfile content', () => { + const content = JSON.stringify({ + name: 'test', + packages: { + 'node_modules/react': { version: '18.0.0' }, + }, + }); const parsers = [ - makeParser('pnpm', ['pnpm-lock.yaml']), - makeParser('npm', ['package-lock.json']), + makeParser('pnpm', (c) => c.includes('importers:')), + makeParser('npm', (c) => c.includes('"packages"')), ]; - const result = await detectLockfile(dir, parsers); + const result = detectLockfile(content, parsers); - expect(result.format).toBe('npm'); - expect(result.path).toBe(join(dir, 'package-lock.json')); + expect(result).toBe('npm'); }); - it('prefers the first parser when multiple lockfiles exist', async () => { - await writeFile(join(dir, 'pnpm-lock.yaml'), ''); - await writeFile(join(dir, 'package-lock.json'), ''); + it('prefers the first parser when multiple match', () => { + const content = ` +importers: + .: + dependencies: + react: ^18.0.0 +`; const parsers = [ - makeParser('pnpm', ['pnpm-lock.yaml']), - makeParser('npm', ['package-lock.json']), + makeParser('pnpm', (c) => c.includes('importers:')), + makeParser('npm', (c) => c.includes('importers:')), ]; - const result = await detectLockfile(dir, parsers); - - expect(result.format).toBe('pnpm'); - }); - - it('checks all lockfileNames within a parser', async () => { - await writeFile(join(dir, 'npm-shrinkwrap.json'), ''); - const parsers = [makeParser('npm', ['package-lock.json', 'npm-shrinkwrap.json'])]; - - const result = await detectLockfile(dir, parsers); + const result = detectLockfile(content, parsers); - expect(result.format).toBe('npm'); - expect(result.path).toBe(join(dir, 'npm-shrinkwrap.json')); + expect(result).toBe('pnpm'); }); - it('throws when no lockfile is found', async () => { + it('throws when no parser matches', () => { + const content = 'some unknown format'; const parsers = [ - makeParser('pnpm', ['pnpm-lock.yaml']), - makeParser('npm', ['package-lock.json']), + makeParser('pnpm', (c) => c.includes('importers:')), + makeParser('npm', (c) => c.includes('"packages"')), ]; - await expect(detectLockfile(dir, parsers)).rejects.toThrow(/No lockfile found/); + expect(() => detectLockfile(content, parsers)).toThrow(/Unable to detect lockfile format/); }); - it('includes all candidate names in the error message', async () => { + it('includes available formats in error message', () => { + const content = 'unknown'; const parsers = [ - makeParser('pnpm', ['pnpm-lock.yaml']), - makeParser('npm', ['package-lock.json', 'npm-shrinkwrap.json']), + makeParser('pnpm', (c) => c.includes('importers:')), + makeParser('npm', (c) => c.includes('packages')), + makeParser('yarn', (c) => c.includes('metadata')), ]; - await expect(detectLockfile(dir, parsers)).rejects.toThrow( - /pnpm-lock\.yaml.*package-lock\.json.*npm-shrinkwrap\.json/, - ); + try { + detectLockfile(content, parsers); + } catch (e) { + const error = e as Error; + expect(error.message).toContain('pnpm'); + expect(error.message).toContain('npm'); + expect(error.message).toContain('yarn'); + } }); }); diff --git a/packages/core/src/lockfile/detect-lockfile.ts b/packages/core/src/lockfile/detect-lockfile.ts index 0bfa5e0..b1fda18 100644 --- a/packages/core/src/lockfile/detect-lockfile.ts +++ b/packages/core/src/lockfile/detect-lockfile.ts @@ -1,5 +1,3 @@ -import { access } from 'node:fs/promises'; -import { join } from 'node:path'; import type { LockfileParser } from '../types/lockfile.js'; export type DetectedLockfile = { @@ -8,28 +6,19 @@ export type DetectedLockfile = { }; /** - * Finds the first known lockfile in `dir` by checking each parser's `lockfileNames`. - * Returns the absolute path and the format name from the matching parser. - * Throws if no lockfile is found. - * - * Supports: pnpm, npm, yarn, and bun lockfile formats. + * Detects the lockfile format from content by trying each parser's detect method. + * Returns the format name from the matching parser. + * Throws if no format can be detected. */ -export async function detectLockfile( - dir: string, - parsers: readonly LockfileParser[], -): Promise { +export function detectLockfile(content: string, parsers: readonly LockfileParser[]): string { for (const parser of parsers) { - for (const filename of parser.lockfileNames) { - const fullPath = join(dir, filename); - try { - await access(fullPath); - return { path: fullPath, format: parser.format }; - } catch { - // not found, try next - } + if (parser.detect(content)) { + return parser.format; } } - const allNames = parsers.flatMap((p) => p.lockfileNames); - throw new Error(`No lockfile found in ${dir}. Expected one of: ${allNames.join(', ')}`); + const availableFormats = parsers.map((p) => p.format); + throw new Error( + `Unable to detect lockfile format. Available formats: ${availableFormats.join(', ')}`, + ); } diff --git a/packages/core/src/types/lockfile.ts b/packages/core/src/types/lockfile.ts index fcae63d..0ab98b0 100644 --- a/packages/core/src/types/lockfile.ts +++ b/packages/core/src/types/lockfile.ts @@ -97,7 +97,9 @@ export type LockfileParser = { /** Human-readable name for this format, e.g. "pnpm" */ readonly format: string; /** Filenames this parser handles, e.g. ["pnpm-lock.yaml"] */ - readonly lockfileNames: readonly string[]; + readonly lockfileNames?: readonly string[]; + /** Detect if this parser can handle the given lockfile content */ + readonly detect: (content: string) => boolean; /** Parse raw lockfile content into a normalized snapshot */ readonly parse: (content: string) => Promise; }; diff --git a/packages/lockfile-bun/src/parse-bun-lockfile.ts b/packages/lockfile-bun/src/parse-bun-lockfile.ts index ddfd9b4..2bd45ec 100644 --- a/packages/lockfile-bun/src/parse-bun-lockfile.ts +++ b/packages/lockfile-bun/src/parse-bun-lockfile.ts @@ -62,8 +62,13 @@ function extractVersion(nameAtVersion: string | undefined): string | undefined { return atIndex === -1 ? nameAtVersion : nameAtVersion.slice(atIndex + 1); } +export function detectBunLockfile(content: string): boolean { + return content.includes('workspaces') && content.includes('packages'); +} + export const bunLockfileParser: LockfileParser = { format: 'bun', lockfileNames: ['bun.lock'], + detect: detectBunLockfile, parse: parseBunLockfile, }; diff --git a/packages/lockfile-npm/src/parse-npm-lockfile.ts b/packages/lockfile-npm/src/parse-npm-lockfile.ts index e7d5326..e7c75a5 100644 --- a/packages/lockfile-npm/src/parse-npm-lockfile.ts +++ b/packages/lockfile-npm/src/parse-npm-lockfile.ts @@ -95,8 +95,18 @@ function getDependencyKeys(context: string, packages: Record { const parsed = YAML.parse(content) as Record; return Promise.resolve(toSnapshot(parsed)); @@ -102,5 +111,6 @@ function extractPackageName(key: string): string { export const yarnLockfileParser: LockfileParser = { format: 'yarn', lockfileNames: ['yarn.lock'], + detect: detectYarnLockfile, parse: parseYarnLockfile, };