Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/pnpm-policy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions packages/pnpm-policy/__tests__/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
61 changes: 60 additions & 1 deletion packages/pnpm-policy/__tests__/inventory.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>): typeof fetch {
Expand Down Expand Up @@ -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);
});
});
15 changes: 13 additions & 2 deletions packages/pnpm-policy/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,21 @@ async function runInventory(parsed: Parsed): Promise<number> {
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 <path> 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'))
);

Expand Down
12 changes: 11 additions & 1 deletion packages/pnpm-policy/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
Expand Down
15 changes: 10 additions & 5 deletions packages/pnpm-policy/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);

Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/pnpm-policy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
groupByScope,
inScope,
inventoryMatches,
mergeInventories,
readInventory,
writeInventory
} from './inventory';
Expand Down
35 changes: 35 additions & 0 deletions packages/pnpm-policy/src/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
15 changes: 12 additions & 3 deletions packages/pnpm-policy/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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[];
Expand Down
Loading