diff --git a/.bumpy/package-source-paths.md b/.bumpy/package-source-paths.md new file mode 100644 index 0000000..a80fc27 --- /dev/null +++ b/.bumpy/package-source-paths.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +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 6543fd0..91e0984 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. @@ -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 | @@ -187,11 +189,32 @@ 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[]` | 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`. 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": { + // @mycli/native-darwin lives in packages/native/darwin + "sourcePaths": ["../../native-src-swift/**", "!../../native-src-swift/**/*.md"], + }, + }, +} +``` + +- 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). + ### `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..03af2af 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 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": { "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..974b23a 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 pkgConfig = await loadPackageConfig(pkg.dir, config, name, rootDir); 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..993c6bd 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,24 @@ 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(); + // `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)]), + ); 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..458bc47 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 { 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'; @@ -42,6 +43,7 @@ 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); @@ -74,7 +76,23 @@ export async function loadPackageConfig( } // Merge: root packages map → package.json["bumpy"] (later wins) - return mergePackageConfig(rootPkgConfig, pkgJsonConfig); + return withRootRelativeSourcePaths(mergePackageConfig(rootPkgConfig, pkgJsonConfig), pkgDir, rootDir); +} + +/** + * `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, 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; + const rootRelative = relative(rootDir, resolve(pkgDir, body)).split(sep).join('/'); + return (negated ? '!' : '') + rootRelative; + }); + return { ...cfg, sourcePaths }; } /** Find a package config from the root config, supporting glob patterns */ @@ -97,6 +115,21 @@ export function resolveFixedGroups(config: BumpyConfig, packageNames: Iterable names.filter((name) => group.some((pattern) => matchGlob(name, pattern)))); } +/** + * Build a matcher for a package's resolved `sourcePaths` (repo-root-relative globs, see + * `loadPackageConfig`). 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/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 09834f8..5b7eb83 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -219,6 +219,15 @@ 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 for sources that live outside this package's directory + * (e.g. a binary built from a sibling package). Like every per-package glob, they are + * relative to the **package dir** wherever they're declared — use `../` to reach out + * (e.g. `"../encryption-binary-swift/**"`). 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 with `!` to exclude. + */ + 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..708b03a 100644 --- a/packages/bumpy/test/core/check.test.ts +++ b/packages/bumpy/test/core/check.test.ts @@ -132,6 +132,68 @@ describe('findChangedPackages', () => { const result = await findChangedPackages(changed, packages, rootDir, makeConfig()); expect(result).toEqual(['pkg-a']); }); + + 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: 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: ['../../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: ['../../swift-src/**'] } } }); + expect(await check(['packages/other/index.ts'], config)).toEqual([]); + }); + + 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('negated patterns exclude matches', async () => { + const config = makeConfig({ + packages: { '@x/helper-darwin': { sourcePaths: ['../../swift-src/**', '!../../swift-src/**/*.md'] } }, + }); + 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('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']); + }); + + 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: ['../../swift-src/**'] } }, + }); + expect(await check(['packages/swift-src/Package.swift'], config)).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..31437a0 100644 --- a/packages/bumpy/test/core/generate.test.ts +++ b/packages/bumpy/test/core/generate.test.ts @@ -23,6 +23,25 @@ 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', () => { + // pkg.bumpy holds sourcePaths already resolved to repo-root-relative by loadPackageConfig + 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', () => {