From 5b02fde2b5e4df982029d08f67dcfa5ba9ea6a12 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 28 Oct 2026 21:04:30 -0700 Subject: [PATCH 1/3] feat: per-package `sourcePaths` for change detection outside the package dir --- .bumpy/package-source-paths.md | 5 +++ docs/configuration.md | 22 ++++++++++- packages/bumpy/config-schema.json | 5 +++ packages/bumpy/src/commands/check.ts | 22 ++++++++++- packages/bumpy/src/commands/generate.ts | 12 ++++-- packages/bumpy/src/core/config.ts | 15 +++++++ packages/bumpy/src/types.ts | 8 ++++ packages/bumpy/test/core/check.test.ts | 48 +++++++++++++++++++++++ packages/bumpy/test/core/generate.test.ts | 18 +++++++++ 9 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 .bumpy/package-source-paths.md diff --git a/.bumpy/package-source-paths.md b/.bumpy/package-source-paths.md new file mode 100644 index 0000000..938018e --- /dev/null +++ b/.bumpy/package-source-paths.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +New per-package `sourcePaths` option: repo-root-relative globs (with `!` exclusions) for sources outside a package's directory. A changed file matching one marks the package as changed, so `bumpy check` requires a bump file for it and `bumpy generate` attributes the commit to it — e.g. a native-binary package built from a separate source package. diff --git a/docs/configuration.md b/docs/configuration.md index 6543fd0..fd1490f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,7 +38,7 @@ These are two different things, and bumpy treats them differently: ### Change detection and `package.json` fields -A package is "changed" (and so needs a bump file) when a changed file inside it matches `changedFilePatterns`. `package.json` is a special case: editing it shouldn't always demand a release — a `devDependencies` bump from Dependabot, for example, doesn't affect what consumers install. +A package is "changed" (and so needs a bump file) when a changed file inside it matches `changedFilePatterns`, or a changed file anywhere in the repo matches its [`sourcePaths`](#sourcepaths--sources-outside-the-package-directory). `package.json` is a special case: editing it shouldn't always demand a release — a `devDependencies` bump from Dependabot, for example, doesn't affect what consumers install. So when `package.json` is the **only** changed file in a package, bumpy diffs it against the base branch and only flags the package if a field **outside** `ignoredPackageJsonFields` changed. The default ignore list is `["devDependencies"]`, meaning dev-only dependency updates don't require a bump file. Every other field — `dependencies`, `exports`, `bin`, `files`, `description`, `scripts`, etc. — still counts. @@ -187,11 +187,31 @@ Per-package settings can be defined in two places: | `skipNpmPublish` | `boolean` | Don't publish to npm (still creates git tags) | | `checkPublished` | `string` | Custom command that outputs the currently published version | | `changedFilePatterns` | `string[]` | Glob patterns for changed-file detection (replaces root setting, not merged) | +| `sourcePaths` | `string[]` | Repo-root-relative globs for sources outside the package dir (see below) | | `dependencyBumpRules` | `object` | Per-package override for dependency propagation rules | | `cascadeTo` | `object` | Explicit cascade targets — glob pattern mapped to `{ trigger, bumpAs }` | | `cascadeFrom` | `object` | Explicit cascade sources — glob pattern mapped to `{ trigger, bumpAs }` | | `releaseTriggeringDevDeps` | `string[]` | devDependencies that affect published output — a change requires a release (see below) | +### `sourcePaths` — sources outside the package directory + +Change detection normally only looks at files inside a package's directory. When a package is built from sources that live elsewhere — e.g. a published native-binary package whose binary is compiled from a separate (private) package — list those sources in `sourcePaths`. Globs are relative to the **repo root**, and a changed file matching one marks the package as changed exactly as if it lived inside it, so `bumpy check` (and the CI check / PR comment) requires a bump file for it and `bumpy generate` attributes the commit to it. + +```jsonc +{ + "packages": { + "@mycli/native-darwin": { + "sourcePaths": ["packages/native-src-swift/**", "!**/*.md"], + }, + }, +} +``` + +- Patterns prefixed with `!` exclude — a file counts when it matches any positive pattern and no negated one. +- `sourcePaths` is additive: `changedFilePatterns` still filters only the files inside the package's own directory and does not apply to `sourcePaths` matches. +- A file can mark several packages as changed (the source package itself, if versioned, and every package listing it). +- Like other per-package options it can be set in the root `packages` map (by name or glob) or in the package's `package.json` `"bumpy"` field — the globs are always repo-root-relative. + ### `directBump: false` — packages that only follow Some packages are derived artifacts of another package and should never be bumped on their own — the typical case is platform-specific binary packages published alongside a core package (the esbuild/napi-rs pattern, where the core references each binary via exact-version `optionalDependencies`). diff --git a/packages/bumpy/config-schema.json b/packages/bumpy/config-schema.json index 9c1ac7f..6f3aaef 100644 --- a/packages/bumpy/config-schema.json +++ b/packages/bumpy/config-schema.json @@ -358,6 +358,11 @@ "description": "Glob patterns to filter which changed files count toward marking this package as changed (overrides root setting)", "items": { "type": "string" } }, + "sourcePaths": { + "type": "array", + "description": "Extra change-detection globs, relative to the repo root, for sources outside this package's directory. A changed file matching one marks this package as changed. Additive to changedFilePatterns (which only applies inside the package). Prefix with ! to exclude.", + "items": { "type": "string" } + }, "dependencyBumpRules": { "type": "object", "description": "Per-package override for dependency propagation rules", diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index c65d2c5..04a99f4 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -1,7 +1,14 @@ import { relative, resolve } from 'node:path'; import picomatch from 'picomatch'; import { log, colorize } from '../utils/logger.ts'; -import { loadConfig, loadPackageConfig, getBumpyDir, matchGlob, resolveFixedGroups } from '../core/config.ts'; +import { + loadConfig, + loadPackageConfig, + getBumpyDir, + matchGlob, + makeSourcePathsMatcher, + resolveFixedGroups, +} from '../core/config.ts'; import { discoverWorkspace } from '../core/workspace.ts'; import { readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts'; import { getChangedFiles, getFileStatuses, getBaseCompareRef, readFileAtRef } from '../core/git.ts'; @@ -215,7 +222,9 @@ export async function checkCommand(rootDir: string, opts: CheckOptions = {}): Pr } else { log.dim('Run `bumpy add` to create a bump file, or `bumpy add --empty` if no release is needed.'); } - log.dim('To adjust which files trigger change detection, set `changedFilePatterns` in .bumpy/_config.json.'); + log.dim( + 'To adjust which files trigger change detection, set `changedFilePatterns` (or per-package `sourcePaths`) in .bumpy/_config.json.', + ); if (willFail) process.exit(1); } @@ -289,10 +298,14 @@ export async function findChangedPackages( // Build per-package matchers (per-package patterns override root patterns) const matchers = new Map(); + // `sourcePaths` matchers — repo-root-relative globs for sources outside the package dir + const sourceMatchers = new Map boolean>(); for (const [name, pkg] of packages) { const pkgConfig = await loadPackageConfig(pkg.dir, config, name); const patterns = pkgConfig.changedFilePatterns ?? config.changedFilePatterns; matchers.set(name, picomatch(patterns)); + const sourceMatcher = makeSourcePathsMatcher(pkgConfig.sourcePaths); + if (sourceMatcher) sourceMatchers.set(name, sourceMatcher); } const ignoredFields = config.ignoredPackageJsonFields ?? ['devDependencies']; @@ -306,9 +319,14 @@ export async function findChangedPackages( const isRoot = pkgRelDir === ''; const prefix = `${pkgRelDir}/`; const matcher = matchers.get(name)!; + const sourceMatcher = sourceMatchers.get(name); let pkgJsonOnlyTrigger = false; for (const file of changedFiles) { + if (sourceMatcher?.(file)) { + changed.add(name); + break; + } let relToPackage: string; if (isRoot) { relToPackage = file; diff --git a/packages/bumpy/src/commands/generate.ts b/packages/bumpy/src/commands/generate.ts index a509832..4d39ede 100644 --- a/packages/bumpy/src/commands/generate.ts +++ b/packages/bumpy/src/commands/generate.ts @@ -4,7 +4,7 @@ import { tryRunArgs } from '../utils/shell.ts'; import { loadConfig } from '../core/config.ts'; import { discoverPackages } from '../core/workspace.ts'; import { writeBumpFile } from '../core/bump-file.ts'; -import { getBumpyDir } from '../core/config.ts'; +import { getBumpyDir, makeSourcePathsMatcher } from '../core/config.ts'; import { ensureDir } from '../utils/fs.ts'; import { slugify, randomName } from '../utils/names.ts'; import { getBaseCompareRef, getBranchCommits, getCurrentBranch, getFilesChangedInCommit } from '../core/git.ts'; @@ -264,19 +264,23 @@ export async function mapCommitToPackages( return [...matched]; } -/** Map file paths to package names based on directory containment */ +/** Map file paths to package names based on directory containment (plus each package's `sourcePaths`) */ export function mapFilesToPackages( files: string[], packages: Map, rootDir: string, ): string[] { const matched = new Set(); + const sourceMatchers = new Map( + [...packages].map(([name, pkg]) => [name, makeSourcePathsMatcher(pkg.bumpy?.sourcePaths)]), + ); for (const file of files) { for (const [name, pkg] of packages) { const pkgRelDir = relative(rootDir, pkg.dir); // Root package (single-package repo) has an empty relative dir, so every - // file belongs to it. Otherwise the file must live under the package dir. - if (pkgRelDir === '' || file.startsWith(pkgRelDir + '/')) { + // file belongs to it. Otherwise the file must live under the package dir + // or match one of its `sourcePaths`. + if (pkgRelDir === '' || file.startsWith(pkgRelDir + '/') || sourceMatchers.get(name)?.(file)) { matched.add(name); } } diff --git a/packages/bumpy/src/core/config.ts b/packages/bumpy/src/core/config.ts index b09107d..2390aba 100644 --- a/packages/bumpy/src/core/config.ts +++ b/packages/bumpy/src/core/config.ts @@ -1,4 +1,5 @@ import { resolve } from 'node:path'; +import picomatch from 'picomatch'; import { readJson, readJsonc, exists } from '../utils/fs.ts'; import { type BumpyConfig, type PackageConfig, DEFAULT_CONFIG, normalizeCascadeConfig } from '../types.ts'; @@ -97,6 +98,20 @@ export function resolveFixedGroups(config: BumpyConfig, packageNames: Iterable names.filter((name) => group.some((pattern) => matchGlob(name, pattern)))); } +/** + * Build a matcher for a package's `sourcePaths` (repo-root-relative globs). Patterns + * starting with `!` exclude — a file counts when it matches any positive pattern and no + * negated one. Returns null when there are no positive patterns. + */ +export function makeSourcePathsMatcher(sourcePaths: string[] | undefined): ((file: string) => boolean) | null { + const include = (sourcePaths ?? []).filter((p) => !p.startsWith('!')); + if (include.length === 0) return null; + const exclude = (sourcePaths ?? []).filter((p) => p.startsWith('!')).map((p) => p.slice(1)); + const isIncluded = picomatch(include); + const isExcluded = exclude.length > 0 ? picomatch(exclude) : () => false; + return (file) => isIncluded(file) && !isExcluded(file); +} + /** Simple glob matching for package names (supports * and **) */ export function matchGlob(name: string, pattern: string): boolean { // Exact match diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index 09834f8..43d8159 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -219,6 +219,14 @@ export interface PackageConfig { checkPublished?: string; /** Glob patterns to filter which changed files count toward marking this package as changed */ changedFilePatterns?: string[]; + /** + * Extra change-detection globs, relative to the **repo root**, for sources that live + * outside this package's directory (e.g. a binary built from a sibling package). A + * changed file matching one marks this package as changed, exactly as if it were inside + * it. Additive — `changedFilePatterns` still applies only to files inside the package. + * Prefix a pattern with `!` to exclude matches (e.g. `"!packages/foo/docs/**"`). + */ + sourcePaths?: string[]; dependencyBumpRules?: Partial>; cascadeTo?: CascadeConfig; cascadeFrom?: CascadeConfig; diff --git a/packages/bumpy/test/core/check.test.ts b/packages/bumpy/test/core/check.test.ts index 6472f1c..ce62a9e 100644 --- a/packages/bumpy/test/core/check.test.ts +++ b/packages/bumpy/test/core/check.test.ts @@ -132,6 +132,54 @@ describe('findChangedPackages', () => { const result = await findChangedPackages(changed, packages, rootDir, makeConfig()); expect(result).toEqual(['pkg-a']); }); + + describe('sourcePaths', () => { + const packages = new Map([ + ['@x/helper-darwin', makePkg('@x/helper-darwin', '1.0.0', { dir: `${rootDir}/packages/helpers/darwin` })], + ['@x/helper-linux', makePkg('@x/helper-linux', '1.0.0', { dir: `${rootDir}/packages/helpers/linux` })], + ['swift-src', makePkg('swift-src', '1.0.0', { dir: `${rootDir}/packages/swift-src` })], + ]); + + test('a file outside the package matching sourcePaths marks it changed', async () => { + const config = makeConfig({ + packages: { '@x/helper-darwin': { sourcePaths: ['packages/swift-src/**'] } }, + }); + const result = await findChangedPackages(['packages/swift-src/Sources/main.swift'], packages, rootDir, config); + expect(result.sort()).toEqual(['@x/helper-darwin', 'swift-src']); + }); + + test('a non-matching file does not mark it changed', async () => { + const config = makeConfig({ + packages: { '@x/helper-darwin': { sourcePaths: ['packages/swift-src/**'] } }, + }); + expect(await findChangedPackages(['packages/other/index.ts'], packages, rootDir, config)).toEqual([]); + }); + + test('negated patterns exclude matches', async () => { + const config = makeConfig({ + packages: { '@x/helper-darwin': { sourcePaths: ['packages/swift-src/**', '!**/*.md'] } }, + }); + const result = await findChangedPackages(['packages/swift-src/README.md'], packages, rootDir, config); + expect(result).toEqual(['swift-src']); + }); + + test('works with glob package keys', async () => { + const config = makeConfig({ + packages: { '@x/helper-*': { sourcePaths: ['native/**'] } }, + }); + const result = await findChangedPackages(['native/shared.c'], packages, rootDir, config); + expect(result.sort()).toEqual(['@x/helper-darwin', '@x/helper-linux']); + }); + + test('changedFilePatterns does not filter sourcePaths matches', async () => { + const config = makeConfig({ + changedFilePatterns: ['src/**'], + packages: { '@x/helper-darwin': { sourcePaths: ['packages/swift-src/**'] } }, + }); + const result = await findChangedPackages(['packages/swift-src/Package.swift'], packages, rootDir, config); + expect(result).toEqual(['@x/helper-darwin']); + }); + }); }); describe('resolveDirectBumpCoverage', () => { diff --git a/packages/bumpy/test/core/generate.test.ts b/packages/bumpy/test/core/generate.test.ts index 251a4a3..74f98c3 100644 --- a/packages/bumpy/test/core/generate.test.ts +++ b/packages/bumpy/test/core/generate.test.ts @@ -23,6 +23,24 @@ describe('mapFilesToPackages', () => { const result = mapFilesToPackages(['packages/pkg-b/src/index.ts'], packages, rootDir); expect(result).toEqual(['pkg-b']); }); + + test('attributes files matching sourcePaths to the package', () => { + const packages = new Map([ + [ + 'helper', + makePkg('helper', '1.0.0', { + dir: `${rootDir}/packages/helper`, + bumpy: { sourcePaths: ['packages/swift-src/**', '!**/*.md'] }, + }), + ], + ['swift-src', makePkg('swift-src', '1.0.0', { dir: `${rootDir}/packages/swift-src` })], + ]); + expect(mapFilesToPackages(['packages/swift-src/main.swift'], packages, rootDir).sort()).toEqual([ + 'helper', + 'swift-src', + ]); + expect(mapFilesToPackages(['packages/swift-src/README.md'], packages, rootDir)).toEqual(['swift-src']); + }); }); describe('mapCommitToPackages — catalog changes', () => { From f92425e05392d985c7a2609fb5b13e883d5b14e4 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 28 Oct 2026 21:09:51 -0700 Subject: [PATCH 2/3] feat: resolve ./ and ../ sourcePaths relative to where they're declared --- docs/configuration.md | 5 +-- packages/bumpy/config-schema.json | 2 +- packages/bumpy/src/commands/check.ts | 2 +- packages/bumpy/src/core/config.ts | 25 ++++++++++++-- packages/bumpy/src/core/workspace.ts | 2 +- packages/bumpy/src/types.ts | 4 ++- packages/bumpy/test/core/check.test.ts | 45 ++++++++++++++++++++++++++ 7 files changed, 76 insertions(+), 9 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index fd1490f..979fc9a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -187,7 +187,7 @@ Per-package settings can be defined in two places: | `skipNpmPublish` | `boolean` | Don't publish to npm (still creates git tags) | | `checkPublished` | `string` | Custom command that outputs the currently published version | | `changedFilePatterns` | `string[]` | Glob patterns for changed-file detection (replaces root setting, not merged) | -| `sourcePaths` | `string[]` | Repo-root-relative globs for sources outside the package dir (see below) | +| `sourcePaths` | `string[]` | Globs for sources outside the package dir (see below) | | `dependencyBumpRules` | `object` | Per-package override for dependency propagation rules | | `cascadeTo` | `object` | Explicit cascade targets — glob pattern mapped to `{ trigger, bumpAs }` | | `cascadeFrom` | `object` | Explicit cascade sources — glob pattern mapped to `{ trigger, bumpAs }` | @@ -210,7 +210,8 @@ Change detection normally only looks at files inside a package's directory. When - Patterns prefixed with `!` exclude — a file counts when it matches any positive pattern and no negated one. - `sourcePaths` is additive: `changedFilePatterns` still filters only the files inside the package's own directory and does not apply to `sourcePaths` matches. - A file can mark several packages as changed (the source package itself, if versioned, and every package listing it). -- Like other per-package options it can be set in the root `packages` map (by name or glob) or in the package's `package.json` `"bumpy"` field — the globs are always repo-root-relative. +- Like other per-package options it can be set in the root `packages` map (by name or glob) or in the package's `package.json` `"bumpy"` field. +- Plain globs are repo-root-relative wherever they're written. Globs starting with `./` or `../` are relative to where they're written: the package's directory in `package.json`, the repo root in `.bumpy/_config.json`. So in `packages/native/darwin/package.json`, `"../../native-src-swift/**"` is the same as `"packages/native-src-swift/**"`. ### `directBump: false` — packages that only follow diff --git a/packages/bumpy/config-schema.json b/packages/bumpy/config-schema.json index 6f3aaef..75078ab 100644 --- a/packages/bumpy/config-schema.json +++ b/packages/bumpy/config-schema.json @@ -360,7 +360,7 @@ }, "sourcePaths": { "type": "array", - "description": "Extra change-detection globs, relative to the repo root, for sources outside this package's directory. A changed file matching one marks this package as changed. Additive to changedFilePatterns (which only applies inside the package). Prefix with ! to exclude.", + "description": "Extra change-detection globs for sources outside this package's directory. A changed file matching one marks this package as changed. Additive to changedFilePatterns (which only applies inside the package). Relative to the repo root; patterns starting with ./ or ../ are relative to where they're written (the package dir in package.json, the repo root in .bumpy/_config.json). Prefix with ! to exclude.", "items": { "type": "string" } }, "dependencyBumpRules": { diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index 04a99f4..974b23a 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -301,7 +301,7 @@ export async function findChangedPackages( // `sourcePaths` matchers — repo-root-relative globs for sources outside the package dir const sourceMatchers = new Map boolean>(); for (const [name, pkg] of packages) { - const pkgConfig = await loadPackageConfig(pkg.dir, config, name); + const pkgConfig = await loadPackageConfig(pkg.dir, config, name, rootDir); const patterns = pkgConfig.changedFilePatterns ?? config.changedFilePatterns; matchers.set(name, picomatch(patterns)); const sourceMatcher = makeSourcePathsMatcher(pkgConfig.sourcePaths); diff --git a/packages/bumpy/src/core/config.ts b/packages/bumpy/src/core/config.ts index 2390aba..1dca6b0 100644 --- a/packages/bumpy/src/core/config.ts +++ b/packages/bumpy/src/core/config.ts @@ -1,4 +1,4 @@ -import { resolve } from 'node:path'; +import { relative, resolve, sep } from 'node:path'; import picomatch from 'picomatch'; import { readJson, readJsonc, exists } from '../utils/fs.ts'; import { type BumpyConfig, type PackageConfig, DEFAULT_CONFIG, normalizeCascadeConfig } from '../types.ts'; @@ -43,9 +43,10 @@ export async function loadPackageConfig( pkgDir: string, rootConfig: BumpyConfig, pkgName: string, + rootDir: string, ): Promise { // Start with what's in the root config's packages map - const rootPkgConfig = findPackageConfig(rootConfig, pkgName); + const rootPkgConfig = withRootRelativeSourcePaths(findPackageConfig(rootConfig, pkgName), rootDir, rootDir); // Layer on package.json["bumpy"] let pkgJsonConfig: PackageConfig = {}; @@ -75,7 +76,25 @@ export async function loadPackageConfig( } // Merge: root packages map → package.json["bumpy"] (later wins) - return mergePackageConfig(rootPkgConfig, pkgJsonConfig); + return mergePackageConfig(rootPkgConfig, withRootRelativeSourcePaths(pkgJsonConfig, pkgDir, rootDir)); +} + +/** + * Normalize `sourcePaths` to repo-root-relative globs. Patterns starting with `./` or + * `../` are relative to where they're written — `baseDir` is the package dir for + * package.json config, the repo root for the root config. Other patterns are already + * repo-root-relative. A leading `!` is preserved. + */ +function withRootRelativeSourcePaths(cfg: PackageConfig, baseDir: string, rootDir: string): PackageConfig { + if (!cfg.sourcePaths) return cfg; + const sourcePaths = cfg.sourcePaths.map((pattern) => { + const negated = pattern.startsWith('!'); + const body = negated ? pattern.slice(1) : pattern; + if (!/^\.\.?\//.test(body)) return pattern; + const rootRelative = relative(rootDir, resolve(baseDir, body)).split(sep).join('/'); + return (negated ? '!' : '') + rootRelative; + }); + return { ...cfg, sourcePaths }; } /** Find a package config from the root config, supporting glob patterns */ diff --git a/packages/bumpy/src/core/workspace.ts b/packages/bumpy/src/core/workspace.ts index 623fe88..662bdd4 100644 --- a/packages/bumpy/src/core/workspace.ts +++ b/packages/bumpy/src/core/workspace.ts @@ -132,7 +132,7 @@ async function loadWorkspacePackage( const name = pkg.name as string; if (!name) return null; - const bumpy = await loadPackageConfig(dir, config, name); + const bumpy = await loadPackageConfig(dir, config, name, rootDir); return { name, diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index 43d8159..aa1ad69 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -220,7 +220,9 @@ export interface PackageConfig { /** Glob patterns to filter which changed files count toward marking this package as changed */ changedFilePatterns?: string[]; /** - * Extra change-detection globs, relative to the **repo root**, for sources that live + * Extra change-detection globs, relative to the **repo root** (or, when starting with + * `./` / `../`, relative to where they're written: the package dir in package.json, the + * repo root in `.bumpy/_config.json`), for sources that live * outside this package's directory (e.g. a binary built from a sibling package). A * changed file matching one marks this package as changed, exactly as if it were inside * it. Additive — `changedFilePatterns` still applies only to files inside the package. diff --git a/packages/bumpy/test/core/check.test.ts b/packages/bumpy/test/core/check.test.ts index ce62a9e..daa91aa 100644 --- a/packages/bumpy/test/core/check.test.ts +++ b/packages/bumpy/test/core/check.test.ts @@ -171,6 +171,51 @@ describe('findChangedPackages', () => { expect(result.sort()).toEqual(['@x/helper-darwin', '@x/helper-linux']); }); + test('./ in the root config is relative to the repo root', async () => { + const config = makeConfig({ + packages: { '@x/helper-darwin': { sourcePaths: ['./packages/swift-src/**'] } }, + }); + const result = await findChangedPackages(['packages/swift-src/main.swift'], packages, rootDir, config); + expect(result.sort()).toEqual(['@x/helper-darwin', 'swift-src']); + }); + + describe('declared in package.json', () => { + const tmpRoot = resolve(import.meta.dir, '../../.test-tmp-source-paths'); + const helperDir = resolve(tmpRoot, 'packages/helpers/darwin'); + const pkgs = new Map([ + ['helper', makePkg('helper', '1.0.0', { dir: helperDir })], + ['swift-src', makePkg('swift-src', '1.0.0', { dir: resolve(tmpRoot, 'packages/swift-src') })], + ]); + + beforeEach(async () => { + await mkdir(helperDir, { recursive: true }); + await writeFile( + resolve(helperDir, 'package.json'), + JSON.stringify({ + name: 'helper', + bumpy: { sourcePaths: ['../../swift-src/**', '!../../swift-src/**/*.md', 'shared/**'] }, + }), + ); + }); + afterEach(async () => { + await rm(tmpRoot, { recursive: true, force: true }); + }); + + test('./ and ../ patterns are relative to the package dir', async () => { + const changed = ['packages/swift-src/main.swift']; + expect((await findChangedPackages(changed, pkgs, tmpRoot, makeConfig())).sort()).toEqual([ + 'helper', + 'swift-src', + ]); + const docs = ['packages/swift-src/README.md']; + expect(await findChangedPackages(docs, pkgs, tmpRoot, makeConfig())).toEqual(['swift-src']); + }); + + test('plain patterns stay repo-root-relative', async () => { + expect(await findChangedPackages(['shared/x.c'], pkgs, tmpRoot, makeConfig())).toEqual(['helper']); + }); + }); + test('changedFilePatterns does not filter sourcePaths matches', async () => { const config = makeConfig({ changedFilePatterns: ['src/**'], From 9621a6131b980c52b9dfadbf240a9068fea0755f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 28 Oct 2026 21:20:53 -0700 Subject: [PATCH 3/3] feat: sourcePaths are package-relative, like every per-package glob --- .bumpy/package-source-paths.md | 2 +- docs/configuration.md | 12 +-- packages/bumpy/config-schema.json | 2 +- packages/bumpy/src/commands/generate.ts | 1 + packages/bumpy/src/core/config.ts | 19 ++-- packages/bumpy/src/types.ts | 13 ++- packages/bumpy/test/core/check.test.ts | 101 ++++++++-------------- packages/bumpy/test/core/generate.test.ts | 1 + 8 files changed, 61 insertions(+), 90 deletions(-) diff --git a/.bumpy/package-source-paths.md b/.bumpy/package-source-paths.md index 938018e..a80fc27 100644 --- a/.bumpy/package-source-paths.md +++ b/.bumpy/package-source-paths.md @@ -2,4 +2,4 @@ '@varlock/bumpy': minor --- -New per-package `sourcePaths` option: repo-root-relative globs (with `!` exclusions) for sources outside a package's directory. A changed file matching one marks the package as changed, so `bumpy check` requires a bump file for it and `bumpy generate` attributes the commit to it — e.g. a native-binary package built from a separate source package. +New per-package `sourcePaths` option: globs (relative to the package dir like other per-package globs, `../` to reach out, `!` to exclude) for sources outside a package's directory. A changed file matching one marks the package as changed, so `bumpy check` requires a bump file for it and `bumpy generate` attributes the commit to it — e.g. a native-binary package built from a separate source package. diff --git a/docs/configuration.md b/docs/configuration.md index 979fc9a..91e0984 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -176,6 +176,8 @@ Per-package settings can be defined in two places: `package.json` settings take precedence over global config. +Globs in per-package options (`changedFilePatterns`, `sourcePaths`) are always relative to the package's directory, wherever they're declared. + | Option | Type | Description | | -------------------------- | -------------------------- | ---------------------------------------------------------------------------------------- | | `managed` | `boolean` | Opt this package in or out of versioning | @@ -195,23 +197,23 @@ Per-package settings can be defined in two places: ### `sourcePaths` — sources outside the package directory -Change detection normally only looks at files inside a package's directory. When a package is built from sources that live elsewhere — e.g. a published native-binary package whose binary is compiled from a separate (private) package — list those sources in `sourcePaths`. Globs are relative to the **repo root**, and a changed file matching one marks the package as changed exactly as if it lived inside it, so `bumpy check` (and the CI check / PR comment) requires a bump file for it and `bumpy generate` attributes the commit to it. +Change detection normally only looks at files inside a package's directory. When a package is built from sources that live elsewhere — e.g. a published native-binary package whose binary is compiled from a separate (private) package — list those sources in `sourcePaths`. A changed file matching one marks the package as changed exactly as if it lived inside it, so `bumpy check` (and the CI check / PR comment) requires a bump file for it and `bumpy generate` attributes the commit to it. ```jsonc { "packages": { "@mycli/native-darwin": { - "sourcePaths": ["packages/native-src-swift/**", "!**/*.md"], + // @mycli/native-darwin lives in packages/native/darwin + "sourcePaths": ["../../native-src-swift/**", "!../../native-src-swift/**/*.md"], }, }, } ``` -- Patterns prefixed with `!` exclude — a file counts when it matches any positive pattern and no negated one. +- Globs are relative to the **package's directory**, like every per-package glob (`changedFilePatterns` too), whether declared in the root `packages` map (by name or glob) or in the package's `package.json` `"bumpy"` field. Use `../` to reach sources outside it. +- Patterns prefixed with `!` exclude — a file counts when it matches any positive pattern and no negated one. Exclusions are package-relative too, so `"!**/*.md"` would only exclude markdown inside the package itself; anchor them to the source dir as above. - `sourcePaths` is additive: `changedFilePatterns` still filters only the files inside the package's own directory and does not apply to `sourcePaths` matches. - A file can mark several packages as changed (the source package itself, if versioned, and every package listing it). -- Like other per-package options it can be set in the root `packages` map (by name or glob) or in the package's `package.json` `"bumpy"` field. -- Plain globs are repo-root-relative wherever they're written. Globs starting with `./` or `../` are relative to where they're written: the package's directory in `package.json`, the repo root in `.bumpy/_config.json`. So in `packages/native/darwin/package.json`, `"../../native-src-swift/**"` is the same as `"packages/native-src-swift/**"`. ### `directBump: false` — packages that only follow diff --git a/packages/bumpy/config-schema.json b/packages/bumpy/config-schema.json index 75078ab..03af2af 100644 --- a/packages/bumpy/config-schema.json +++ b/packages/bumpy/config-schema.json @@ -360,7 +360,7 @@ }, "sourcePaths": { "type": "array", - "description": "Extra change-detection globs for sources outside this package's directory. A changed file matching one marks this package as changed. Additive to changedFilePatterns (which only applies inside the package). Relative to the repo root; patterns starting with ./ or ../ are relative to where they're written (the package dir in package.json, the repo root in .bumpy/_config.json). Prefix with ! to exclude.", + "description": "Extra change-detection globs for sources outside this package's directory, relative to the package dir like every per-package glob (use ../ to reach out). A changed file matching one marks this package as changed. Additive to changedFilePatterns (which only applies inside the package). Prefix with ! to exclude.", "items": { "type": "string" } }, "dependencyBumpRules": { diff --git a/packages/bumpy/src/commands/generate.ts b/packages/bumpy/src/commands/generate.ts index 4d39ede..993c6bd 100644 --- a/packages/bumpy/src/commands/generate.ts +++ b/packages/bumpy/src/commands/generate.ts @@ -271,6 +271,7 @@ export function mapFilesToPackages( rootDir: string, ): string[] { const matched = new Set(); + // `pkg.bumpy.sourcePaths` was resolved to repo-root-relative globs by loadPackageConfig const sourceMatchers = new Map( [...packages].map(([name, pkg]) => [name, makeSourcePathsMatcher(pkg.bumpy?.sourcePaths)]), ); diff --git a/packages/bumpy/src/core/config.ts b/packages/bumpy/src/core/config.ts index 1dca6b0..458bc47 100644 --- a/packages/bumpy/src/core/config.ts +++ b/packages/bumpy/src/core/config.ts @@ -46,7 +46,7 @@ export async function loadPackageConfig( rootDir: string, ): Promise { // Start with what's in the root config's packages map - const rootPkgConfig = withRootRelativeSourcePaths(findPackageConfig(rootConfig, pkgName), rootDir, rootDir); + const rootPkgConfig = findPackageConfig(rootConfig, pkgName); // Layer on package.json["bumpy"] let pkgJsonConfig: PackageConfig = {}; @@ -76,22 +76,20 @@ export async function loadPackageConfig( } // Merge: root packages map → package.json["bumpy"] (later wins) - return mergePackageConfig(rootPkgConfig, withRootRelativeSourcePaths(pkgJsonConfig, pkgDir, rootDir)); + return withRootRelativeSourcePaths(mergePackageConfig(rootPkgConfig, pkgJsonConfig), pkgDir, rootDir); } /** - * Normalize `sourcePaths` to repo-root-relative globs. Patterns starting with `./` or - * `../` are relative to where they're written — `baseDir` is the package dir for - * package.json config, the repo root for the root config. Other patterns are already - * repo-root-relative. A leading `!` is preserved. + * `sourcePaths` are written relative to the package dir (like every per-package glob, + * wherever it's declared) — resolve them to repo-root-relative globs once, so they can be + * matched directly against changed files. A leading `!` is preserved. */ -function withRootRelativeSourcePaths(cfg: PackageConfig, baseDir: string, rootDir: string): PackageConfig { +function withRootRelativeSourcePaths(cfg: PackageConfig, pkgDir: string, rootDir: string): PackageConfig { if (!cfg.sourcePaths) return cfg; const sourcePaths = cfg.sourcePaths.map((pattern) => { const negated = pattern.startsWith('!'); const body = negated ? pattern.slice(1) : pattern; - if (!/^\.\.?\//.test(body)) return pattern; - const rootRelative = relative(rootDir, resolve(baseDir, body)).split(sep).join('/'); + const rootRelative = relative(rootDir, resolve(pkgDir, body)).split(sep).join('/'); return (negated ? '!' : '') + rootRelative; }); return { ...cfg, sourcePaths }; @@ -118,7 +116,8 @@ export function resolveFixedGroups(config: BumpyConfig, packageNames: Iterable>; diff --git a/packages/bumpy/test/core/check.test.ts b/packages/bumpy/test/core/check.test.ts index daa91aa..708b03a 100644 --- a/packages/bumpy/test/core/check.test.ts +++ b/packages/bumpy/test/core/check.test.ts @@ -134,95 +134,64 @@ describe('findChangedPackages', () => { }); describe('sourcePaths', () => { + // sourcePaths are package-relative, so loadPackageConfig reads package.json from disk + const tmpRoot = resolve(import.meta.dir, '../../.test-tmp-source-paths'); + const helperDir = resolve(tmpRoot, 'packages/helpers/darwin'); const packages = new Map([ - ['@x/helper-darwin', makePkg('@x/helper-darwin', '1.0.0', { dir: `${rootDir}/packages/helpers/darwin` })], - ['@x/helper-linux', makePkg('@x/helper-linux', '1.0.0', { dir: `${rootDir}/packages/helpers/linux` })], - ['swift-src', makePkg('swift-src', '1.0.0', { dir: `${rootDir}/packages/swift-src` })], + ['@x/helper-darwin', makePkg('@x/helper-darwin', '1.0.0', { dir: helperDir })], + ['@x/helper-linux', makePkg('@x/helper-linux', '1.0.0', { dir: resolve(tmpRoot, 'packages/helpers/linux') })], + ['swift-src', makePkg('swift-src', '1.0.0', { dir: resolve(tmpRoot, 'packages/swift-src') })], ]); + const check = (files: string[], config = makeConfig()) => + findChangedPackages(files, packages, tmpRoot, config).then((r) => r.sort()); + + afterEach(async () => { + await rm(tmpRoot, { recursive: true, force: true }); + }); test('a file outside the package matching sourcePaths marks it changed', async () => { - const config = makeConfig({ - packages: { '@x/helper-darwin': { sourcePaths: ['packages/swift-src/**'] } }, - }); - const result = await findChangedPackages(['packages/swift-src/Sources/main.swift'], packages, rootDir, config); - expect(result.sort()).toEqual(['@x/helper-darwin', 'swift-src']); + const config = makeConfig({ packages: { '@x/helper-darwin': { sourcePaths: ['../../swift-src/**'] } } }); + expect(await check(['packages/swift-src/Sources/main.swift'], config)).toEqual(['@x/helper-darwin', 'swift-src']); }); test('a non-matching file does not mark it changed', async () => { - const config = makeConfig({ - packages: { '@x/helper-darwin': { sourcePaths: ['packages/swift-src/**'] } }, - }); - expect(await findChangedPackages(['packages/other/index.ts'], packages, rootDir, config)).toEqual([]); + const config = makeConfig({ packages: { '@x/helper-darwin': { sourcePaths: ['../../swift-src/**'] } } }); + expect(await check(['packages/other/index.ts'], config)).toEqual([]); }); - test('negated patterns exclude matches', async () => { - const config = makeConfig({ - packages: { '@x/helper-darwin': { sourcePaths: ['packages/swift-src/**', '!**/*.md'] } }, - }); - const result = await findChangedPackages(['packages/swift-src/README.md'], packages, rootDir, config); - expect(result).toEqual(['swift-src']); + test('globs are package-relative, not repo-root-relative', async () => { + const config = makeConfig({ packages: { '@x/helper-darwin': { sourcePaths: ['packages/swift-src/**'] } } }); + expect(await check(['packages/swift-src/main.swift'], config)).toEqual(['swift-src']); }); - test('works with glob package keys', async () => { + test('negated patterns exclude matches', async () => { const config = makeConfig({ - packages: { '@x/helper-*': { sourcePaths: ['native/**'] } }, + packages: { '@x/helper-darwin': { sourcePaths: ['../../swift-src/**', '!../../swift-src/**/*.md'] } }, }); - const result = await findChangedPackages(['native/shared.c'], packages, rootDir, config); - expect(result.sort()).toEqual(['@x/helper-darwin', '@x/helper-linux']); + expect(await check(['packages/swift-src/README.md'], config)).toEqual(['swift-src']); + expect(await check(['packages/swift-src/main.swift'], config)).toEqual(['@x/helper-darwin', 'swift-src']); }); - test('./ in the root config is relative to the repo root', async () => { - const config = makeConfig({ - packages: { '@x/helper-darwin': { sourcePaths: ['./packages/swift-src/**'] } }, - }); - const result = await findChangedPackages(['packages/swift-src/main.swift'], packages, rootDir, config); - expect(result.sort()).toEqual(['@x/helper-darwin', 'swift-src']); + test('works with glob package keys (resolved per package)', async () => { + const config = makeConfig({ packages: { '@x/helper-*': { sourcePaths: ['../shared/**'] } } }); + expect(await check(['packages/helpers/shared/x.c'], config)).toEqual(['@x/helper-darwin', '@x/helper-linux']); }); - describe('declared in package.json', () => { - const tmpRoot = resolve(import.meta.dir, '../../.test-tmp-source-paths'); - const helperDir = resolve(tmpRoot, 'packages/helpers/darwin'); - const pkgs = new Map([ - ['helper', makePkg('helper', '1.0.0', { dir: helperDir })], - ['swift-src', makePkg('swift-src', '1.0.0', { dir: resolve(tmpRoot, 'packages/swift-src') })], - ]); - - beforeEach(async () => { - await mkdir(helperDir, { recursive: true }); - await writeFile( - resolve(helperDir, 'package.json'), - JSON.stringify({ - name: 'helper', - bumpy: { sourcePaths: ['../../swift-src/**', '!../../swift-src/**/*.md', 'shared/**'] }, - }), - ); - }); - afterEach(async () => { - await rm(tmpRoot, { recursive: true, force: true }); - }); - - test('./ and ../ patterns are relative to the package dir', async () => { - const changed = ['packages/swift-src/main.swift']; - expect((await findChangedPackages(changed, pkgs, tmpRoot, makeConfig())).sort()).toEqual([ - 'helper', - 'swift-src', - ]); - const docs = ['packages/swift-src/README.md']; - expect(await findChangedPackages(docs, pkgs, tmpRoot, makeConfig())).toEqual(['swift-src']); - }); - - test('plain patterns stay repo-root-relative', async () => { - expect(await findChangedPackages(['shared/x.c'], pkgs, tmpRoot, makeConfig())).toEqual(['helper']); - }); + test('works when declared in package.json', async () => { + await mkdir(helperDir, { recursive: true }); + await writeFile( + resolve(helperDir, 'package.json'), + JSON.stringify({ name: '@x/helper-darwin', bumpy: { sourcePaths: ['../../swift-src/**'] } }), + ); + expect(await check(['packages/swift-src/main.swift'])).toEqual(['@x/helper-darwin', 'swift-src']); }); test('changedFilePatterns does not filter sourcePaths matches', async () => { const config = makeConfig({ changedFilePatterns: ['src/**'], - packages: { '@x/helper-darwin': { sourcePaths: ['packages/swift-src/**'] } }, + packages: { '@x/helper-darwin': { sourcePaths: ['../../swift-src/**'] } }, }); - const result = await findChangedPackages(['packages/swift-src/Package.swift'], packages, rootDir, config); - expect(result).toEqual(['@x/helper-darwin']); + expect(await check(['packages/swift-src/Package.swift'], config)).toEqual(['@x/helper-darwin']); }); }); }); diff --git a/packages/bumpy/test/core/generate.test.ts b/packages/bumpy/test/core/generate.test.ts index 74f98c3..31437a0 100644 --- a/packages/bumpy/test/core/generate.test.ts +++ b/packages/bumpy/test/core/generate.test.ts @@ -25,6 +25,7 @@ describe('mapFilesToPackages', () => { }); test('attributes files matching sourcePaths to the package', () => { + // pkg.bumpy holds sourcePaths already resolved to repo-root-relative by loadPackageConfig const packages = new Map([ [ 'helper',