diff --git a/packages/pnpm-policy/README.md b/packages/pnpm-policy/README.md index 441bf06..db4d1d1 100644 --- a/packages/pnpm-policy/README.md +++ b/packages/pnpm-policy/README.md @@ -75,6 +75,7 @@ scopes: - "@acme" # Written by `pnpm-policy inventory`. Commit it; review its diffs. +# May also name an installed package that ships one, or a list of either. inventory: ./pnpm-policy.inventory.json # Dependencies allowed to run install scripts. The value is the reason. @@ -101,12 +102,28 @@ settings: | `blockExoticSubdeps` | boolean | `false` | Refuse transitive deps from git/URL sources. | | `maintainers` | string[] | `[]` | **Your own** npm accounts. See the warning below. | | `scopes` | string[] | `[]` | Scopes you own, emitted as globs. | -| `inventory` | path or package | – | Where the generated inventory lives. | +| `inventory` | path, package, or list | – | Where the inventory comes from. A list is merged. | | `intersect` | boolean | `true` | Only emit names this workspace actually resolves. | | `allowBuilds` | map or list | `{}` | Dependencies permitted to run install scripts. | | `exceptions` | list | `[]` | Third-party bypasses, each with a reason. | | `settings` | map | `{}` | Extra pnpm settings to include in the managed block. | +### `inventory` can name more than one source + +`inventory` takes a path, an installed package that ships one, or a list of either. A list is merged into a single inventory before the policy is resolved. + +```yaml +inventory: + - "@acme/pnpm-policy" # your accounts, published and pinned + - "@acme/pnpm-policy-upstream" # an upstream you have chosen to trust +``` + +This exists so inventories that are deliberately kept apart can stay apart. Trusting an upstream account is a decision one workspace may have made and others have not, and folding that account into the inventory everyone installs would extend the exemption to every workspace by default. Keeping them as separate published packages lets each workspace opt in by listing what it actually trusts — instead of checking a flattened copy of both into the repo, where it goes stale and has to be reviewed by hand. + +Merging is a union: an inventory only ever says what is *exempt*, so combining two can widen the set and never narrow it. `generatedAt` reports the **oldest** of the inputs, because the merged view is only as fresh as its stalest source. + +`pnpm-policy inventory` writes one file. With several configured there is no single default to overwrite, so it asks for `--out`. + ### `maintainers` is your own identity, not a trust list Every package published by a listed account bypasses the release-age quarantine. That is the point — waiting on your own release protects nothing — but it means the entry is a delegation of trust as wide as the account itself. diff --git a/packages/pnpm-policy/__tests__/generate.test.ts b/packages/pnpm-policy/__tests__/generate.test.ts index c62af4a..c9c4f2d 100644 --- a/packages/pnpm-policy/__tests__/generate.test.ts +++ b/packages/pnpm-policy/__tests__/generate.test.ts @@ -175,3 +175,62 @@ describe('config errors', () => { expect(() => generate({ cwd: dir })).toThrow(/no inventory is available/); }); }); + +describe('multiple inventories', () => { + // The point of the list: a workspace consumes two separately-published + // inventories — its own accounts, and an upstream it has chosen to trust — + // without flattening them into one copy checked in beside the config. + function twoInventoryWorkspace(): string { + const dir = mkdtempSync(join(tmpdir(), 'pnpm-policy-')); + writeFileSync(join(dir, 'pnpm-lock.yaml'), LOCKFILE); + writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n'); + writeFileSync(join(dir, 'ours.json'), JSON.stringify(INVENTORY, null, 2)); + writeFileSync( + join(dir, 'upstream.json'), + JSON.stringify( + { + generatedAt: '2026-01-01T00:00:00.000Z', + maintainers: ['upstream'], + scopes: ['@upstream'], + packages: ['grafast'] + }, + null, + 2 + ) + ); + writeFileSync( + join(dir, 'pnpm-policy.yaml'), + `minimumReleaseAge: 14d +maintainers: + - me +inventory: + - ./ours.json + - ./upstream.json +` + ); + return dir; + } + + it('exempts names from every listed inventory', () => { + const dir = twoInventoryWorkspace(); + const written = readFileSync(generate({ cwd: dir, intersect: false }).file, 'utf-8'); + expect(written).toContain('- yanse'); // from ours.json + expect(written).toContain('- grafast'); // from upstream.json + expect(written).toContain('- "@acme/*"'); + expect(written).toContain('- "@upstream/*"'); + }); + + it('still intersects a merged inventory against the lockfile', () => { + const dir = twoInventoryWorkspace(); + const result = generate({ cwd: dir }); + const written = readFileSync(result.file, 'utf-8'); + // Present in a listed inventory, absent from this lockfile. + expect(written).not.toContain('grafast'); + expect(result.report.omittedPackages).toContain('grafast'); + }); + + it('accepts a single string, so existing configs keep working', () => { + const dir = workspace(CONFIG); + expect(readFileSync(generate({ cwd: dir }).file, 'utf-8')).toContain('- yanse'); + }); +}); diff --git a/packages/pnpm-policy/__tests__/inventory.test.ts b/packages/pnpm-policy/__tests__/inventory.test.ts index 14d95a0..dd81b1c 100644 --- a/packages/pnpm-policy/__tests__/inventory.test.ts +++ b/packages/pnpm-policy/__tests__/inventory.test.ts @@ -1,5 +1,5 @@ import type { Inventory } from '../src'; -import { buildInventory, groupByScope, inventoryMatches, packagesByMaintainer } from '../src'; +import { buildInventory, groupByScope, inventoryMatches, mergeInventories, packagesByMaintainer } from '../src'; /** A registry stub: query string in, package names out. */ function stubFetch(pages: Record): typeof fetch { @@ -181,3 +181,62 @@ describe('inventoryMatches', () => { expect(inventoryMatches(inventory, 'react')).toBe(false); }); }); + +describe('mergeInventories', () => { + const ours: Inventory = { + generatedAt: '2026-02-01T00:00:00.000Z', + maintainers: ['me'], + scopes: ['@acme'], + packages: ['yanse', 'shared-name'] + }; + const upstream: Inventory = { + generatedAt: '2026-01-01T00:00:00.000Z', + maintainers: ['them'], + scopes: ['@upstream'], + packages: ['grafast', 'shared-name'] + }; + + it('unions scopes, packages and maintainers', () => { + const merged = mergeInventories([ours, upstream]); + expect(merged.scopes).toEqual(['@acme', '@upstream']); + expect(merged.packages).toEqual(['grafast', 'shared-name', 'yanse']); + expect(merged.maintainers).toEqual(['me', 'them']); + }); + + it('deduplicates a name both inventories claim', () => { + const merged = mergeInventories([ours, upstream]); + expect(merged.packages.filter((name) => name === 'shared-name')).toHaveLength(1); + }); + + // The merged view is only as fresh as its stalest input; reporting the newest + // would overstate how current the exemption list is. + it('reports the oldest generatedAt', () => { + expect(mergeInventories([ours, upstream]).generatedAt).toBe('2026-01-01T00:00:00.000Z'); + expect(mergeInventories([upstream, ours]).generatedAt).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('returns a single inventory untouched', () => { + expect(mergeInventories([ours])).toBe(ours); + }); + + it('refuses an empty list rather than inventing an empty inventory', () => { + expect(() => mergeInventories([])).toThrow(/empty list/); + }); + + it('keeps sharedScopes when any input has them, and omits the key otherwise', () => { + expect(mergeInventories([ours, upstream]).sharedScopes).toBeUndefined(); + const merged = mergeInventories([ + ours, + { ...upstream, sharedScopes: ['@mixed'] } + ]); + expect(merged.sharedScopes).toEqual(['@mixed']); + }); + + it('matches names contributed by either inventory', () => { + const merged = mergeInventories([ours, upstream]); + expect(inventoryMatches(merged, 'grafast')).toBe(true); + expect(inventoryMatches(merged, '@acme/widget')).toBe(true); + expect(inventoryMatches(merged, '@upstream/thing')).toBe(true); + expect(inventoryMatches(merged, 'lodash')).toBe(false); + }); +}); diff --git a/packages/pnpm-policy/src/cli.ts b/packages/pnpm-policy/src/cli.ts index e50334c..1ec84b8 100644 --- a/packages/pnpm-policy/src/cli.ts +++ b/packages/pnpm-policy/src/cli.ts @@ -209,10 +209,21 @@ async function runInventory(parsed: Parsed): Promise { return 1; } + // With several inventories configured there is no single file this command + // owns — the others are published elsewhere — so writing requires --out rather + // than guessing which one to overwrite. + if (!parsed.out && config.inventory.length > 1) { + console.error( + `${configFile} configures ${config.inventory.length} inventories, so there is no ` + + 'single default to write. Pass --out to choose one.' + ); + return 1; + } + const out = resolve( parsed.out ?? - (config.inventory - ? join(resolve(configFile, '..'), config.inventory) + (config.inventory[0] + ? join(resolve(configFile, '..'), config.inventory[0]) : join(resolve(configFile, '..'), 'pnpm-policy.inventory.json')) ); diff --git a/packages/pnpm-policy/src/config.ts b/packages/pnpm-policy/src/config.ts index 3068584..59e7588 100644 --- a/packages/pnpm-policy/src/config.ts +++ b/packages/pnpm-policy/src/config.ts @@ -95,6 +95,16 @@ function normalizeScopes(scopes: string[] | undefined): string[] { .sort(); } +/** + * Accept one inventory reference or several. Several are merged at load time, so + * a workspace can combine separately-published inventories rather than keeping a + * flattened copy of them checked in. + */ +function normalizeInventory(value: string | string[] | undefined): string[] { + if (value === undefined) return []; + return (Array.isArray(value) ? value : [value]).filter((entry) => entry.length > 0); +} + /** Apply defaults and convert a config into the shape the resolver consumes. */ export function normalizeConfig(config: PolicyConfig): ResolvedConfig { return { @@ -104,7 +114,7 @@ export function normalizeConfig(config: PolicyConfig): ResolvedConfig { blockExoticSubdeps: config.blockExoticSubdeps ?? false, maintainers: config.maintainers ?? [], scopes: normalizeScopes(config.scopes), - inventory: config.inventory, + inventory: normalizeInventory(config.inventory), intersect: config.intersect ?? true, allowBuilds: normalizeAllowBuilds(config.allowBuilds), exceptions: normalizeExceptions(config.exceptions), diff --git a/packages/pnpm-policy/src/generate.ts b/packages/pnpm-policy/src/generate.ts index 524e774..ba19dec 100644 --- a/packages/pnpm-policy/src/generate.ts +++ b/packages/pnpm-policy/src/generate.ts @@ -8,7 +8,7 @@ import { dirname, join } from 'path'; import { loadConfig, resolveFromConfig } from './config'; import { PolicyError } from './errors'; -import { readInventory } from './inventory'; +import { mergeInventories, readInventory } from './inventory'; import { readWorkspacePackages } from './lockfile'; import type { BuildsKey } from './policy'; import { resolvePolicy } from './policy'; @@ -34,11 +34,11 @@ interface Loaded { } /** - * Locate the inventory: a path relative to the config, or a package that ships + * Locate one inventory: a path relative to the config, or a package that ships * one (`@constructive-io/pnpm-policy`), which is how a fleet of workspaces * shares a single reviewed export instead of each keeping its own copy. */ -function loadInventory(configFile: string, reference: string): Inventory { +function loadOneInventory(configFile: string, reference: string): Inventory { const asPath = resolveFromConfig(configFile, reference); if (existsSync(asPath)) return readInventory(asPath); @@ -62,8 +62,13 @@ function load(options: RunOptions): Loaded { const workspaceDir = options.cwd ?? dirname(configFile); const intersect = options.intersect ?? config.intersect; - const inventory = config.inventory - ? loadInventory(configFile, config.inventory) + // Several references merge into one inventory, so a workspace can consume two + // separately-published exports — its own accounts and an upstream it has chosen + // to trust — without flattening them into a copy checked in beside the config. + const inventory = config.inventory.length + ? mergeInventories( + config.inventory.map((reference) => loadOneInventory(configFile, reference)) + ) : undefined; if (!inventory && config.maintainers.length && !config.scopes.length) { diff --git a/packages/pnpm-policy/src/index.ts b/packages/pnpm-policy/src/index.ts index 9aa6c70..37d6448 100644 --- a/packages/pnpm-policy/src/index.ts +++ b/packages/pnpm-policy/src/index.ts @@ -17,6 +17,7 @@ export { groupByScope, inScope, inventoryMatches, + mergeInventories, readInventory, writeInventory } from './inventory'; diff --git a/packages/pnpm-policy/src/inventory.ts b/packages/pnpm-policy/src/inventory.ts index 218c697..e6af31c 100644 --- a/packages/pnpm-policy/src/inventory.ts +++ b/packages/pnpm-policy/src/inventory.ts @@ -130,6 +130,41 @@ export function inventoryMatches(inventory: Inventory, name: string): boolean { return inScope(name, inventory.scopes) || inventory.packages.includes(name); } +/** + * Combine inventories into one. + * + * Every list is a union: an inventory says what is exempt, so merging can only + * widen the set, never narrow it. `generatedAt` takes the OLDEST timestamp of + * the inputs — the merged view is only as fresh as its stalest source, and + * reporting the newest would overstate it. + * + * A scope that one inventory owns outright and another sees as shared stays in + * `sharedScopes` as well, so the caller can still tell it is not exclusively + * ours; `scopes` and `sharedScopes` are not treated as mutually exclusive here. + */ +export function mergeInventories(inventories: Inventory[]): Inventory { + if (inventories.length === 0) { + throw new PolicyError('Cannot merge an empty list of inventories'); + } + if (inventories.length === 1) return inventories[0]; + + const union = (pick: (inventory: Inventory) => string[] | undefined): string[] => + [...new Set(inventories.flatMap((inventory) => pick(inventory) ?? []))].sort(); + + const sharedScopes = union((inventory) => inventory.sharedScopes); + + return { + generatedAt: inventories + .map((inventory) => inventory.generatedAt) + .filter(Boolean) + .sort()[0] ?? '', + maintainers: union((inventory) => inventory.maintainers), + scopes: union((inventory) => inventory.scopes), + packages: union((inventory) => inventory.packages), + ...(sharedScopes.length ? { sharedScopes } : {}) + }; +} + export function readInventory(file: string): Inventory { const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Inventory; if (!Array.isArray(parsed.scopes) || !Array.isArray(parsed.packages)) { diff --git a/packages/pnpm-policy/src/types.ts b/packages/pnpm-policy/src/types.ts index 054a28f..0359c7a 100644 --- a/packages/pnpm-policy/src/types.ts +++ b/packages/pnpm-policy/src/types.ts @@ -38,8 +38,16 @@ export interface PolicyConfig { * own but publish to under a different account (CI tokens, org automation). */ scopes?: string[]; - /** Path to the committed inventory export, or an installed package that ships one. */ - inventory?: string; + /** + * Where the first-party inventory comes from: a path relative to this config, + * or an installed package that ships one. + * + * A list is merged into a single inventory, which is how a workspace combines + * inventories that are deliberately kept apart — your own accounts in one + * published package, an upstream you have chosen to trust in another — without + * either being flattened into a copy checked in beside the config. + */ + inventory?: string | string[]; /** * Emit only the first-party names this workspace actually resolves. * Scopes are always emitted as globs — see the README. @@ -59,7 +67,8 @@ export interface ResolvedConfig { blockExoticSubdeps: boolean; maintainers: string[]; scopes: string[]; - inventory?: string; + /** Normalized to a list; empty when no inventory is configured. */ + inventory: string[]; intersect: boolean; allowBuilds: AllowedBuild[]; exceptions: PolicyException[];