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
10 changes: 9 additions & 1 deletion packages/pnpm-policy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ maintainers:
scopes:
- "@acme"

# First-party package names to exempt directly, without a registry query or
# pinned inventory. Names and pnpm globs are allowed.
packages:
- pgpm
- makage
- "graphile-*"

# 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
Expand Down Expand Up @@ -102,6 +109,7 @@ 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. |
| `packages` | string[] | `[]` | First-party names or pnpm globs to exempt directly. |
| `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. |
Expand Down Expand Up @@ -229,7 +237,7 @@ Two rules govern how it compresses:

**Unscoped names are listed individually**, which is exact: they come from your own maintainer query.

**Intersection.** By default only names this workspace actually resolves (read from `pnpm-lock.yaml`) are written out — over 1100 published packages becomes the ~85 that appear in this repo. Scope globs are never intersected: nobody else can publish into a scope you own, so the glob stays correct when a new package lands there tomorrow. Pass `--no-intersect` to emit everything. A config with no `inventory:` has no individual names to narrow, so it needs no lockfile at all — which is what lets a freshly scaffolded workspace generate its policy before its first install.
**Intersection.** By default only names this workspace actually resolves (read from `pnpm-lock.yaml`) are written out — over 1100 published packages becomes the ~85 that appear in this repo. Scope globs are never intersected: nobody else can publish into a scope you own, so the glob stays correct when a new package lands there tomorrow. Pass `--no-intersect` to emit everything. Names listed under `packages:` are explicit claims and are never intersected away. A config with no `inventory:` has no inventory names to narrow, so it needs no lockfile at all — which is what lets a freshly scaffolded workspace generate its policy before its first install.

Commit the inventory and review its diffs. It is an exemption list, so a name appearing in it is a name that stops being quarantined — worth one human glance, which is also why refreshing it should open a pull request rather than run silently in an install hook.

Expand Down
10 changes: 10 additions & 0 deletions packages/pnpm-policy/__tests__/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ describe('generate', () => {
expect(readFileSync(generate({ cwd: dir }).file, 'utf-8')).toContain('"@acme/*"');
});

it('needs no inventory or lockfile when packages are claimed directly', () => {
const dir = workspace(
'minimumReleaseAge: 14d\nmaintainers:\n - me\npackages:\n - graphile-storage-registry\n'
);
rmSync(join(dir, 'pnpm-lock.yaml'));
expect(readFileSync(generate({ cwd: dir }).file, 'utf-8')).toContain(
'- graphile-storage-registry'
);
});

it('reports no change on a second run', () => {
const dir = workspace(CONFIG);
generate({ cwd: dir });
Expand Down
58 changes: 58 additions & 0 deletions packages/pnpm-policy/__tests__/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,52 @@ describe('resolvePolicy', () => {
expect(report.scopes).toContain('@launchql/*');
});

it('merges, sorts, and deduplicates directly claimed package names', () => {
const { report, settings } = resolve({
packages: [' zeta ', 'graphile-*', 'inquirerer', 'zeta']
});
expect(report.firstPartyPackages).toEqual([
'graphile-*',
'inquirerer',
'pgsql-parser',
'yanse',
'zeta'
]);
expect(settings.minimumReleaseAgeExclude).toEqual([
'@constructive-io/*',
'@pgsql/*',
'graphile-*',
'inquirerer',
'pgsql-parser',
'yanse',
'zeta'
]);
});

it('does not repeat a direct package covered by a scope glob', () => {
const { report } = resolve({ packages: ['@constructive-io/new', 'owned-package'] });
expect(report.firstPartyPackages).toEqual([
'owned-package',
'pgsql-parser',
'inquirerer',
'yanse'
].sort());
});

it('does not intersect directly claimed names away', () => {
const { report, settings } = resolve({ packages: ['owned-package'] }, ['yanse']);
expect(report.firstPartyPackages).toEqual(['owned-package', 'yanse']);
expect(report.omittedPackages).toEqual(['inquirerer', 'pgsql-parser']);
expect(settings.minimumReleaseAgeExclude).toContain('owned-package');
});

it('explains direct package claims in generated comments', () => {
const { comments } = resolve({ packages: ['owned-package'] });
expect(commentAt(comments.before, ['minimumReleaseAgeExclude'])).toContain(
'inventory and the names claimed in pnpm-policy.yaml'
);
});

it('does not list a package a scope glob already covers', () => {
const { report } = resolvePolicy({
config: normalizeConfig({}),
Expand Down Expand Up @@ -218,6 +264,18 @@ describe('normalizeConfig', () => {
]);
});

it('normalizes directly claimed packages', () => {
expect(normalizeConfig({
packages: [' zeta ', '', 'graphile-*', 'zeta', 'alpha']
}).packages).toEqual(['alpha', 'graphile-*', 'zeta']);
});

it('rejects a directly claimed package that is not a string', () => {
expect(() => normalizeConfig({ packages: ['valid', 42] as never })).toThrow(
/packages entry must be a string/
);
});

it('sorts allowBuilds so the generated file does not churn', () => {
const config = normalizeConfig({ allowBuilds: ['sharp', 'esbuild'] });
expect(config.allowBuilds.map((b) => b.package)).toEqual(['esbuild', 'sharp']);
Expand Down
4 changes: 4 additions & 0 deletions packages/pnpm-policy/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ maintainers: []
# packages you publish there tomorrow — only list scopes nobody else can publish to.
scopes: []

# First-party package names to exempt directly (pnpm globs are allowed).
# packages:
# - my-package

# Built by \`pnpm-policy inventory\`; commit it and review its diffs.
inventory: ./pnpm-policy.inventory.json

Expand Down
18 changes: 18 additions & 0 deletions packages/pnpm-policy/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,23 @@ function normalizeScopes(scopes: string[] | undefined): string[] {
.sort();
}

function normalizePackages(packages: string[] | undefined): string[] {
const entries = packages ?? [];
if (!Array.isArray(entries)) {
throw new PolicyError('packages must be a list of package names');
}
for (const entry of entries) {
if (typeof entry !== 'string') {
throw new PolicyError('A packages entry must be a string');
}
}
return [...new Set(
entries
.map((entry) => entry.trim())
.filter(Boolean)
)].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
Expand All @@ -137,6 +154,7 @@ export function normalizeConfig(config: PolicyConfig): ResolvedConfig {
blockExoticSubdeps: config.blockExoticSubdeps ?? false,
maintainers: config.maintainers ?? [],
scopes: normalizeScopes(config.scopes),
packages: normalizePackages(config.packages),
inventory: normalizeInventory(config.inventory),
intersect: config.intersect ?? true,
allowBuilds,
Expand Down
10 changes: 8 additions & 2 deletions packages/pnpm-policy/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,16 @@ function load(options: RunOptions): Loaded {
)
: undefined;

if (!inventory && config.maintainers.length && !config.scopes.length) {
if (
!inventory &&
config.maintainers.length &&
!config.scopes.length &&
!config.packages.length
) {
throw new PolicyError(
'maintainers are configured but no inventory is available. ' +
'Run `pnpm-policy inventory` to build one, or set `scopes` to skip the registry.'
'Run `pnpm-policy inventory` to build one, or set `scopes` or `packages` to claim ' +
'first-party names directly.'
);
}

Expand Down
20 changes: 13 additions & 7 deletions packages/pnpm-policy/src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,16 @@ function buildExclude(options: ResolveOptions): {
// it stays correct when a new package lands there tomorrow.
const scopes = [...new Set([...(inventory?.scopes ?? []), ...config.scopes])].sort();

const known = inventory?.packages ?? [];
const explicit = new Set(config.packages);
const known = [...new Set([...(inventory?.packages ?? []), ...config.packages])].sort();
const intersect = config.intersect && resolved != null;

const packages: string[] = [];
const omitted: string[] = [];
for (const name of known) {
// A name already covered by a scope glob would be noise in the file.
if (scopes.some((scope) => name.startsWith(`${scope}/`))) continue;
if (intersect && !resolved.has(name)) omitted.push(name);
if (intersect && !explicit.has(name) && !resolved.has(name)) omitted.push(name);
else packages.push(name);
}

Expand All @@ -80,7 +81,8 @@ function buildComments(
config: ResolvedConfig,
report: PolicyReport,
buildsKey: BuildsKey,
excludeCount: number
excludeCount: number,
inventory?: Inventory
): PolicyComments {
const before: Array<[CommentPath, string]> = [];
const inline: Array<[CommentPath, string]> = [];
Expand Down Expand Up @@ -110,9 +112,13 @@ function buildComments(
? `First-party membership comes from what ${config.maintainers.join(', ')} ${
config.maintainers.length === 1 ? 'publishes' : 'publish'
} on npm — waiting on your own release protects nothing.`
: report.firstPartyPackages.length
? 'First-party membership comes from the inventory.'
: 'First-party membership comes from the scopes claimed in pnpm-policy.yaml.'
: config.packages.length && inventory
? 'First-party membership comes from the inventory and the names claimed in pnpm-policy.yaml.'
: config.packages.length
? 'First-party membership comes from the names claimed in pnpm-policy.yaml.'
: report.firstPartyPackages.length
? 'First-party membership comes from the inventory.'
: 'First-party membership comes from the scopes claimed in pnpm-policy.yaml.'
].join('\n')
]);
}
Expand Down Expand Up @@ -216,7 +222,7 @@ export function resolvePolicy(options: ResolveOptions): ResolvedPolicy {

return {
settings,
comments: buildComments(config, report, buildsKey, exclude.length),
comments: buildComments(config, report, buildsKey, exclude.length, options.inventory),
report
};
}
Expand Down
9 changes: 9 additions & 0 deletions packages/pnpm-policy/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export interface PolicyConfig {
* own but publish to under a different account (CI tokens, org automation).
*/
scopes?: string[];
/**
* First-party package names to exempt from the wait, listed directly. Use
* for unscoped names (or names in a shared scope) you publish, when you'd
* rather list them than query the registry or pin an inventory. pnpm globs
* are allowed (`graphile-*`).
*/
packages?: string[];
/**
* Where the first-party inventory comes from: a path relative to this config,
* or an installed package that ships one.
Expand Down Expand Up @@ -79,6 +86,8 @@ export interface ResolvedConfig {
blockExoticSubdeps: boolean;
maintainers: string[];
scopes: string[];
/** Normalized first-party package names claimed directly by this config. */
packages: string[];
/** Normalized to a list; empty when no inventory is configured. */
inventory: string[];
intersect: boolean;
Expand Down
Loading