From 9af8ed9553d0db9f8f06fcea6a1f59d05b9d6d8e Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 15 Aug 2026 02:28:40 +0000 Subject: [PATCH] feat(ws-changed): filter and classify a changeset by file extension --- packages/ws-changed/README.md | 38 +++++++- .../ws-changed/__tests__/affected.test.ts | 96 +++++++++++++++++++ packages/ws-changed/__tests__/cli.test.ts | 13 +++ packages/ws-changed/__tests__/config.test.ts | 14 +++ packages/ws-changed/__tests__/glob.test.ts | 27 +++++- packages/ws-changed/__tests__/run.test.ts | 19 ++++ packages/ws-changed/src/affected.ts | 51 +++++++++- packages/ws-changed/src/cli.ts | 45 ++++++++- packages/ws-changed/src/config.ts | 10 +- packages/ws-changed/src/glob.ts | 26 +++++ packages/ws-changed/src/index.ts | 3 +- packages/ws-changed/src/run.ts | 6 +- packages/ws-changed/src/types.ts | 43 +++++++++ 13 files changed, 380 insertions(+), 11 deletions(-) diff --git a/packages/ws-changed/README.md b/packages/ws-changed/README.md index d1c81b1..ed1e36a 100644 --- a/packages/ws-changed/README.md +++ b/packages/ws-changed/README.md @@ -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 @@ -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: @@ -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: @@ -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"] } } @@ -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). | diff --git a/packages/ws-changed/__tests__/affected.test.ts b/packages/ws-changed/__tests__/affected.test.ts index 568ef59..2ba44f3 100644 --- a/packages/ws-changed/__tests__/affected.test.ts +++ b/packages/ws-changed/__tests__/affected.test.ts @@ -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])); @@ -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']); + }); +}); diff --git a/packages/ws-changed/__tests__/cli.test.ts b/packages/ws-changed/__tests__/cli.test.ts index 3bd7eb5..059971d 100644 --- a/packages/ws-changed/__tests__/cli.test.ts +++ b/packages/ws-changed/__tests__/cli.test.ts @@ -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/); }); diff --git a/packages/ws-changed/__tests__/config.test.ts b/packages/ws-changed/__tests__/config.test.ts index aca29ce..b55479d 100644 --- a/packages/ws-changed/__tests__/config.test.ts +++ b/packages/ws-changed/__tests__/config.test.ts @@ -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); diff --git a/packages/ws-changed/__tests__/glob.test.ts b/packages/ws-changed/__tests__/glob.test.ts index 89d5ad3..ddae961 100644 --- a/packages/ws-changed/__tests__/glob.test.ts +++ b/packages/ws-changed/__tests__/glob.test.ts @@ -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', () => { @@ -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'); diff --git a/packages/ws-changed/__tests__/run.test.ts b/packages/ws-changed/__tests__/run.test.ts index a7d8fc1..b9af195 100644 --- a/packages/ws-changed/__tests__/run.test.ts +++ b/packages/ws-changed/__tests__/run.test.ts @@ -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/*'], diff --git a/packages/ws-changed/src/affected.ts b/packages/ws-changed/src/affected.ts index b13e777..2a56289 100644 --- a/packages/ws-changed/src/affected.ts +++ b/packages/ws-changed/src/affected.ts @@ -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). */ @@ -53,10 +78,20 @@ export function affected(workspace: Workspace, params: AffectedParams): Affected const changedPkgs = new Set(); const rootChanged: string[] = []; const changedVia = new Map(); + const keep = fileFilter(params.files); + const ignored: string[] = []; + const extensions = new Set(); + const extsByPkg = new Map>(); 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. @@ -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(); + set.add(ext); + extsByPkg.set(owner, set); + } } else { rootChanged.push(rel); } @@ -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() }; } diff --git a/packages/ws-changed/src/cli.ts b/packages/ws-changed/src/cli.ts index 86cd627..0dcc56e 100644 --- a/packages/ws-changed/src/cli.ts +++ b/packages/ws-changed/src/cli.ts @@ -20,9 +20,14 @@ Options: --global Paths whose change means "everything affected" (repeatable) --include Only consider packages whose dir matches these globs --exclude Drop packages whose dir matches these globs + --ext Only count changed files with these extensions, e.g. + --ext .sql (repeatable, comma-separated) + --files Only count changed files matching these globs (repeatable) + --not-files Ignore changed files matching these globs (repeatable) --changed Print only directly-changed packages (not dependents) --dirs Print package directories instead of names --why Explain why each affected package was selected + --exts Print the extensions changed in each changed package --list List all workspace packages (ignore changes) --graph Print the dependency graph (topological order) --json Print the full result as JSON @@ -38,7 +43,13 @@ Examples: ws-changed --base origin/main ws-changed --provider pnpm,pgpm --base origin/main --json ws-changed --provider pgpm --dirs --global 'pnpm-lock.yaml' '.github/**' - ws-changed --why --base origin/develop`; + ws-changed --why --base origin/develop + + # does this branch touch SQL, and where? + ws-changed --provider pgpm --ext .sql --exts + + # lint lane: TypeScript only, ignoring generated trees + ws-changed --ext ts,tsx --not-files '**/generated/**'`; function packageVersion(): string { for (const candidate of ['../package.json', './package.json']) { @@ -59,6 +70,7 @@ interface Parsed { onlyChanged: boolean; dirs: boolean; why: boolean; + exts: boolean; list: boolean; graph: boolean; json: boolean; @@ -78,12 +90,16 @@ export function parseArgs(argv: string[]): Parsed { const global: string[] = []; const include: string[] = []; const exclude: string[] = []; + const ext: string[] = []; + const files: string[] = []; + const notFiles: string[] = []; const overrides: Partial = {}; const parsed: Parsed = { overrides, onlyChanged: false, dirs: false, why: false, + exts: false, list: false, graph: false, json: false, @@ -135,6 +151,15 @@ export function parseArgs(argv: string[]): Parsed { case '--exclude': pushList(exclude, next()); break; + case '--ext': + pushList(ext, next()); + break; + case '--files': + pushList(files, next()); + break; + case '--not-files': + pushList(notFiles, next()); + break; case '--cwd': parsed.cwd = next(); break; @@ -147,6 +172,9 @@ export function parseArgs(argv: string[]): Parsed { case '--why': parsed.why = true; break; + case '--exts': + parsed.exts = true; + break; case '--list': parsed.list = true; break; @@ -165,6 +193,13 @@ export function parseArgs(argv: string[]): Parsed { if (global.length) overrides.global = global; if (include.length) overrides.include = include; if (exclude.length) overrides.exclude = exclude; + if (ext.length || files.length || notFiles.length) { + overrides.files = { + ...(ext.length ? { ext } : {}), + ...(files.length ? { include: files } : {}), + ...(notFiles.length ? { exclude: notFiles } : {}) + }; + } return parsed; } @@ -233,6 +268,14 @@ export function run(argv: string[] = process.argv.slice(2)): number { return 0; } + if (parsed.exts) { + for (const name of result.changed) { + const exts = result.extensionsByPackage[name] ?? []; + console.log(`${name}\t${exts.join(' ')}`); + } + return 0; + } + if (parsed.why) { for (const reason of result.why) { const suffix = diff --git a/packages/ws-changed/src/config.ts b/packages/ws-changed/src/config.ts index 48f5fe3..3a0f411 100644 --- a/packages/ws-changed/src/config.ts +++ b/packages/ws-changed/src/config.ts @@ -28,10 +28,14 @@ export function loadConfig(params: { defaults: DEFAULT_CONFIG, // A changed lockfile or CI change should invalidate everything, so let the // environment inject a global-trigger list without a config file. - envLayer: (env) => - env.WS_CHANGED_GLOBAL + envLayer: (env) => ({ + ...(env.WS_CHANGED_GLOBAL ? { global: env.WS_CHANGED_GLOBAL.split(',').map((s) => s.trim()).filter(Boolean) } - : {} + : {}), + // A CI lane asks one question per extension set, so let the lane name its + // extensions in the environment rather than needing its own config file. + ...(env.WS_CHANGED_EXT ? { files: { ext: env.WS_CHANGED_EXT } } : {}) + }) }); const result = loader.loadSync({ cwd: params.cwd, diff --git a/packages/ws-changed/src/glob.ts b/packages/ws-changed/src/glob.ts index a577cf8..a57ea27 100644 --- a/packages/ws-changed/src/glob.ts +++ b/packages/ws-changed/src/glob.ts @@ -135,6 +135,32 @@ export function makeMatcher(patterns: string[] = []): (rel: string) => boolean { return (rel: string) => regexes.some((re) => re.test(rel)); } +/** + * Normalize `.sql` / `sql` / `['.sql','.psql']` / `'ts,tsx'` into a lowercased + * `.ext` list. Same shape git-changed accepts, so a filter written for one works + * verbatim in the other. + */ +export function normalizeExts(ext?: string | string[]): string[] { + const list = Array.isArray(ext) ? ext : ext ? [ext] : []; + return list + .flatMap((e) => e.split(',')) + .map((e) => e.trim()) + .filter(Boolean) + .map((e) => (e.startsWith('.') ? e : `.${e}`)) + .map((e) => e.toLowerCase()); +} + +/** + * The lowercased extension of a `/`-separated relative path, or `''` when it has + * none. A leading dot is a name, not an extension, so `.gitignore` and `Makefile` + * both yield `''`. + */ +export function extOf(rel: string): string { + const base = rel.slice(rel.lastIndexOf('/') + 1); + const dot = base.lastIndexOf('.'); + return dot > 0 ? base.slice(dot).toLowerCase() : ''; +} + /** Normalize an absolute or relative path to a `/`-separated path relative to root. */ export function toRel(root: string, path: string): string { return relative(root, path).split(sep).join('/'); diff --git a/packages/ws-changed/src/index.ts b/packages/ws-changed/src/index.ts index 6b97e84..79e5d0d 100644 --- a/packages/ws-changed/src/index.ts +++ b/packages/ws-changed/src/index.ts @@ -1,6 +1,6 @@ export { affected, type AffectedParams } from './affected'; export { DEFAULT_CONFIG, loadConfig } from './config'; -export { expandDirGlob, makeMatcher, toRel } from './glob'; +export { expandDirGlob, extOf, makeMatcher, normalizeExts, toRel } from './glob'; export { WorkspaceGraph } from './graph'; export { globProvider } from './providers/glob'; export { pgpmProvider } from './providers/pgpm'; @@ -11,6 +11,7 @@ export type { AffectedReason, AffectedResult, EdgeKind, + FileFilter, GlobProviderConfig, PgpmProviderConfig, PnpmProviderConfig, diff --git a/packages/ws-changed/src/run.ts b/packages/ws-changed/src/run.ts index 4a1c75f..02b0fe3 100644 --- a/packages/ws-changed/src/run.ts +++ b/packages/ws-changed/src/run.ts @@ -49,6 +49,10 @@ export function wsChanged(params: WsChangedParams = {}): WsChangedRun { base = cr.base; } - const result = affected(workspace, { changed, global: config.global }); + const result = affected(workspace, { + changed, + global: config.global, + ...(config.files ? { files: config.files } : {}) + }); return { workspace, config, configPath, result, base }; } diff --git a/packages/ws-changed/src/types.ts b/packages/ws-changed/src/types.ts index f821eff..1c560f5 100644 --- a/packages/ws-changed/src/types.ts +++ b/packages/ws-changed/src/types.ts @@ -34,6 +34,28 @@ export interface Workspace { packages: WorkspacePackage[]; } +/** + * A filter over the changed *files*, applied before they are mapped to owning + * packages — the counterpart to {@link WsChangedConfig.include}/`exclude`, + * which filter the *packages*. + * + * This is what makes one changeset answer several questions: "which packages + * have changed SQL" and "which have changed TypeScript" are the same diff with + * a different `ext`, and a CI lane that rebuilds an image cares about neither + * when the only change is a `.md`. + * + * Same shapes and semantics as git-changed's `ext`/`include`/`exclude`, so a + * filter can move between the two without translation. + */ +export interface FileFilter { + /** Keep only these extensions: `'.sql'`, `'sql'`, `['.ts', '.tsx']`. */ + ext?: string | string[]; + /** Keep only paths matching these globs. */ + include?: string[]; + /** Drop paths matching these globs — generated trees, `dist/`, fixtures. */ + exclude?: string[]; +} + /** pnpm dependency edge kinds, so callers can select which ones count. */ export type EdgeKind = 'prod' | 'dev' | 'peer' | 'optional'; @@ -93,6 +115,11 @@ export interface WsChangedConfig { * matches, {@link AffectedResult.global} is `true`. */ global?: string[]; + /** + * Filter the changed files before they are attributed to packages. A file the + * filter drops cannot make a package affected and cannot trigger `global`. + */ + files?: FileFilter; /** Restrict discovered packages to those whose relDir matches these globs. */ include?: string[]; /** Drop discovered packages whose relDir matches these globs. */ @@ -128,4 +155,20 @@ export interface AffectedResult { globalMatches: string[]; /** Per-package explanation of why it is affected. */ why: AffectedReason[]; + /** + * Distinct extensions among the changed files that survived the file filter, + * lowercased and sorted (`['.sql', '.ts']`). Extensionless files contribute + * nothing. Answers "did this branch touch any SQL at all?" without a second + * pass over the diff. + */ + extensions: string[]; + /** + * The extensions changed *within* each package that directly owns a change, + * keyed by package name. Dependents are absent — they own no changed file — + * so `extensionsByPackage[pkg]` is "what changed in `pkg`", not "what `pkg` + * might be affected by". + */ + extensionsByPackage: Record; + /** Changed paths dropped by the file filter, sorted. Empty without a filter. */ + ignored: string[]; }