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
38 changes: 37 additions & 1 deletion packages/ws-changed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ ws-changed --provider pgpm --dirs --global 'pnpm-lock.yaml' '.github/**'
# explain why each package was selected
ws-changed --why --base origin/develop

# only count changed SQL — and print which extensions changed in each package
ws-changed --provider pgpm --ext .sql --exts

# a lint lane: TypeScript only, ignoring generated trees
ws-changed --ext ts,tsx --not-files '**/generated/**'

# just enumerate / inspect the graph (ignores changes)
ws-changed --list
ws-changed --graph
Expand All @@ -68,6 +74,9 @@ result.changed; // ['core'] — packages that directly own a c
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 }]
result.extensions; // ['.sql', '.ts'] — extensions present in the changeset
result.extensionsByPackage; // { core: ['.sql'] } — what changed *in* each changed package
result.ignored; // ['README.md'] — changed paths the file filter dropped
```

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:
Expand All @@ -83,6 +92,30 @@ const result = affected(workspace, {
});
```

## Extensions: one changeset, several questions

A CI lane's real question is rarely "did anything change?" but "did anything *of this kind* change, and where?" — a SQL lint lane cares about `.sql`, an image build about its build inputs, a lint lane about `.ts`. `files` narrows the changed files **before** they are attributed to packages, so the same diff answers each question separately:

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

// which packages have changed SQL? (and their dependents)
const sql = wsChanged({ overrides: { provider: 'pgpm', files: { ext: '.sql' } } });
if (!sql.result.packages.length) skipSqlLint();

// what kind of change was it, per package?
wsChanged().result.extensionsByPackage; // { 'my-pkg': ['.md', '.ts'] }
```

`ext` takes the same shapes `git-changed` accepts — `'sql'`, `'.sql'`, `'ts,tsx'`, `['.ts','.tsx']` — normalized to a lowercased `.ext` list, so a filter moves between the two without translation. A dotfile or extensionless name (`.gitignore`, `Makefile`) has *no* extension and therefore matches no `ext` filter.

Two properties worth knowing:

- **A dropped file cannot trigger `global`.** The filter defines which files the question is about, so a SQL lane asking about `.sql` is not told "everything is affected" by a changed `pnpm-lock.yaml`. Ask the unfiltered question when you want the lockfile's blast radius.
- **`extensionsByPackage` covers changed packages only**, never dependents — they own no changed file, so "what changed in `pkg`" stays distinct from "what `pkg` is affected by".

On the CLI it's `--ext`, `--files`, `--not-files` (and `--exts` to print the per-package breakdown); in the environment, `WS_CHANGED_EXT` — enough for a CI lane to name its extensions without carrying its own config file.

## 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:
Expand All @@ -93,6 +126,7 @@ Discovered by confstash (`ws-changed.config.{ts,js,json}`, `.ws-changedrc{,.json
"provider": ["pnpm", "pgpm"],
"global": ["pnpm-lock.yaml", ".github/**", "bin/shard-plan.cjs"],
"exclude": ["**/fixtures/**"],
"files": { "ext": [".sql"], "exclude": ["**/generated/**"] },
"providers": {
"pnpm": { "edgeKinds": ["prod", "dev", "peer"] }
}
Expand All @@ -104,7 +138,9 @@ Discovered by confstash (`ws-changed.config.{ts,js,json}`, `.ws-changedrc{,.json
| `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. |
| `include` / `exclude` | Restrict the **package** set by directory glob. |
| `files.ext` | Only count changed files with these extensions (`'.sql'`, `'ts,tsx'`, `['.ts','.tsx']`). Also settable via `WS_CHANGED_EXT`. |
| `files.include` / `files.exclude` | Restrict the changed **file** set by glob — a generated tree, `dist/`, fixtures. |
| `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). |

Expand Down
96 changes: 96 additions & 0 deletions packages/ws-changed/__tests__/affected.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,20 @@ describe('affected', () => {
expect(r.changed).toEqual(['lib']);
});

it('reports the extensions in the changeset, and per changed package', () => {
const r = affected(workspace, {
changed: ['packages/core/deploy/x.sql', 'packages/core/src/x.ts', 'packages/lib/README.md']
});
expect(r.extensions).toEqual(['.md', '.sql', '.ts']);
expect(r.extensionsByPackage).toEqual({ core: ['.sql', '.ts'], lib: ['.md'] });
});

it('leaves dependents out of extensionsByPackage — they own no changed file', () => {
const r = affected(workspace, { changed: ['packages/core/x.sql'] });
expect(r.packages).toEqual(['app', 'core', 'lib']);
expect(Object.keys(r.extensionsByPackage)).toEqual(['core']);
});

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]));
Expand All @@ -72,3 +86,85 @@ describe('affected', () => {
expect(byName.get('app')).toMatchObject({ kind: 'dependent', via: 'lib' });
});
});

describe('affected — file filter', () => {
it('keeps only the named extensions, and reports the rest as ignored', () => {
const r = affected(workspace, {
changed: ['packages/core/deploy/x.sql', 'packages/lib/src/x.ts'],
files: { ext: '.sql' }
});
expect(r.changed).toEqual(['core']);
expect(r.packages).toEqual(['app', 'core', 'lib']);
expect(r.ignored).toEqual(['packages/lib/src/x.ts']);
expect(r.extensions).toEqual(['.sql']);
});

it('accepts an extension with or without a dot, in any case, comma-joined', () => {
const changed = ['packages/lib/a.SQL', 'packages/app/b.tsx', 'packages/core/c.md'];
expect(affected(workspace, { changed, files: { ext: 'sql' } }).changed).toEqual(['lib']);
expect(affected(workspace, { changed, files: { ext: ['.sql', 'tsx'] } }).changed).toEqual([
'app',
'lib'
]);
expect(affected(workspace, { changed, files: { ext: 'sql,tsx' } }).changed).toEqual(['app', 'lib']);
});

it('treats an extensionless file and a dotfile as having no extension', () => {
const changed = ['packages/lib/Makefile', 'packages/app/.gitignore'];
const all = affected(workspace, { changed });
expect(all.changed).toEqual(['app', 'lib']);
expect(all.extensions).toEqual([]);
// Nothing can match an ext filter, so neither package is selected.
expect(affected(workspace, { changed, files: { ext: '.ts' } }).changed).toEqual([]);
});

it('filters by include and exclude globs', () => {
const changed = ['packages/lib/src/x.ts', 'packages/lib/generated/y.ts'];
expect(
affected(workspace, { changed, files: { exclude: ['**/generated/**'] } }).changed
).toEqual(['lib']);
expect(
affected(workspace, { changed, files: { exclude: ['**/generated/**'] } }).ignored
).toEqual(['packages/lib/generated/y.ts']);
expect(affected(workspace, { changed, files: { include: ['**/generated/**'] } }).ignored).toEqual([
'packages/lib/src/x.ts'
]);
});

it('does not let a filtered-out file trigger the global short-circuit', () => {
// The lockfile means "everything" only for a question its extension is part
// of: a SQL lane asking about .sql must not be globalized by pnpm-lock.yaml.
const params = { changed: ['pnpm-lock.yaml'], global: ['pnpm-lock.yaml'] };
expect(affected(workspace, params).global).toBe(true);
expect(affected(workspace, { ...params, files: { ext: '.sql' } }).global).toBe(false);
expect(affected(workspace, { ...params, files: { ext: '.sql' } }).ignored).toEqual([
'pnpm-lock.yaml'
]);
});

it('drops a filtered-out root change from rootChanged', () => {
const r = affected(workspace, {
changed: ['README.md', 'tsconfig.json'],
files: { ext: '.json' }
});
expect(r.rootChanged).toEqual(['tsconfig.json']);
expect(r.ignored).toEqual(['README.md']);
});

it('an empty filter keeps everything', () => {
const changed = ['packages/lib/x.ts', 'README.md'];
const r = affected(workspace, { changed, files: { ext: [], include: [], exclude: [] } });
expect(r.changed).toEqual(['lib']);
expect(r.rootChanged).toEqual(['README.md']);
expect(r.ignored).toEqual([]);
});

it('filters absolute paths too', () => {
const r = affected(workspace, {
changed: ['/r/packages/lib/x.sql', '/r/packages/app/x.ts'],
files: { ext: '.sql' }
});
expect(r.changed).toEqual(['lib']);
expect(r.ignored).toEqual(['packages/app/x.ts']);
});
});
13 changes: 13 additions & 0 deletions packages/ws-changed/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ describe('parseArgs', () => {
expect(p.overrides.exclude).toEqual(['apps/**']);
});

it('parses the file filter flags into config.files', () => {
const p = parseArgs(['--ext', 'sql,ts', '--files', 'deploy/**', '--not-files', '**/generated/**']);
expect(p.overrides.files).toEqual({
ext: ['sql', 'ts'],
include: ['deploy/**'],
exclude: ['**/generated/**']
});
});

it('leaves config.files unset when no file flag is given', () => {
expect(parseArgs(['--base', 'origin/main']).overrides.files).toBeUndefined();
});

it('throws on an unknown option', () => {
expect(() => parseArgs(['--nope'])).toThrow(/Unknown option/);
});
Expand Down
14 changes: 14 additions & 0 deletions packages/ws-changed/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ describe('loadConfig', () => {
expect(config.provider).toBe('pgpm');
});

it('reads the extension filter from the environment layer', () => {
const root = buildWorkspace({ packages: [] });
roots.push(root);
const prev = process.env.WS_CHANGED_EXT;
process.env.WS_CHANGED_EXT = '.sql,.ts';
try {
const { config } = loadConfig({ cwd: root });
expect(config.files).toEqual({ ext: '.sql,.ts' });
} finally {
if (prev === undefined) delete process.env.WS_CHANGED_EXT;
else process.env.WS_CHANGED_EXT = prev;
}
});

it('reads global triggers from the environment layer', () => {
const root = buildWorkspace({ packages: [] });
roots.push(root);
Expand Down
27 changes: 26 additions & 1 deletion packages/ws-changed/__tests__/glob.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { rmSync } from 'fs';

import { expandDirGlob, makeMatcher, toRel } from '../src/glob';
import { expandDirGlob, extOf, makeMatcher, normalizeExts, toRel } from '../src/glob';
import { buildWorkspace } from './support/build-workspace';

describe('expandDirGlob', () => {
Expand Down Expand Up @@ -79,6 +79,31 @@ describe('makeMatcher', () => {
});
});

describe('normalizeExts', () => {
it('adds the dot, lowercases, splits commas, and tolerates absence', () => {
expect(normalizeExts('sql')).toEqual(['.sql']);
expect(normalizeExts('.SQL')).toEqual(['.sql']);
expect(normalizeExts('ts, tsx')).toEqual(['.ts', '.tsx']);
expect(normalizeExts(['.ts', 'tsx'])).toEqual(['.ts', '.tsx']);
expect(normalizeExts()).toEqual([]);
expect(normalizeExts([''])).toEqual([]);
});
});

describe('extOf', () => {
it('returns the lowercased extension', () => {
expect(extOf('packages/a/deploy/x.SQL')).toBe('.sql');
expect(extOf('a/b.tar.gz')).toBe('.gz');
});

it('returns empty for a dotfile or an extensionless name', () => {
expect(extOf('.gitignore')).toBe('');
expect(extOf('packages/a/.npmrc')).toBe('');
expect(extOf('Makefile')).toBe('');
expect(extOf('bin/ws-changed')).toBe('');
});
});

describe('toRel', () => {
it('returns a posix path relative to root', () => {
expect(toRel('/repo', '/repo/packages/a/index.ts')).toBe('packages/a/index.ts');
Expand Down
19 changes: 19 additions & 0 deletions packages/ws-changed/__tests__/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,25 @@ describe('wsChanged', () => {
expect(result.packages).toEqual(['app', 'core']);
});

it('applies the config file filter to the changed set', () => {
const root = buildWorkspace({
pnpmGlobs: ['packages/*'],
packages: [
{ dir: 'packages/app', pkg: { name: 'app', dependencies: { core: 'workspace:*' } } },
{ dir: 'packages/core', pkg: { name: 'core' } }
]
});
roots.push(root);
const { result } = wsChanged({
cwd: root,
overrides: { root, files: { ext: '.sql' } },
changed: ['packages/core/deploy/x.sql', 'packages/app/src/x.ts']
});
expect(result.changed).toEqual(['core']);
expect(result.ignored).toEqual(['packages/app/src/x.ts']);
expect(result.extensionsByPackage).toEqual({ core: ['.sql'] });
});

it('flags a global-trigger change from config', () => {
const root = buildWorkspace({
pnpmGlobs: ['packages/*'],
Expand Down
51 changes: 48 additions & 3 deletions packages/ws-changed/src/affected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,40 @@
*/
import { isAbsolute } from 'path';

import { makeMatcher, toRel } from './glob';
import { extOf, makeMatcher, normalizeExts, toRel } from './glob';
import { WorkspaceGraph } from './graph';
import type { AffectedReason, AffectedResult, Workspace } from './types';
import type { AffectedReason, AffectedResult, FileFilter, Workspace } from './types';

export interface AffectedParams {
/** Changed paths — absolute, or relative to the workspace root. */
changed: string[];
/** Glob patterns (relative to root) that mean "everything is affected". */
global?: string[];
/**
* Narrow the changed files before they are attributed to packages, by
* extension and/or glob. A dropped file affects nothing and triggers no
* `global` — the filter defines which files the question is about, so asking
* "which packages have changed SQL" is not answered `true` by a lockfile.
*/
files?: FileFilter;
}

/**
* Compile a {@link FileFilter} into a predicate over relative paths. Order is
* ext → include → exclude, and each clause is skipped when unset, so an empty
* filter keeps everything.
*/
function fileFilter(filter: FileFilter | undefined): (rel: string) => boolean {
const exts = normalizeExts(filter?.ext);
const include = filter?.include?.length ? makeMatcher(filter.include) : undefined;
const exclude = filter?.exclude?.length ? makeMatcher(filter.exclude) : undefined;
if (!exts.length && !include && !exclude) return () => true;
return (rel: string) => {
if (exts.length && !exts.includes(extOf(rel))) return false;
if (include && !include(rel)) return false;
if (exclude && exclude(rel)) return false;
return true;
};
}

/** Map a changed path to the workspace package that owns it (longest relDir prefix). */
Expand Down Expand Up @@ -53,10 +78,20 @@ export function affected(workspace: Workspace, params: AffectedParams): Affected
const changedPkgs = new Set<string>();
const rootChanged: string[] = [];
const changedVia = new Map<string, string>();
const keep = fileFilter(params.files);
const ignored: string[] = [];
const extensions = new Set<string>();
const extsByPkg = new Map<string, Set<string>>();

const perPattern = globalPatterns.map((p) => ({ p, match: makeMatcher([p]) }));
for (const raw of params.changed) {
const rel = isAbsolute(raw) ? toRel(workspace.root, raw) : raw.split('\\').join('/');
if (!keep(rel)) {
ignored.push(rel);
continue;
}
const ext = extOf(rel);
if (ext) extensions.add(ext);
if (globalPatterns.length && globalMatch(rel)) {
// Record which pattern matched for the report; keep scanning so a mixed
// changeset still lists its owning packages.
Expand All @@ -66,6 +101,11 @@ export function affected(workspace: Workspace, params: AffectedParams): Affected
if (owner) {
if (!changedPkgs.has(owner)) changedVia.set(owner, rel);
changedPkgs.add(owner);
if (ext) {
const set = extsByPkg.get(owner) ?? new Set<string>();
set.add(ext);
extsByPkg.set(owner, set);
}
} else {
rootChanged.push(rel);
}
Expand All @@ -88,6 +128,11 @@ export function affected(workspace: Workspace, params: AffectedParams): Affected
rootChanged: [...new Set(rootChanged)].sort(),
global: globalMatches.size > 0,
globalMatches: [...globalMatches].sort(),
why
why,
extensions: [...extensions].sort(),
extensionsByPackage: Object.fromEntries(
[...extsByPkg.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([name, set]) => [name, [...set].sort()])
),
ignored: [...new Set(ignored)].sort()
};
}
Loading
Loading