From 737752a66db632812034bcda1f04939568b4e01f Mon Sep 17 00:00:00 2001 From: Henri Lahtinen Date: Wed, 1 Apr 2026 09:11:03 +0300 Subject: [PATCH 1/3] feat: implement --root-deps-affect-all CLI flag - Add --root-deps-affect-all flag to mark workspace packages as affected when root dependencies change in the lockfile - Root package is excluded from results - only workspace packages appear - Filter by dependency type (dependencies, devDependencies, peerDependencies, optionalDependencies) - Root detection by path instead of private field (more robust) - Simplify resolve-affected-packages by excluding root from workspace graph --- .../bun-root-deps-affect-all/after-bun.lock | 26 ++++ .../bun-root-deps-affect-all/before-bun.lock | 26 ++++ .../workspace/package.json | 13 ++ .../workspace/packages/pkg-a/package.json | 7 + .../after-package-lock.json | 33 +++++ .../before-package-lock.json | 33 +++++ .../workspace/package.json | 13 ++ .../workspace/packages/pkg-a/package.json | 7 + .../after-pnpm-lock.yaml | 31 ++++ .../before-pnpm-lock.yaml | 31 ++++ .../workspace/package.json | 13 ++ .../workspace/packages/pkg-a/package.json | 7 + .../yarn-root-deps-affect-all/after-yarn.lock | 26 ++++ .../before-yarn.lock | 26 ++++ .../workspace/package.json | 13 ++ .../workspace/packages/pkg-a/package.json | 7 + .../transitive-dependency-detection.test.ts | 132 ++++++++++++++++++ .../cli/src/commands/run-affected-command.ts | 20 ++- packages/cli/src/options/cli-options.types.ts | 2 + packages/cli/src/options/parse-cli-args.ts | 5 + .../src/options/to-dependency-filter.test.ts | 11 +- .../cli/src/options/to-dependency-filter.ts | 19 ++- .../src/affected/find-affected-packages.ts | 22 ++- .../core/src/affected/find-dependents.test.ts | 4 +- .../resolve-affected-packages.test.ts | 30 ++-- .../src/affected/resolve-affected-packages.ts | 73 ++++++++++ packages/core/src/index.ts | 2 +- packages/core/src/types/lockfile.ts | 14 +- .../workspace/build-workspace-graph.test.ts | 25 ++-- .../src/workspace/build-workspace-graph.ts | 12 +- .../load-workspace-manifests.test.ts | 60 +++++--- .../src/workspace/load-workspace-manifests.ts | 36 ++++- 32 files changed, 706 insertions(+), 73 deletions(-) create mode 100644 packages/cli/e2e/fixtures/bun-root-deps-affect-all/after-bun.lock create mode 100644 packages/cli/e2e/fixtures/bun-root-deps-affect-all/before-bun.lock create mode 100644 packages/cli/e2e/fixtures/bun-root-deps-affect-all/workspace/package.json create mode 100644 packages/cli/e2e/fixtures/bun-root-deps-affect-all/workspace/packages/pkg-a/package.json create mode 100644 packages/cli/e2e/fixtures/npm-root-deps-affect-all/after-package-lock.json create mode 100644 packages/cli/e2e/fixtures/npm-root-deps-affect-all/before-package-lock.json create mode 100644 packages/cli/e2e/fixtures/npm-root-deps-affect-all/workspace/package.json create mode 100644 packages/cli/e2e/fixtures/npm-root-deps-affect-all/workspace/packages/pkg-a/package.json create mode 100644 packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/after-pnpm-lock.yaml create mode 100644 packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/before-pnpm-lock.yaml create mode 100644 packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/workspace/package.json create mode 100644 packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/workspace/packages/pkg-a/package.json create mode 100644 packages/cli/e2e/fixtures/yarn-root-deps-affect-all/after-yarn.lock create mode 100644 packages/cli/e2e/fixtures/yarn-root-deps-affect-all/before-yarn.lock create mode 100644 packages/cli/e2e/fixtures/yarn-root-deps-affect-all/workspace/package.json create mode 100644 packages/cli/e2e/fixtures/yarn-root-deps-affect-all/workspace/packages/pkg-a/package.json diff --git a/packages/cli/e2e/fixtures/bun-root-deps-affect-all/after-bun.lock b/packages/cli/e2e/fixtures/bun-root-deps-affect-all/after-bun.lock new file mode 100644 index 0000000..58708ef --- /dev/null +++ b/packages/cli/e2e/fixtures/bun-root-deps-affect-all/after-bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "test-monorepo", + "dependencies": { + "lodash": "^4.17.0" + }, + "devDependencies": { + "vitest": "^1.0.0" + } + }, + "packages/pkg-a": { + "name": "pkg-a", + "version": "1.0.0", + "dependencies": {} + } + }, + "packages": { + "lodash": ["lodash@4.17.22", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZbet2yk9dzQ1uFR5/0w=="], + "lodash@4.17.22": ["lodash@4.17.22", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZbet2yk9dzQ1uFR5/0w=="], + "vitest": ["vitest@1.0.0", "", {}, "sha512-dummy"], + "vitest@1.0.0": ["vitest@1.0.0", "", {}, "sha100-dummy"] + } +} diff --git a/packages/cli/e2e/fixtures/bun-root-deps-affect-all/before-bun.lock b/packages/cli/e2e/fixtures/bun-root-deps-affect-all/before-bun.lock new file mode 100644 index 0000000..1dee0ff --- /dev/null +++ b/packages/cli/e2e/fixtures/bun-root-deps-affect-all/before-bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "test-monorepo", + "dependencies": { + "lodash": "^4.17.0" + }, + "devDependencies": { + "vitest": "^1.0.0" + } + }, + "packages/pkg-a": { + "name": "pkg-a", + "version": "1.0.0", + "dependencies": {} + } + }, + "packages": { + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZbet2yk9dzQ1uFR5/0w=="], + "lodash@4.17.21": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZbet2yk9dzQ1uFR5/0w=="], + "vitest": ["vitest@1.0.0", "", {}, "sha512-dummy"], + "vitest@1.0.0": ["vitest@1.0.0", "", {}, "sha512-dummy"] + } +} diff --git a/packages/cli/e2e/fixtures/bun-root-deps-affect-all/workspace/package.json b/packages/cli/e2e/fixtures/bun-root-deps-affect-all/workspace/package.json new file mode 100644 index 0000000..551ab99 --- /dev/null +++ b/packages/cli/e2e/fixtures/bun-root-deps-affect-all/workspace/package.json @@ -0,0 +1,13 @@ +{ + "name": "test-monorepo", + "private": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "lodash": "^4.17.0" + }, + "devDependencies": { + "vitest": "^1.0.0" + } +} diff --git a/packages/cli/e2e/fixtures/bun-root-deps-affect-all/workspace/packages/pkg-a/package.json b/packages/cli/e2e/fixtures/bun-root-deps-affect-all/workspace/packages/pkg-a/package.json new file mode 100644 index 0000000..8fbdf7c --- /dev/null +++ b/packages/cli/e2e/fixtures/bun-root-deps-affect-all/workspace/packages/pkg-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "pkg-a", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.0" + } +} diff --git a/packages/cli/e2e/fixtures/npm-root-deps-affect-all/after-package-lock.json b/packages/cli/e2e/fixtures/npm-root-deps-affect-all/after-package-lock.json new file mode 100644 index 0000000..9670e64 --- /dev/null +++ b/packages/cli/e2e/fixtures/npm-root-deps-affect-all/after-package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "test-monorepo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test-monorepo", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.0" + }, + "devDependencies": { + "vitest": "^1.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.22.tgz" + }, + "node_modules/vitest": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.0.0.tgz" + }, + "packages/pkg-a": { + "version": "1.0.0", + "dependencies": {} + }, + "packages/pkg-a/node_modules/lodash": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.22.tgz" + } + } +} diff --git a/packages/cli/e2e/fixtures/npm-root-deps-affect-all/before-package-lock.json b/packages/cli/e2e/fixtures/npm-root-deps-affect-all/before-package-lock.json new file mode 100644 index 0000000..26171cf --- /dev/null +++ b/packages/cli/e2e/fixtures/npm-root-deps-affect-all/before-package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "test-monorepo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test-monorepo", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.0" + }, + "devDependencies": { + "vitest": "^1.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" + }, + "node_modules/vitest": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.0.0.tgz" + }, + "packages/pkg-a": { + "version": "1.0.0", + "dependencies": {} + }, + "packages/pkg-a/node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" + } + } +} diff --git a/packages/cli/e2e/fixtures/npm-root-deps-affect-all/workspace/package.json b/packages/cli/e2e/fixtures/npm-root-deps-affect-all/workspace/package.json new file mode 100644 index 0000000..551ab99 --- /dev/null +++ b/packages/cli/e2e/fixtures/npm-root-deps-affect-all/workspace/package.json @@ -0,0 +1,13 @@ +{ + "name": "test-monorepo", + "private": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "lodash": "^4.17.0" + }, + "devDependencies": { + "vitest": "^1.0.0" + } +} diff --git a/packages/cli/e2e/fixtures/npm-root-deps-affect-all/workspace/packages/pkg-a/package.json b/packages/cli/e2e/fixtures/npm-root-deps-affect-all/workspace/packages/pkg-a/package.json new file mode 100644 index 0000000..8fbdf7c --- /dev/null +++ b/packages/cli/e2e/fixtures/npm-root-deps-affect-all/workspace/packages/pkg-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "pkg-a", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.0" + } +} diff --git a/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/after-pnpm-lock.yaml b/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/after-pnpm-lock.yaml new file mode 100644 index 0000000..604881e --- /dev/null +++ b/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/after-pnpm-lock.yaml @@ -0,0 +1,31 @@ +lockfileVersion: '9.0' + +metadata: + packageManager: pnpm@9.15.9 + +importers: + .: + dependencies: + lodash: + specifier: ^4.17.0 + version: 4.17.22 + devDependencies: + vitest: + specifier: ^1.0.0 + version: 1.0.0 + packages/pkg-a: + dependencies: {} + +packages: + lodash@4.17.22: + resolution: + { + integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZbet2yk9dzQ1uFR5/0w==, + } + + vitest@1.0.0: + resolution: { integrity: sha512-dummy } + +snapshots: + lodash@4.17.22: {} + vitest@1.0.0: {} diff --git a/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/before-pnpm-lock.yaml b/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/before-pnpm-lock.yaml new file mode 100644 index 0000000..83bad56 --- /dev/null +++ b/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/before-pnpm-lock.yaml @@ -0,0 +1,31 @@ +lockfileVersion: '9.0' + +metadata: + packageManager: pnpm@9.15.9 + +importers: + .: + dependencies: + lodash: + specifier: ^4.17.0 + version: 4.17.21 + devDependencies: + vitest: + specifier: ^1.0.0 + version: 1.0.0 + packages/pkg-a: + dependencies: {} + +packages: + lodash@4.17.21: + resolution: + { + integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4sLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZbet2yk9dzQ1uFR5/0w==, + } + + vitest@1.0.0: + resolution: { integrity: sha512-dummy } + +snapshots: + lodash@4.17.21: {} + vitest@1.0.0: {} diff --git a/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/workspace/package.json b/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/workspace/package.json new file mode 100644 index 0000000..551ab99 --- /dev/null +++ b/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/workspace/package.json @@ -0,0 +1,13 @@ +{ + "name": "test-monorepo", + "private": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "lodash": "^4.17.0" + }, + "devDependencies": { + "vitest": "^1.0.0" + } +} diff --git a/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/workspace/packages/pkg-a/package.json b/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/workspace/packages/pkg-a/package.json new file mode 100644 index 0000000..8fbdf7c --- /dev/null +++ b/packages/cli/e2e/fixtures/pnpm-root-deps-affect-all/workspace/packages/pkg-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "pkg-a", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.0" + } +} diff --git a/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/after-yarn.lock b/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/after-yarn.lock new file mode 100644 index 0000000..d6a37bb --- /dev/null +++ b/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/after-yarn.lock @@ -0,0 +1,26 @@ +# yarn.lock - Yarn Berry (v4) format + +__metadata: + version: 6 + cacheKey: 8 + +"lodash@npm:^4.17.20": + version: 4.17.22 + resolution: "lodash@npm:4.17.22" + checksum: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZbet2yk9dzQ1uFR5/0w== + languageName: node + linkType: hard + +"vitest@npm:^1.0.0": + version: 1.0.0 + resolution: "vitest@npm:1.0.0" + checksum: sha512-dummy + languageName: node + linkType: hard + +"pkg-a@workspace:packages/pkg-a": + version: 1.0.0 + resolution: "pkg-a@workspace:packages/pkg-a" + checksum: sha512-pkg-a-1-0-0 + languageName: node + linkType: soft diff --git a/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/before-yarn.lock b/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/before-yarn.lock new file mode 100644 index 0000000..b02c211 --- /dev/null +++ b/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/before-yarn.lock @@ -0,0 +1,26 @@ +# yarn.lock - Yarn Berry (v4) format + +__metadata: + version: 6 + cacheKey: 8 + +"lodash@npm:^4.17.20": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZbet2yk9dzQ1uFR5/0w== + languageName: node + linkType: hard + +"vitest@npm:^1.0.0": + version: 1.0.0 + resolution: "vitest@npm:1.0.0" + checksum: sha512-dummy + languageName: node + linkType: hard + +"pkg-a@workspace:packages/pkg-a": + version: 1.0.0 + resolution: "pkg-a@workspace:packages/pkg-a" + checksum: sha512-pkg-a-1-0-0 + languageName: node + linkType: soft diff --git a/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/workspace/package.json b/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/workspace/package.json new file mode 100644 index 0000000..551ab99 --- /dev/null +++ b/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/workspace/package.json @@ -0,0 +1,13 @@ +{ + "name": "test-monorepo", + "private": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "lodash": "^4.17.0" + }, + "devDependencies": { + "vitest": "^1.0.0" + } +} diff --git a/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/workspace/packages/pkg-a/package.json b/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/workspace/packages/pkg-a/package.json new file mode 100644 index 0000000..8fbdf7c --- /dev/null +++ b/packages/cli/e2e/fixtures/yarn-root-deps-affect-all/workspace/packages/pkg-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "pkg-a", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.0" + } +} diff --git a/packages/cli/e2e/transitive-dependency-detection.test.ts b/packages/cli/e2e/transitive-dependency-detection.test.ts index e1e7044..653883f 100644 --- a/packages/cli/e2e/transitive-dependency-detection.test.ts +++ b/packages/cli/e2e/transitive-dependency-detection.test.ts @@ -226,3 +226,135 @@ describe.each(fixtures)( }); }, ); + +const rootDepsFixtures = [ + { + format: 'pnpm', + name: 'pnpm', + beforeLock: 'before-pnpm-lock.yaml', + afterLock: 'after-pnpm-lock.yaml', + }, + { + format: 'npm', + name: 'npm', + beforeLock: 'before-package-lock.json', + afterLock: 'after-package-lock.json', + }, + { + format: 'yarn', + name: 'yarn', + beforeLock: 'before-yarn.lock', + afterLock: 'after-yarn.lock', + }, + { + format: 'bun', + name: 'bun', + beforeLock: 'before-bun.lock', + afterLock: 'after-bun.lock', + }, +] as const; + +describe.each(rootDepsFixtures)( + 'E2E: --root-deps-affect-all ($name)', + ({ format, name, beforeLock, afterLock }) => { + const fixturesDir = join(__dirname, 'fixtures', `${name}-root-deps-affect-all`); + + it('detects affected packages when --root-deps-affect-all is NOT used (normal detection)', async () => { + const workspaceRoot = join(fixturesDir, 'workspace'); + const beforeLockfilePath = join(fixturesDir, beforeLock); + const afterLockfilePath = join(fixturesDir, afterLock); + + const options: CliOptions = { + lockfileBefore: beforeLockfilePath, + lockfileAfter: afterLockfilePath, + workspaceRoot, + output: 'json', + format, + deps: true, + dev: true, + peer: true, + optional: true, + rootDepsAffectAll: false, + }; + + const result = await runAffectedCommand(options); + + const affected = JSON.parse(result); + expect(affected).toContain('pkg-a'); + }); + + it('affects workspace packages that depend on changed root deps when --root-deps-affect-all is used', async () => { + const workspaceRoot = join(fixturesDir, 'workspace'); + const beforeLockfilePath = join(fixturesDir, beforeLock); + const afterLockfilePath = join(fixturesDir, afterLock); + + const options: CliOptions = { + lockfileBefore: beforeLockfilePath, + lockfileAfter: afterLockfilePath, + workspaceRoot, + output: 'json', + format, + deps: true, + dev: true, + peer: true, + optional: true, + rootDepsAffectAll: true, + }; + + const result = await runAffectedCommand(options); + + const affected = JSON.parse(result); + expect(affected).toContain('pkg-a'); + expect(affected).not.toContain('test-monorepo'); + }); + + it('affects workspace packages when --root-deps-affect-all is used even if filter only includes some dep types', async () => { + const workspaceRoot = join(fixturesDir, 'workspace'); + const beforeLockfilePath = join(fixturesDir, beforeLock); + const afterLockfilePath = join(fixturesDir, afterLock); + + const options: CliOptions = { + lockfileBefore: beforeLockfilePath, + lockfileAfter: afterLockfilePath, + workspaceRoot, + output: 'json', + format, + deps: true, + dev: false, + peer: false, + optional: false, + rootDepsAffectAll: true, + }; + + const result = await runAffectedCommand(options); + + const affected = JSON.parse(result); + expect(affected).toContain('pkg-a'); + expect(affected).not.toContain('test-monorepo'); + }); + + it('affects no packages when --root-deps-affect-all is used but filter excludes all changed root dep types', async () => { + const workspaceRoot = join(fixturesDir, 'workspace'); + const beforeLockfilePath = join(fixturesDir, beforeLock); + const afterLockfilePath = join(fixturesDir, afterLock); + + const options: CliOptions = { + lockfileBefore: beforeLockfilePath, + lockfileAfter: afterLockfilePath, + workspaceRoot, + output: 'json', + format, + deps: false, + dev: false, + peer: true, + optional: false, + rootDepsAffectAll: true, + }; + + const result = await runAffectedCommand(options); + + const affected = JSON.parse(result); + expect(affected).toHaveLength(0); + }); + }, +); diff --git a/packages/cli/src/commands/run-affected-command.ts b/packages/cli/src/commands/run-affected-command.ts index ebf0bbc..2c38836 100644 --- a/packages/cli/src/commands/run-affected-command.ts +++ b/packages/cli/src/commands/run-affected-command.ts @@ -1,4 +1,9 @@ -import { detectLockfile, findAffectedPackages } from '@lockfile-affected/core'; +import { + detectLockfile, + findAffectedPackages, + type LockfileParser, + type DependencyFilter, +} from '@lockfile-affected/core'; import { isSupportedFormat, lockfileParsers, @@ -32,13 +37,20 @@ export async function runAffectedCommand(options: CliOptions): Promise { ]); const filter = toDependencyFilter(options); - const affected = await findAffectedPackages({ + const hasFilter = + filter.dependencies || + filter.devDependencies || + filter.peerDependencies || + filter.optionalDependencies; + const findOptions = { beforeContent, afterContent, parser, workspaceRoot: options.workspaceRoot, - filter, - }); + ...(hasFilter && { 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/cli/src/options/cli-options.types.ts b/packages/cli/src/options/cli-options.types.ts index f5d3484..2181a6c 100644 --- a/packages/cli/src/options/cli-options.types.ts +++ b/packages/cli/src/options/cli-options.types.ts @@ -24,4 +24,6 @@ export type CliOptions = { readonly dev: boolean; readonly peer: boolean; readonly optional: boolean; + /** When enabled, root dependency changes affect all workspace packages */ + readonly rootDepsAffectAll?: boolean; }; diff --git a/packages/cli/src/options/parse-cli-args.ts b/packages/cli/src/options/parse-cli-args.ts index 03c14e7..f68ac8b 100644 --- a/packages/cli/src/options/parse-cli-args.ts +++ b/packages/cli/src/options/parse-cli-args.ts @@ -16,6 +16,7 @@ Options: --peer Include peer dependencies --optional Include optional dependencies (when no dep flags are set, all types are included) + --root-deps-affect-all Treat root dependency changes as affecting all packages --help Show this help message `; @@ -35,6 +36,7 @@ export function parseCliArgs(args: readonly string[]): ParseCliArgsResult { let dev = false; let peer = false; let optional = false; + let rootDepsAffectAll = false; const iter = args[Symbol.iterator](); for (const arg of iter) { @@ -50,6 +52,8 @@ export function parseCliArgs(args: readonly string[]): ParseCliArgsResult { peer = true; } else if (arg === '--optional') { optional = true; + } else if (arg === '--root-deps-affect-all') { + rootDepsAffectAll = true; } else if (arg === '--workspace') { const { value, done } = iter.next(); if (done || !value) throw new Error('--workspace requires a path argument'); @@ -88,6 +92,7 @@ export function parseCliArgs(args: readonly string[]): ParseCliArgsResult { dev, peer, optional, + rootDepsAffectAll, }, }; } diff --git a/packages/cli/src/options/to-dependency-filter.test.ts b/packages/cli/src/options/to-dependency-filter.test.ts index bd5f0f3..80faf97 100644 --- a/packages/cli/src/options/to-dependency-filter.test.ts +++ b/packages/cli/src/options/to-dependency-filter.test.ts @@ -12,16 +12,17 @@ const base: CliOptions = { dev: false, peer: false, optional: false, + rootDepsAffectAll: false, }; describe('toDependencyFilter', () => { - it('returns all types when no flags are set', () => { + it('returns all false when no flags are set', () => { const filter = toDependencyFilter(base); - expect(filter.dependencies).toBe(true); - expect(filter.devDependencies).toBe(true); - expect(filter.peerDependencies).toBe(true); - expect(filter.optionalDependencies).toBe(true); + expect(filter.dependencies).toBe(false); + expect(filter.devDependencies).toBe(false); + expect(filter.peerDependencies).toBe(false); + expect(filter.optionalDependencies).toBe(false); }); it('returns only deps when only --deps is set', () => { diff --git a/packages/cli/src/options/to-dependency-filter.ts b/packages/cli/src/options/to-dependency-filter.ts index b4b0a5a..be1594f 100644 --- a/packages/cli/src/options/to-dependency-filter.ts +++ b/packages/cli/src/options/to-dependency-filter.ts @@ -6,12 +6,6 @@ import type { CliOptions } from './cli-options.types.js'; * When no dep type flags are set, falls back to including all types. */ export function toDependencyFilter(options: CliOptions): DependencyFilter { - const anyExplicit = options.deps || options.dev || options.peer || options.optional; - - if (!anyExplicit) { - return allDependencyTypes; - } - return { dependencies: options.deps, devDependencies: options.dev, @@ -19,3 +13,16 @@ export function toDependencyFilter(options: CliOptions): DependencyFilter { optionalDependencies: options.optional, }; } + +/** + * Returns true only if the filter explicitly excludes a specific dependency type. + * Note: This only works if the filter was created with explicit values. + */ +export function isEmptyFilter(filter: DependencyFilter): boolean { + return ( + !filter.dependencies && + !filter.devDependencies && + !filter.peerDependencies && + !filter.optionalDependencies + ); +} diff --git a/packages/core/src/affected/find-affected-packages.ts b/packages/core/src/affected/find-affected-packages.ts index 315fcc6..a3d351a 100644 --- a/packages/core/src/affected/find-affected-packages.ts +++ b/packages/core/src/affected/find-affected-packages.ts @@ -1,5 +1,5 @@ import { - allDependencyTypes, + allDependencyTypesEnabled, type DependencyFilter, type LockfileParser, } from '../types/lockfile.js'; @@ -19,6 +19,8 @@ export type FindAffectedOptions = { readonly workspaceRoot: string; /** 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; }; /** @@ -35,6 +37,20 @@ export async function findAffectedPackages( ]); const diff = diffLockfileSnapshots(snapshotBefore, snapshotAfter); - const workspaceGraph = buildWorkspaceGraph(manifests); - return resolveAffectedPackages(diff, workspaceGraph, options.filter ?? allDependencyTypes); + const workspaceGraph = buildWorkspaceGraph(manifests, snapshotAfter); + + if (options.rootDepsAffectAll) { + const resolveOptions = { + rootDepsAffectAll: true, + rootContext: diff.changed.has('.'), + }; + return resolveAffectedPackages( + diff, + workspaceGraph, + options.filter ?? allDependencyTypesEnabled, + resolveOptions, + ); + } + + return resolveAffectedPackages(diff, workspaceGraph, options.filter ?? allDependencyTypesEnabled); } diff --git a/packages/core/src/affected/find-dependents.test.ts b/packages/core/src/affected/find-dependents.test.ts index d20f095..a3696df 100644 --- a/packages/core/src/affected/find-dependents.test.ts +++ b/packages/core/src/affected/find-dependents.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { allDependencyTypes, type WorkspaceGraph } from '../types/lockfile.js'; +import { allDependencyTypesEnabled, type WorkspaceGraph } from '../types/lockfile.js'; import { findDependents } from './find-dependents.js'; const pkgWith = ( @@ -49,7 +49,7 @@ describe('findDependents', () => { ['lib', { name: 'lib', dependencyGroups: pkgWith({ dependencies: ['vitest'] }) }], ]); - const dependents = findDependents('vitest', workspace, allDependencyTypes); + const dependents = findDependents('vitest', workspace, allDependencyTypesEnabled); expect(dependents.size).toBe(2); expect(dependents.has('app')).toBe(true); diff --git a/packages/core/src/affected/resolve-affected-packages.test.ts b/packages/core/src/affected/resolve-affected-packages.test.ts index 6b346b1..592da5d 100644 --- a/packages/core/src/affected/resolve-affected-packages.test.ts +++ b/packages/core/src/affected/resolve-affected-packages.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { allDependencyTypes, type LockfileDiff, type WorkspaceGraph } from '../types/lockfile.js'; +import { + allDependencyTypesEnabled, + type LockfileDiff, + type WorkspaceGraph, +} from '../types/lockfile.js'; import { resolveAffectedPackages } from './resolve-affected-packages.js'; const emptyDiff: LockfileDiff = { @@ -28,7 +32,7 @@ describe('resolveAffectedPackages', () => { ['app', { name: 'app', dependencyGroups: pkgWith({ dependencies: ['lodash'] }) }], ]); - const affected = resolveAffectedPackages(emptyDiff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(emptyDiff, workspace, allDependencyTypesEnabled); expect(affected.size).toBe(0); }); @@ -52,7 +56,7 @@ describe('resolveAffectedPackages', () => { ]), }; - const affected = resolveAffectedPackages(diff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(diff, workspace, allDependencyTypesEnabled); expect(affected.size).toBe(1); expect(affected.has('app')).toBe(true); @@ -77,7 +81,7 @@ describe('resolveAffectedPackages', () => { ]), }; - const affected = resolveAffectedPackages(diff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(diff, workspace, allDependencyTypesEnabled); expect(affected.has('app')).toBe(true); }); @@ -177,7 +181,7 @@ describe('resolveAffectedPackages', () => { ]), }; - const affected = resolveAffectedPackages(diff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(diff, workspace, allDependencyTypesEnabled); expect(affected.size).toBe(2); expect(affected.has('app-a')).toBe(true); @@ -203,7 +207,7 @@ describe('resolveAffectedPackages', () => { ]), }; - const affected = resolveAffectedPackages(diff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(diff, workspace, allDependencyTypesEnabled); expect(affected.size).toBe(0); }); @@ -231,7 +235,7 @@ describe('resolveAffectedPackages', () => { ]), }; - const affected = resolveAffectedPackages(diff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(diff, workspace, allDependencyTypesEnabled); expect(affected.size).toBe(3); expect(affected.has('pkg-base')).toBe(true); @@ -258,7 +262,7 @@ describe('resolveAffectedPackages', () => { ]), }; - const affected = resolveAffectedPackages(diff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(diff, workspace, allDependencyTypesEnabled); expect(affected.has('pkg-a')).toBe(true); expect(affected.has('pkg-b')).toBe(true); @@ -309,7 +313,7 @@ describe('resolveAffectedPackages', () => { ]), }; - const affected = resolveAffectedPackages(diff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(diff, workspace, allDependencyTypesEnabled); expect(affected.has('lib-c')).toBe(true); expect(affected.has('lib-a')).toBe(true); @@ -340,7 +344,7 @@ describe('resolveAffectedPackages', () => { ]), }; - const affected = resolveAffectedPackages(diff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(diff, workspace, allDependencyTypesEnabled); expect(affected.size).toBe(2); expect(affected.has('pkg-a')).toBe(true); @@ -370,7 +374,7 @@ describe('resolveAffectedPackages', () => { ]), }; - const affected = resolveAffectedPackages(diff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(diff, workspace, allDependencyTypesEnabled); expect(affected.size).toBe(3); expect(affected.has('pkg-a')).toBe(true); @@ -399,7 +403,7 @@ describe('resolveAffectedPackages', () => { ]), }; - const affected = resolveAffectedPackages(diff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(diff, workspace, allDependencyTypesEnabled); expect(affected.size).toBe(1); expect(affected.has('pkg-a')).toBe(true); @@ -428,7 +432,7 @@ describe('resolveAffectedPackages', () => { ]), }; - const affected = resolveAffectedPackages(diff, workspace, allDependencyTypes); + const affected = resolveAffectedPackages(diff, workspace, allDependencyTypesEnabled); expect(affected.size).toBe(3); expect(affected.has('pkg-a')).toBe(true); diff --git a/packages/core/src/affected/resolve-affected-packages.ts b/packages/core/src/affected/resolve-affected-packages.ts index 11d5a6b..09f5176 100644 --- a/packages/core/src/affected/resolve-affected-packages.ts +++ b/packages/core/src/affected/resolve-affected-packages.ts @@ -5,11 +5,16 @@ import type { WorkspaceGraph, } from '../types/lockfile.js'; import { findDependents } from './find-dependents.js'; +import { allDependencyTypes } from '../types/lockfile.js'; export function resolveAffectedPackages( diff: LockfileDiff, workspace: WorkspaceGraph, filter: DependencyFilter, + options?: { + readonly rootDepsAffectAll?: boolean; + readonly rootContext?: boolean; + }, ): ReadonlySet { const changedNames = collectChangedDependencyNames(diff); @@ -17,6 +22,10 @@ export function resolveAffectedPackages( return new Set(); } + if (options?.rootDepsAffectAll && isEmptyFilter(filter)) { + return new Set(); + } + const directlyAffected = new Set(); for (const [packageName] of workspace) { @@ -31,6 +40,22 @@ export function resolveAffectedPackages( } } + if (options?.rootDepsAffectAll && options?.rootContext) { + const rootChanged = hasRootChanged(diff); + if (rootChanged && hasFilterOverlap(filter)) { + const rootChangedNames = getRootChangedNames(diff); + for (const [packageName, pkg] of workspace) { + for (const depType of allDependencyTypes) { + if (!filter[depType]) continue; + if (hasOverlap(pkg.dependencyGroups[depType], rootChangedNames)) { + directlyAffected.add(packageName); + break; + } + } + } + } + } + const allAffected = new Set(directlyAffected); const toProcess = Array.from(directlyAffected); @@ -88,3 +113,51 @@ function hasOverlap(deps: ReadonlySet, changedNames: ReadonlySet } return false; } + +function hasRootChanged(diff: LockfileDiff): boolean { + if (diff.changed.has('.')) return true; + if (diff.addedContexts.has('.')) return true; + if (diff.removedContexts.has('.')) return true; + return false; +} + +function hasFilterOverlap(filter: DependencyFilter): boolean { + return Boolean( + filter.dependencies || + filter.devDependencies || + filter.peerDependencies || + filter.optionalDependencies, + ); +} + +function isEmptyFilter(filter: DependencyFilter): boolean { + return ( + !filter.dependencies && + !filter.devDependencies && + !filter.peerDependencies && + !filter.optionalDependencies + ); +} + +function getRootChangedNames(diff: LockfileDiff): ReadonlySet { + const names = new Set(); + + const rootDiff = diff.changed.get('.'); + if (rootDiff) { + for (const name of rootDiff.added.keys()) names.add(name); + for (const name of rootDiff.removed.keys()) names.add(name); + for (const name of rootDiff.changed.keys()) names.add(name); + } + + const rootAdded = diff.addedContexts.get('.'); + if (rootAdded) { + for (const name of rootAdded.keys()) names.add(name); + } + + const rootRemoved = diff.removedContexts.get('.'); + if (rootRemoved) { + for (const name of rootRemoved.keys()) names.add(name); + } + + return names; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0fc5a8a..ae2a841 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,7 +7,7 @@ export type { WorkspaceGraph, WorkspacePackage, } from './types/lockfile.js'; -export { allDependencyTypes } from './types/lockfile.js'; +export { allDependencyTypes, allDependencyTypesEnabled } from './types/lockfile.js'; export { diffLockfileSnapshots } from './diff/diff-lockfile-snapshots.js'; export { resolveAffectedPackages } from './affected/resolve-affected-packages.js'; export { findAffectedPackages } from './affected/find-affected-packages.js'; diff --git a/packages/core/src/types/lockfile.ts b/packages/core/src/types/lockfile.ts index 3e4edf3..fcae63d 100644 --- a/packages/core/src/types/lockfile.ts +++ b/packages/core/src/types/lockfile.ts @@ -59,13 +59,21 @@ export type DependencyFilter = { readonly optionalDependencies?: boolean; }; -/** A DependencyFilter that includes all dependency types. */ -export const allDependencyTypes: DependencyFilter = { +export const allDependencyTypes = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] satisfies (keyof DependencyFilter)[]; + +export type DependencyTypeKey = (typeof allDependencyTypes)[number]; + +export const allDependencyTypesEnabled = { dependencies: true, devDependencies: true, peerDependencies: true, optionalDependencies: true, -}; +} satisfies DependencyFilter; /** * Represents a single package in the workspace (monorepo member). diff --git a/packages/core/src/workspace/build-workspace-graph.test.ts b/packages/core/src/workspace/build-workspace-graph.test.ts index 45db00d..c016041 100644 --- a/packages/core/src/workspace/build-workspace-graph.test.ts +++ b/packages/core/src/workspace/build-workspace-graph.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; +import type { PackageManifest } from './build-workspace-graph.js'; import { buildWorkspaceGraph } from './build-workspace-graph.js'; describe('buildWorkspaceGraph', () => { - it('builds a graph from package.json manifests', () => { - const manifests = [ + it('builds a graph from package.json manifests', async () => { + const manifests: PackageManifest[] = [ { name: 'app', dependencies: { lodash: '^4.0.0' }, @@ -16,7 +17,7 @@ describe('buildWorkspaceGraph', () => { }, ]; - const graph = buildWorkspaceGraph(manifests); + const graph = await buildWorkspaceGraph(manifests); expect(graph.size).toBe(2); @@ -30,10 +31,10 @@ describe('buildWorkspaceGraph', () => { expect(utils?.dependencyGroups.peerDependencies.has('react')).toBe(true); }); - it('handles packages with no dependencies', () => { - const manifests = [{ name: 'empty-pkg' }]; + it('handles packages with no dependencies', async () => { + const manifests: PackageManifest[] = [{ name: 'empty-pkg' }]; - const graph = buildWorkspaceGraph(manifests); + const graph = await buildWorkspaceGraph(manifests); const pkg = graph.get('empty-pkg'); expect(pkg).toBeDefined(); @@ -43,20 +44,20 @@ describe('buildWorkspaceGraph', () => { expect(pkg?.dependencyGroups.optionalDependencies.size).toBe(0); }); - it('skips manifests without a name', () => { - const manifests = [ + it('skips manifests without a name', async () => { + const manifests: PackageManifest[] = [ { dependencies: { lodash: '4.0.0' } }, { name: 'valid-pkg', dependencies: { react: '18.0.0' } }, ]; - const graph = buildWorkspaceGraph(manifests); + const graph = await buildWorkspaceGraph(manifests); expect(graph.size).toBe(1); expect(graph.has('valid-pkg')).toBe(true); }); - it('keeps each dependency type in its own group', () => { - const manifests = [ + it('keeps each dependency type in its own group', async () => { + const manifests: PackageManifest[] = [ { name: 'full-pkg', dependencies: { a: '1.0.0' }, @@ -66,7 +67,7 @@ describe('buildWorkspaceGraph', () => { }, ]; - const graph = buildWorkspaceGraph(manifests); + const graph = await buildWorkspaceGraph(manifests); const pkg = graph.get('full-pkg'); expect(pkg?.dependencyGroups.dependencies.has('a')).toBe(true); diff --git a/packages/core/src/workspace/build-workspace-graph.ts b/packages/core/src/workspace/build-workspace-graph.ts index b660334..2afb2be 100644 --- a/packages/core/src/workspace/build-workspace-graph.ts +++ b/packages/core/src/workspace/build-workspace-graph.ts @@ -1,4 +1,9 @@ -import type { DependencyGroups, WorkspaceGraph, WorkspacePackage } from '../types/lockfile.js'; +import type { + DependencyGroups, + LockfileSnapshot, + WorkspaceGraph, + WorkspacePackage, +} from '../types/lockfile.js'; /** * Shape of a package.json relevant to building the workspace graph. @@ -16,7 +21,10 @@ export type PackageManifest = { * Packages without a name field are ignored. * Each dependency type is kept in its own group to allow fine-grained filtering. */ -export function buildWorkspaceGraph(manifests: readonly PackageManifest[]): WorkspaceGraph { +export function buildWorkspaceGraph( + manifests: readonly PackageManifest[], + lockfileSnapshot?: LockfileSnapshot, +): WorkspaceGraph { const graph = new Map(); for (const manifest of manifests) { diff --git a/packages/core/src/workspace/load-workspace-manifests.test.ts b/packages/core/src/workspace/load-workspace-manifests.test.ts index d76f2bf..b050bdd 100644 --- a/packages/core/src/workspace/load-workspace-manifests.test.ts +++ b/packages/core/src/workspace/load-workspace-manifests.test.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomBytes } from 'node:crypto'; import { beforeEach, describe, expect, it } from 'vitest'; -import { loadWorkspaceManifests } from './load-workspace-manifests.js'; +import { loadWorkspaceManifests, loadRootManifest } from './load-workspace-manifests.js'; function makeTempDir(): string { return join(tmpdir(), `load-workspace-manifests-test-${randomBytes(6).toString('hex')}`); @@ -22,16 +22,7 @@ describe('loadWorkspaceManifests', () => { expect(result).toEqual([]); }); - it('finds a package.json in the root dir', async () => { - await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'root' })); - - const result = await loadWorkspaceManifests(dir); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ name: 'root' }); - }); - - it('finds package.json files recursively', async () => { + it('finds package.json files recursively but not root', async () => { await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'root' })); await mkdir(join(dir, 'packages', 'a'), { recursive: true }); await writeFile(join(dir, 'packages', 'a', 'package.json'), JSON.stringify({ name: 'pkg-a' })); @@ -41,7 +32,7 @@ describe('loadWorkspaceManifests', () => { const result = await loadWorkspaceManifests(dir); const names = result.map((m) => m.name).sort(); - expect(names).toEqual(['pkg-a', 'pkg-b', 'root']); + expect(names).toEqual(['pkg-a', 'pkg-b']); }); it('skips node_modules directories', async () => { @@ -54,8 +45,7 @@ describe('loadWorkspaceManifests', () => { const result = await loadWorkspaceManifests(dir); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ name: 'root' }); + expect(result).toHaveLength(0); }); it('skips malformed package.json files', async () => { @@ -66,11 +56,47 @@ describe('loadWorkspaceManifests', () => { expect(result).toEqual([]); }); - it('accepts package.json without a name field', async () => { - await writeFile(join(dir, 'package.json'), JSON.stringify({ version: '1.0.0' })); + it('accepts package.json without a name field (skips it)', async () => { + await mkdir(join(dir, 'packages', 'no-name'), { recursive: true }); + await writeFile( + join(dir, 'packages', 'no-name', 'package.json'), + JSON.stringify({ version: '1.0.0' }), + ); + await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'root' })); const result = await loadWorkspaceManifests(dir); - expect(result).toHaveLength(1); + expect(result).toHaveLength(0); + }); +}); + +describe('loadRootManifest', () => { + let dir: string; + + beforeEach(async () => { + dir = makeTempDir(); + await mkdir(dir, { recursive: true }); + }); + + it('loads root package.json', async () => { + await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'root', version: '1.0.0' })); + + const result = await loadRootManifest(dir); + + expect(result).toMatchObject({ name: 'root', version: '1.0.0' }); + }); + + it('returns undefined when no root package.json exists', async () => { + const result = await loadRootManifest(dir); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for malformed package.json', async () => { + await writeFile(join(dir, 'package.json'), 'not valid json'); + + const result = await loadRootManifest(dir); + + expect(result).toBeUndefined(); }); }); diff --git a/packages/core/src/workspace/load-workspace-manifests.ts b/packages/core/src/workspace/load-workspace-manifests.ts index 80d3452..52638bd 100644 --- a/packages/core/src/workspace/load-workspace-manifests.ts +++ b/packages/core/src/workspace/load-workspace-manifests.ts @@ -4,15 +4,38 @@ import type { PackageManifest } from './build-workspace-graph.js'; /** * Recursively walks `dir` and returns all valid package.json manifests found, - * skipping `node_modules` directories and malformed files. + * skipping `node_modules` directories and the root package.json. + * Only returns workspace packages (not the root). */ export async function loadWorkspaceManifests(dir: string): Promise { const manifests: PackageManifest[] = []; - await collectManifests(dir, manifests); + await collectManifests(dir, dir, manifests); return manifests; } -async function collectManifests(dir: string, manifests: PackageManifest[]): Promise { +/** + * Loads the root package.json from the workspace root. + * Returns undefined if the file doesn't exist or is malformed. + */ +export async function loadRootManifest(dir: string): Promise { + const rootPath = join(dir, 'package.json'); + try { + const content = await readFile(rootPath, 'utf-8'); + const parsed: unknown = JSON.parse(content); + if (isPackageManifest(parsed)) { + return parsed; + } + } catch { + // file doesn't exist or is malformed + } + return undefined; +} + +async function collectManifests( + dir: string, + workspaceRoot: string, + manifests: PackageManifest[], +): Promise { let entries; try { entries = await readdir(dir, { withFileTypes: true }); @@ -26,8 +49,11 @@ async function collectManifests(dir: string, manifests: PackageManifest[]): Prom const fullPath = join(dir, entry.name); if (entry.isDirectory()) { - await collectManifests(fullPath, manifests); + await collectManifests(fullPath, workspaceRoot, manifests); } else if (entry.isFile() && entry.name === 'package.json') { + // Skip root package.json + if (fullPath === join(workspaceRoot, 'package.json')) continue; + try { const content = await readFile(fullPath, 'utf-8'); const parsed: unknown = JSON.parse(content); @@ -44,6 +70,6 @@ async function collectManifests(dir: string, manifests: PackageManifest[]): Prom function isPackageManifest(value: unknown): value is PackageManifest { if (typeof value !== 'object' || value === null) return false; const obj = value as Record; - if ('name' in obj && typeof obj['name'] !== 'string') return false; + if (!('name' in obj) || typeof obj['name'] !== 'string') return false; return true; } From 35fa7835310e43fa0969c04bb37f608e24fc4a7f Mon Sep 17 00:00:00 2001 From: Henri Lahtinen Date: Wed, 1 Apr 2026 09:14:35 +0300 Subject: [PATCH 2/3] docs: add WIP disclaimer to package READMEs --- packages/cli/README.md | 2 ++ packages/core/README.md | 2 ++ packages/lockfile-bun/README.md | 2 ++ packages/lockfile-npm/README.md | 2 ++ packages/lockfile-pnpm/README.md | 2 ++ packages/lockfile-yarn/README.md | 2 ++ 6 files changed, 12 insertions(+) diff --git a/packages/cli/README.md b/packages/cli/README.md index 9ef38cf..f0f4e74 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3,6 +3,8 @@ [![CI](https://github.com/split/lockfile-affected/actions/workflows/ci.yml/badge.svg)](https://github.com/split/lockfile-affected/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/lockfile-affected)](https://www.npmjs.com/package/lockfile-affected) +> **Warning:** This tool is still in early development. The API may change, and some features are incomplete or not yet fully tested. Use in production at your own risk. + Find which workspace packages are affected by lockfile changes. ## Purpose diff --git a/packages/core/README.md b/packages/core/README.md index 1389b51..93574a7 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,5 +1,7 @@ # @lockfile-affected/core +> **Warning:** This tool is still in early development. The API may change, and some features are incomplete or not yet fully tested. Use in production at your own risk. + Diff engine, affected-package resolver, and programmatic API for `lockfile-affected`. ## Installation diff --git a/packages/lockfile-bun/README.md b/packages/lockfile-bun/README.md index c972be7..e3db0be 100644 --- a/packages/lockfile-bun/README.md +++ b/packages/lockfile-bun/README.md @@ -1,5 +1,7 @@ # @lockfile-affected/lockfile-bun +> **Warning:** This tool is still in early development. The API may change, and some features are incomplete or not yet fully tested. Use in production at your own risk. + bun.lock adapter for [`lockfile-affected`](https://www.npmjs.com/package/lockfile-affected). Parses `bun.lock` content into a normalized `name -> version` snapshot diff --git a/packages/lockfile-npm/README.md b/packages/lockfile-npm/README.md index f20f312..29731af 100644 --- a/packages/lockfile-npm/README.md +++ b/packages/lockfile-npm/README.md @@ -1,5 +1,7 @@ # @lockfile-affected/lockfile-npm +> **Warning:** This tool is still in early development. The API may change, and some features are incomplete or not yet fully tested. Use in production at your own risk. + `package-lock.json` adapter for [`lockfile-affected`](https://www.npmjs.com/package/lockfile-affected). ## Minimal usage diff --git a/packages/lockfile-pnpm/README.md b/packages/lockfile-pnpm/README.md index 36c6702..a096bbd 100644 --- a/packages/lockfile-pnpm/README.md +++ b/packages/lockfile-pnpm/README.md @@ -1,5 +1,7 @@ # @lockfile-affected/lockfile-pnpm +> **Warning:** This tool is still in early development. The API may change, and some features are incomplete or not yet fully tested. Use in production at your own risk. + `pnpm-lock.yaml` adapter for [`lockfile-affected`](https://www.npmjs.com/package/lockfile-affected). ## Minimal usage diff --git a/packages/lockfile-yarn/README.md b/packages/lockfile-yarn/README.md index 2433d96..b527bc3 100644 --- a/packages/lockfile-yarn/README.md +++ b/packages/lockfile-yarn/README.md @@ -1,5 +1,7 @@ # @lockfile-affected/lockfile-yarn +> **Warning:** This tool is still in early development. The API may change, and some features are incomplete or not yet fully tested. Use in production at your own risk. + `yarn.lock` adapter for [`lockfile-affected`](https://www.npmjs.com/package/lockfile-affected). Supports both classic (v1) and berry (v2+) lockfile formats. From 19592fe6ab88c074ffeecf33886851821393d059 Mon Sep 17 00:00:00 2001 From: Henri Lahtinen Date: Wed, 1 Apr 2026 09:19:36 +0300 Subject: [PATCH 3/3] fix: remove unused imports and parameters - Remove unused LockfileParser and DependencyFilter from run-affected-command - Remove unused allDependencyTypes from to-dependency-filter - Remove unused lockfileSnapshot parameter from buildWorkspaceGraph - Add lint to feedback loop in AGENTS.md --- AGENTS.md | 9 ++++++++- packages/cli/src/commands/run-affected-command.ts | 7 +------ packages/cli/src/options/to-dependency-filter.ts | 2 +- packages/core/src/affected/find-affected-packages.ts | 2 +- packages/core/src/workspace/build-workspace-graph.ts | 12 ++---------- 5 files changed, 13 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2598032..1f7895a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,5 @@ ## Code style + - Pure functions preferred; side effects only at I/O boundaries - Name files after what they do: `parse-pnpm-lockfile.ts`, `resolve-affected-packages.ts` - No force casts (`as Foo`) — use type guards and narrowing @@ -6,20 +7,26 @@ - Conventional commits for commit messages ## Architecture + Vertical slice — one package per concern: + - `@lockfile-affected/core` — pure domain logic, cli and adapter agnostic - `@lockfile-affected/lockfile-*` — one package per lockfile format - `@lockfile-affected/cli` — I/O boundary, orchestration ## Adding a lockfile format + 1. Create `packages/lockfile-/` 2. Export a `LockfileParser` from `@lockfile-affected/core` 3. Write tests against fixture content before implementing 4. Register the parser in `@lockfile-affected/cli` ## Feedback loop + After every change: + ``` -pnpm test && pnpm typecheck && pnpm format:check +pnpm test && pnpm typecheck && pnpm format:check && pnpm lint ``` + Write tests before implementation. One logical change per commit. diff --git a/packages/cli/src/commands/run-affected-command.ts b/packages/cli/src/commands/run-affected-command.ts index 2c38836..8fecc2d 100644 --- a/packages/cli/src/commands/run-affected-command.ts +++ b/packages/cli/src/commands/run-affected-command.ts @@ -1,9 +1,4 @@ -import { - detectLockfile, - findAffectedPackages, - type LockfileParser, - type DependencyFilter, -} from '@lockfile-affected/core'; +import { detectLockfile, findAffectedPackages } from '@lockfile-affected/core'; import { isSupportedFormat, lockfileParsers, diff --git a/packages/cli/src/options/to-dependency-filter.ts b/packages/cli/src/options/to-dependency-filter.ts index be1594f..734eb51 100644 --- a/packages/cli/src/options/to-dependency-filter.ts +++ b/packages/cli/src/options/to-dependency-filter.ts @@ -1,4 +1,4 @@ -import { allDependencyTypes, type DependencyFilter } from '@lockfile-affected/core'; +import { type DependencyFilter } from '@lockfile-affected/core'; import type { CliOptions } from './cli-options.types.js'; /** diff --git a/packages/core/src/affected/find-affected-packages.ts b/packages/core/src/affected/find-affected-packages.ts index a3d351a..b6b5ca3 100644 --- a/packages/core/src/affected/find-affected-packages.ts +++ b/packages/core/src/affected/find-affected-packages.ts @@ -37,7 +37,7 @@ export async function findAffectedPackages( ]); const diff = diffLockfileSnapshots(snapshotBefore, snapshotAfter); - const workspaceGraph = buildWorkspaceGraph(manifests, snapshotAfter); + const workspaceGraph = buildWorkspaceGraph(manifests); if (options.rootDepsAffectAll) { const resolveOptions = { diff --git a/packages/core/src/workspace/build-workspace-graph.ts b/packages/core/src/workspace/build-workspace-graph.ts index 2afb2be..b660334 100644 --- a/packages/core/src/workspace/build-workspace-graph.ts +++ b/packages/core/src/workspace/build-workspace-graph.ts @@ -1,9 +1,4 @@ -import type { - DependencyGroups, - LockfileSnapshot, - WorkspaceGraph, - WorkspacePackage, -} from '../types/lockfile.js'; +import type { DependencyGroups, WorkspaceGraph, WorkspacePackage } from '../types/lockfile.js'; /** * Shape of a package.json relevant to building the workspace graph. @@ -21,10 +16,7 @@ export type PackageManifest = { * Packages without a name field are ignored. * Each dependency type is kept in its own group to allow fine-grained filtering. */ -export function buildWorkspaceGraph( - manifests: readonly PackageManifest[], - lockfileSnapshot?: LockfileSnapshot, -): WorkspaceGraph { +export function buildWorkspaceGraph(manifests: readonly PackageManifest[]): WorkspaceGraph { const graph = new Map(); for (const manifest of manifests) {