From 57999b12e85467b6601a70f400feb8031f6ff0d5 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 17 Sep 2026 05:05:01 +0000 Subject: [PATCH] feat(pnpm-policy): list first-party packages inline with `packages` --- packages/pnpm-policy/README.md | 10 +++- .../pnpm-policy/__tests__/generate.test.ts | 10 ++++ packages/pnpm-policy/__tests__/policy.test.ts | 58 +++++++++++++++++++ packages/pnpm-policy/src/cli.ts | 4 ++ packages/pnpm-policy/src/config.ts | 18 ++++++ packages/pnpm-policy/src/generate.ts | 10 +++- packages/pnpm-policy/src/policy.ts | 20 ++++--- packages/pnpm-policy/src/types.ts | 9 +++ 8 files changed, 129 insertions(+), 10 deletions(-) diff --git a/packages/pnpm-policy/README.md b/packages/pnpm-policy/README.md index 3f5526e..9e03160 100644 --- a/packages/pnpm-policy/README.md +++ b/packages/pnpm-policy/README.md @@ -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 @@ -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. | @@ -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. diff --git a/packages/pnpm-policy/__tests__/generate.test.ts b/packages/pnpm-policy/__tests__/generate.test.ts index c9c4f2d..f97bf36 100644 --- a/packages/pnpm-policy/__tests__/generate.test.ts +++ b/packages/pnpm-policy/__tests__/generate.test.ts @@ -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 }); diff --git a/packages/pnpm-policy/__tests__/policy.test.ts b/packages/pnpm-policy/__tests__/policy.test.ts index c95f584..1604bd3 100644 --- a/packages/pnpm-policy/__tests__/policy.test.ts +++ b/packages/pnpm-policy/__tests__/policy.test.ts @@ -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({}), @@ -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']); diff --git a/packages/pnpm-policy/src/cli.ts b/packages/pnpm-policy/src/cli.ts index 17710af..c1ff325 100644 --- a/packages/pnpm-policy/src/cli.ts +++ b/packages/pnpm-policy/src/cli.ts @@ -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 diff --git a/packages/pnpm-policy/src/config.ts b/packages/pnpm-policy/src/config.ts index b025123..ee0e45e 100644 --- a/packages/pnpm-policy/src/config.ts +++ b/packages/pnpm-policy/src/config.ts @@ -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 @@ -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, diff --git a/packages/pnpm-policy/src/generate.ts b/packages/pnpm-policy/src/generate.ts index 136dee2..e875211 100644 --- a/packages/pnpm-policy/src/generate.ts +++ b/packages/pnpm-policy/src/generate.ts @@ -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.' ); } diff --git a/packages/pnpm-policy/src/policy.ts b/packages/pnpm-policy/src/policy.ts index 0061c7f..56f30e0 100644 --- a/packages/pnpm-policy/src/policy.ts +++ b/packages/pnpm-policy/src/policy.ts @@ -52,7 +52,8 @@ 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[] = []; @@ -60,7 +61,7 @@ function buildExclude(options: ResolveOptions): { 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); } @@ -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]> = []; @@ -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') ]); } @@ -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 }; } diff --git a/packages/pnpm-policy/src/types.ts b/packages/pnpm-policy/src/types.ts index af5983d..74116fd 100644 --- a/packages/pnpm-policy/src/types.ts +++ b/packages/pnpm-policy/src/types.ts @@ -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. @@ -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;