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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ jobs:
- nginx-ast
- promql-ast
- yamlize
- ws-changed

steps:
- name: Checkout code
Expand Down
131 changes: 131 additions & 0 deletions packages/ws-changed/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# ws-changed

<p align="center">
<img src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" height="250">
<br />
<strong>which workspace packages a changeset affects</strong>
<br />
<br />
Pluggable workspace dependency graphs (pnpm, pgpm, glob) + affected-package selection for change-aware CI — <code>affected = changed ∪ dependents</code>, with a <code>--why</code> explainer, config via confstash, and changed files from git-changed
<br />
<br />
<a href="https://github.com/constructive-io/dev-utils/actions/workflows/ci.yml">
<img height="20" src="https://github.com/constructive-io/dev-utils/actions/workflows/ci.yml/badge.svg" />
</a>
<a href="https://github.com/constructive-io/dev-utils/blob/main/LICENSE">
<img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/>
</a>
<a href="https://www.npmjs.com/package/ws-changed">
<img height="20" src="https://img.shields.io/npm/v/ws-changed?color=blue"/>
</a>
</p>

## Why

On a feature branch you don't need to run the whole test suite — only the packages your change can actually reach. Answering "which packages are affected?" is two problems the ecosystem usually couples to a specific tool (`pnpm --filter '...[origin/main]'`, `lerna`, `nx affected`):

1. **What is a "package" here, and what depends on what?** For a JavaScript monorepo it's the pnpm package graph. For a Postgres extension monorepo (pgpm) it's the module `requires` graph. Sometimes it's a plain directory of services. These are *different* dependency graphs over the *same* repo, and which one you want depends on the question you're asking.
2. **Which packages own the files that changed, and what transitively depends on them?**

`ws-changed` separates the two. Providers answer (1) — `pnpm`, `pgpm`, and `glob` are built in, and you can register your own — and the affected engine answers (2) on whatever graph the provider produced. Changed files come from [`git-changed`](../git-changed); configuration comes from [`confstash`](../confstash), so which provider(s) to run, the workspace root, and the "global trigger" paths (a lockfile, CI config) are all declarative and overridable per call and per CLI flag.

## Installation

```bash
npm install ws-changed
```

## CLI

```bash
# packages affected by this branch, vs origin/main
ws-changed --base origin/main

# use the pnpm graph AND the pgpm module graph together
ws-changed --provider pnpm,pgpm --base origin/main --json

# print affected package directories, and treat a lockfile/CI change as "everything"
ws-changed --provider pgpm --dirs --global 'pnpm-lock.yaml' '.github/**'

# explain why each package was selected
ws-changed --why --base origin/develop

# just enumerate / inspect the graph (ignores changes)
ws-changed --list
ws-changed --graph
```

Exit code is always `0`. With `--json`, read `.result.global`: when `true`, a global-trigger path changed and you should treat every package as affected (skip the selection).

## Library

```ts
import { wsChanged } from 'ws-changed';

const { result } = wsChanged({ base: 'origin/main' });
result.packages; // ['app', 'core', 'lib'] — changed ∪ transitive dependents
result.changed; // ['core'] — packages that directly own a changed file
result.rootChanged;// ['README.md'] — changed paths owned by no package
result.global; // false — did a global-trigger path change?
result.why; // [{ package, kind: 'changed'|'dependent', via }]
```

Lower-level pieces are exported too — `loadWorkspace`, the `WorkspaceGraph` (direct/transitive dependencies & dependents, topological sort, cycle detection), and `affected` for when you already hold the changed paths:

```ts
import { loadWorkspace, affected } from 'ws-changed';
import { changedPaths } from 'git-changed';

const { workspace, config } = loadWorkspace({ overrides: { provider: ['pnpm', 'pgpm'] } });
const result = affected(workspace, {
changed: changedPaths({ base: 'origin/main' }),
global: config.global
});
```

## Configuration

Discovered by confstash (`ws-changed.config.{ts,js,json}`, `.ws-changedrc{,.json,.yaml}`, or a `ws-changed` key in `package.json`), walking up from the cwd:

```jsonc
// .ws-changedrc.json
{
"provider": ["pnpm", "pgpm"],
"global": ["pnpm-lock.yaml", ".github/**", "bin/shard-plan.cjs"],
"exclude": ["**/fixtures/**"],
"providers": {
"pnpm": { "edgeKinds": ["prod", "dev", "peer"] }
}
}
```

| Key | Meaning |
| --- | --- |
| `provider` | Provider name or list. Multiple providers compose: their package sets are unioned by name and their edges merged, so `['pnpm','pgpm']` gives JS *and* SQL edges on the same nodes. Default `pnpm`. |
| `root` | Workspace root. Default: the git repo root, else cwd. |
| `global` | Glob patterns whose change means "everything is affected" (`AffectedResult.global`). Also settable via `WS_CHANGED_GLOBAL` (comma-separated). |
| `include` / `exclude` | Restrict the package set by directory glob. |
| `providers.pnpm.edgeKinds` | Which dependency kinds form edges: `prod`, `dev`, `peer`, `optional`. Default: all. |
| `providers.pgpm.globs` / `providers.glob.globs` | Directory globs to search (default: the workspace's own globs). |

## Providers

- **`pnpm`** — JavaScript workspace packages. Reads `pnpm-workspace.yaml` (or `package.json` `workspaces`), then each package's dependency maps. A dependency is an internal edge whenever its *name* is a workspace package — every `workspace:` protocol variant (`workspace:*`, `workspace:^`, `workspace:~`, `workspace:^1.2.3`, `workspace:1.2.3`) **and** a bare semver range when a workspace package publishes under that name (how dist-publishing monorepos reference each other).
- **`pgpm`** — Postgres package-manager modules. A module dir carries a `<name>.control` whose `requires` field lists its module dependencies and a `pgpm.plan` whose `%project` names it; edges to modules outside the workspace (`plpgsql`, extensions) are recorded as `external`.
- **`glob`** — plain directories, no edges. For anything that isn't pnpm or pgpm; `affected === changed`.

### Custom providers

```ts
import { registerProvider, type WorkspaceProvider } from 'ws-changed';

const myProvider: WorkspaceProvider = {
name: 'my-graph',
discover({ root, config }) {
return [/* WorkspacePackage[] with name/dir/requires/external */];
}
};
registerProvider(myProvider);
```

This is the extension point for teaching `ws-changed` a new notion of "package" and its edges without it needing to know anything about your domain.
74 changes: 74 additions & 0 deletions packages/ws-changed/__tests__/affected.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { affected } from '../src/affected';
import type { Workspace, WorkspacePackage } from '../src/types';

function pkg(name: string, relDir: string, requires: string[] = []): WorkspacePackage {
return { name, dir: `/r/${relDir}`, relDir, requires, external: [], provider: 'test' };
}

// packages/app -> packages/lib -> packages/core
const workspace: Workspace = {
root: '/r',
providers: ['test'],
packages: [
pkg('core', 'packages/core'),
pkg('lib', 'packages/lib', ['core']),
pkg('app', 'packages/app', ['lib'])
]
};

describe('affected', () => {
it('maps a changed file to its owning package', () => {
const r = affected(workspace, { changed: ['packages/app/src/index.ts'] });
expect(r.changed).toEqual(['app']);
});

it('includes transitive dependents of a changed package', () => {
const r = affected(workspace, { changed: ['packages/core/src/x.ts'] });
expect(r.packages).toEqual(['app', 'core', 'lib']);
expect(r.changed).toEqual(['core']);
});

it('does not pull in dependencies, only dependents', () => {
const r = affected(workspace, { changed: ['packages/app/src/x.ts'] });
expect(r.packages).toEqual(['app']);
});

it('reports root/unowned changes separately', () => {
const r = affected(workspace, { changed: ['README.md', 'packages/lib/x.ts'] });
expect(r.rootChanged).toEqual(['README.md']);
expect(r.changed).toEqual(['lib']);
expect(r.packages).toEqual(['app', 'lib']);
});

it('maps by longest matching directory (nested packages)', () => {
const nested: Workspace = {
root: '/r',
providers: ['test'],
packages: [pkg('outer', 'packages'), pkg('inner', 'packages/inner')]
};
const r = affected(nested, { changed: ['packages/inner/file.ts'] });
expect(r.changed).toEqual(['inner']);
});

it('flags a global-trigger change', () => {
const r = affected(workspace, {
changed: ['pnpm-lock.yaml'],
global: ['pnpm-lock.yaml', '.github/**']
});
expect(r.global).toBe(true);
expect(r.globalMatches).toEqual(['pnpm-lock.yaml']);
});

it('accepts absolute paths', () => {
const r = affected(workspace, { changed: ['/r/packages/lib/index.ts'] });
expect(r.changed).toEqual(['lib']);
});

it('explains why each package is affected', () => {
const r = affected(workspace, { changed: ['packages/core/x.ts'] });
const byName = new Map(r.why.map((w) => [w.package, w]));
expect(byName.get('core')).toMatchObject({ kind: 'changed', via: 'packages/core/x.ts' });
expect(byName.get('lib')).toMatchObject({ kind: 'dependent', via: 'core' });
expect(byName.get('app')).toMatchObject({ kind: 'dependent', via: 'lib' });
});
});
97 changes: 97 additions & 0 deletions packages/ws-changed/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { rmSync } from 'fs';

import { parseArgs, run } from '../src/cli';
import { buildWorkspace } from './support/build-workspace';

describe('parseArgs', () => {
it('parses provider as a repeatable, comma-separated list', () => {
expect(parseArgs(['--provider', 'pnpm,pgpm']).overrides.provider).toEqual(['pnpm', 'pgpm']);
expect(parseArgs(['--provider', 'pnpm', '--provider', 'glob']).overrides.provider).toEqual([
'pnpm',
'glob'
]);
});

it('parses --base and --no-base', () => {
expect(parseArgs(['--base', 'origin/develop']).base).toBe('origin/develop');
expect(parseArgs(['--no-base']).base).toBe(false);
});

it('accepts --flag=value form', () => {
expect(parseArgs(['--base=origin/main']).base).toBe('origin/main');
});

it('parses global/include/exclude lists', () => {
const p = parseArgs(['--global', 'pnpm-lock.yaml', '--global', '.github/**', '--exclude', 'apps/**']);
expect(p.overrides.global).toEqual(['pnpm-lock.yaml', '.github/**']);
expect(p.overrides.exclude).toEqual(['apps/**']);
});

it('throws on an unknown option', () => {
expect(() => parseArgs(['--nope'])).toThrow(/Unknown option/);
});

it('throws when a value-taking flag has no value', () => {
expect(() => parseArgs(['--base'])).toThrow(/requires a value/);
});
});

describe('run', () => {
const roots: string[] = [];
const logs: string[] = [];
let logSpy: jest.SpyInstance;
let outSpy: jest.SpyInstance;
beforeEach(() => {
logs.length = 0;
logSpy = jest.spyOn(console, 'log').mockImplementation((...a) => void logs.push(a.join(' ')));
outSpy = jest
.spyOn(process.stdout, 'write')
.mockImplementation((chunk: string | Uint8Array) => {
logs.push(String(chunk).replace(/\n$/, ''));
return true;
});
});
afterEach(() => {
logSpy.mockRestore();
outSpy.mockRestore();
});
afterAll(() => roots.forEach((r) => rmSync(r, { recursive: true, force: true })));

it('prints the version', () => {
expect(run(['--version'])).toBe(0);
expect(logs.join('\n')).toMatch(/\d+\.\d+\.\d+|unknown/);
});

it('exits 2 on a bad flag', () => {
const err = jest.spyOn(console, 'error').mockImplementation(() => undefined);
expect(run(['--bogus'])).toBe(2);
err.mockRestore();
});

it('--list prints every package name', () => {
const root = buildWorkspace({
pnpmGlobs: ['packages/*'],
packages: [
{ dir: 'packages/a', pkg: { name: 'a' } },
{ dir: 'packages/b', pkg: { name: 'b', dependencies: { a: 'workspace:*' } } }
]
});
roots.push(root);
expect(run(['--list', '--cwd', root, '--root', root])).toBe(0);
expect(logs.join('\n').split('\n').sort()).toEqual(['a', 'b']);
});

it('--graph prints dependencies-first order', () => {
const root = buildWorkspace({
pnpmGlobs: ['packages/*'],
packages: [
{ dir: 'packages/a', pkg: { name: 'a' } },
{ dir: 'packages/b', pkg: { name: 'b', dependencies: { a: 'workspace:*' } } }
]
});
roots.push(root);
expect(run(['--graph', '--cwd', root, '--root', root])).toBe(0);
const out = logs.join('\n');
expect(out).toContain('b <- a');
});
});
63 changes: 63 additions & 0 deletions packages/ws-changed/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { rmSync, writeFileSync } from 'fs';
import { join } from 'path';

import { DEFAULT_CONFIG, loadConfig } from '../src/config';
import { buildWorkspace } from './support/build-workspace';

describe('loadConfig', () => {
const roots: string[] = [];
afterAll(() => roots.forEach((r) => rmSync(r, { recursive: true, force: true })));

it('returns defaults when no config file is present', () => {
const root = buildWorkspace({ packages: [] });
roots.push(root);
const { config } = loadConfig({ cwd: root });
expect(config.provider).toBe(DEFAULT_CONFIG.provider);
});

it('discovers a .ws-changedrc.json and merges it over defaults', () => {
const root = buildWorkspace({ packages: [] });
roots.push(root);
writeFileSync(
join(root, '.ws-changedrc.json'),
JSON.stringify({ provider: ['pnpm', 'pgpm'], global: ['pnpm-lock.yaml'] })
);
const { config, filepath } = loadConfig({ cwd: root });
expect(config.provider).toEqual(['pnpm', 'pgpm']);
expect(config.global).toEqual(['pnpm-lock.yaml']);
expect(filepath).toContain('.ws-changedrc.json');
});

it('reads a ws-changed key from package.json', () => {
const root = buildWorkspace({ packages: [], rootFiles: {} });
roots.push(root);
writeFileSync(
join(root, 'package.json'),
JSON.stringify({ name: 'root', 'ws-changed': { provider: 'glob' } })
);
const { config } = loadConfig({ cwd: root });
expect(config.provider).toBe('glob');
});

it('lets runtime overrides win over the file', () => {
const root = buildWorkspace({ packages: [] });
roots.push(root);
writeFileSync(join(root, '.ws-changedrc.json'), JSON.stringify({ provider: 'pnpm' }));
const { config } = loadConfig({ cwd: root, overrides: { provider: 'pgpm' } });
expect(config.provider).toBe('pgpm');
});

it('reads global triggers from the environment layer', () => {
const root = buildWorkspace({ packages: [] });
roots.push(root);
const prev = process.env.WS_CHANGED_GLOBAL;
process.env.WS_CHANGED_GLOBAL = 'pnpm-lock.yaml, .github/**';
try {
const { config } = loadConfig({ cwd: root });
expect(config.global).toEqual(['pnpm-lock.yaml', '.github/**']);
} finally {
if (prev === undefined) delete process.env.WS_CHANGED_GLOBAL;
else process.env.WS_CHANGED_GLOBAL = prev;
}
});
});
Loading
Loading