Skip to content

Commit 398638c

Browse files
authored
Merge pull request #118 from constructive-io/feat/origins-command
feat(pnpm-policy): origins — group dependencies by the repository they publish from
2 parents 5c3b6f5 + 190b784 commit 398638c

6 files changed

Lines changed: 520 additions & 0 deletions

File tree

packages/pnpm-policy/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,33 @@ That becomes pnpm's `allowBuilds` map (pnpm ≥ 10.16), with the reasons as inli
170170

171171
Unlike the release-age exemptions, this list is **not** derived from anything: a package that runs install scripts is a deliberate trust decision, whoever published it.
172172

173+
## Understanding what you depend on
174+
175+
Deciding what to exempt means deciding which *projects* you trust, but npm only offers accounts — and an account is as wide as everything its owner will ever publish. The person maintaining a library you want may also co-maintain something enormous you did not mean to exempt.
176+
177+
`origins` answers the question npm does not: group the packages a workspace resolves by the repository they publish from.
178+
179+
```bash
180+
pnpm-policy origins # every resolved package, grouped by repo owner
181+
pnpm-policy origins --from postgraphile # only the subtree that one dependency dragged in
182+
pnpm-policy origins --owner acme # just that owner's packages
183+
pnpm-policy origins --owner acme --out acme.inventory.json # written as an inventory
184+
```
185+
186+
```
187+
$ pnpm-policy origins --from postgraphile
188+
radix-ui (29)
189+
<no repository metadata> (20)
190+
graphile (15)
191+
graphql (8)
192+
```
193+
194+
`--from` reads the lockfile's dependency graph and walks it, so you see what a single decision actually pulled in rather than surveying everything at once. Transitive dependencies are included, because those are the ones an exemption list forgets.
195+
196+
`--owner ... --out ...` writes the result as an inventory, ready to pass to `inventory:`. It emits **names only** — no `maintainers`, no scope globs — because the point is a reviewed list, and a glob would re-widen it to whatever gets published into that scope next.
197+
198+
The repository field is self-reported, so this is a proxy for provenance, not proof of it. It answers "which project is this package from", not "is this package safe".
199+
173200
## The inventory
174201
175202
`pnpm-policy inventory` queries `registry.npmjs.org` for `maintainer:<account>`, paginates, and writes:
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { mkdtempSync, writeFileSync } from 'fs';
2+
import { tmpdir } from 'os';
3+
import { join } from 'path';
4+
5+
import {
6+
groupByOwner,
7+
namesFromOwners,
8+
packageOrigins,
9+
reachableFrom,
10+
readLockfileGraph,
11+
repositorySlug
12+
} from '../src';
13+
14+
describe('repositorySlug', () => {
15+
it('handles the shapes a repository field actually takes', () => {
16+
expect(repositorySlug('git+https://github.com/graphile/crystal.git')).toBe('graphile/crystal');
17+
expect(repositorySlug('https://github.com/graphile/crystal')).toBe('graphile/crystal');
18+
expect(repositorySlug('git@github.com:graphile/crystal.git')).toBe('graphile/crystal');
19+
expect(repositorySlug('ssh://git@github.com/graphile/crystal.git')).toBe('graphile/crystal');
20+
expect(repositorySlug('graphile/crystal')).toBe('graphile/crystal');
21+
});
22+
23+
it('lowercases, so owner comparisons are not case-sensitive', () => {
24+
expect(repositorySlug('https://github.com/GraphQL/graphql-js')).toBe('graphql/graphql-js');
25+
});
26+
27+
it('is undefined when there is nothing usable', () => {
28+
expect(repositorySlug(undefined)).toBeUndefined();
29+
expect(repositorySlug('not a url')).toBeUndefined();
30+
});
31+
});
32+
33+
const LOCKFILE = `lockfileVersion: '9.0'
34+
35+
importers:
36+
.:
37+
dependencies:
38+
postgraphile:
39+
specifier: ^5.0.0
40+
version: 5.0.0
41+
devDependencies:
42+
jest:
43+
specifier: ^30.0.0
44+
version: 30.0.0
45+
46+
snapshots:
47+
postgraphile@5.0.0:
48+
dependencies:
49+
grafast: 1.0.0
50+
graphql: 16.0.0
51+
grafast@1.0.0:
52+
dependencies:
53+
graphql: 16.0.0
54+
tamedevil: 1.0.0
55+
tamedevil@1.0.0: {}
56+
graphql@16.0.0: {}
57+
jest@30.0.0:
58+
dependencies:
59+
chalk: 5.0.0
60+
chalk@5.0.0: {}
61+
`;
62+
63+
function lockfile(): string {
64+
const dir = mkdtempSync(join(tmpdir(), 'pnpm-policy-graph-'));
65+
writeFileSync(join(dir, 'pnpm-lock.yaml'), LOCKFILE);
66+
return join(dir, 'pnpm-lock.yaml');
67+
}
68+
69+
describe('readLockfileGraph', () => {
70+
it('reads direct dependencies as roots, dev included', () => {
71+
const graph = readLockfileGraph(lockfile());
72+
expect([...graph.roots].sort()).toEqual(['jest', 'postgraphile']);
73+
});
74+
75+
it('maps each package to what it depends on', () => {
76+
const graph = readLockfileGraph(lockfile());
77+
expect([...(graph.edges.get('postgraphile') ?? [])].sort()).toEqual(['grafast', 'graphql']);
78+
});
79+
});
80+
81+
describe('reachableFrom', () => {
82+
it('returns the subtree under a dependency, not the whole lockfile', () => {
83+
const graph = readLockfileGraph(lockfile());
84+
const under = reachableFrom(graph, ['postgraphile']);
85+
expect([...under].sort()).toEqual(['grafast', 'graphql', 'postgraphile', 'tamedevil']);
86+
// jest is a root too, but nothing under postgraphile pulls it in.
87+
expect(under.has('jest')).toBe(false);
88+
expect(under.has('chalk')).toBe(false);
89+
});
90+
91+
it('terminates on a cycle', () => {
92+
const graph = {
93+
roots: new Set(['a']),
94+
edges: new Map([
95+
['a', new Set(['b'])],
96+
['b', new Set(['a'])]
97+
])
98+
};
99+
expect([...reachableFrom(graph, ['a'])].sort()).toEqual(['a', 'b']);
100+
});
101+
});
102+
103+
describe('packageOrigins', () => {
104+
const packuments: Record<string, unknown> = {
105+
grafast: { repository: { url: 'git+https://github.com/graphile/crystal.git' } },
106+
graphql: { repository: { url: 'git+https://github.com/graphql/graphql-js.git' } },
107+
// repository only on the latest version, as older publishes sometimes do
108+
ruru: {
109+
'dist-tags': { latest: '2.0.0' },
110+
versions: { '2.0.0': { repository: 'https://github.com/graphile/crystal' } }
111+
}
112+
};
113+
114+
const stub = (async (url: string) => {
115+
const name = decodeURIComponent(url.split('/').pop() as string);
116+
const body = packuments[name];
117+
return body
118+
? { ok: true, json: async () => body }
119+
: { ok: false, status: 404, json: async () => ({}) };
120+
}) as unknown as typeof fetch;
121+
122+
it('resolves owners, including a repository found only on the latest version', async () => {
123+
const origins = await packageOrigins(['grafast', 'graphql', 'ruru'], { fetchImpl: stub });
124+
expect(origins.map((o) => o.owner)).toEqual(['graphile', 'graphql', 'graphile']);
125+
});
126+
127+
it('reports an unknown package instead of aborting the survey', async () => {
128+
const origins = await packageOrigins(['grafast', 'nope'], { fetchImpl: stub });
129+
expect(origins).toHaveLength(2);
130+
expect(origins[1]).toEqual({ name: 'nope' });
131+
});
132+
133+
it('groups by owner and filters to the owners asked for', async () => {
134+
const origins = await packageOrigins(['grafast', 'graphql', 'ruru'], { fetchImpl: stub });
135+
expect(groupByOwner(origins).get('graphile')).toEqual(['grafast', 'ruru']);
136+
// The point of the whole exercise: graphql is a different project.
137+
expect(namesFromOwners(origins, ['graphile'])).toEqual(['grafast', 'ruru']);
138+
expect(namesFromOwners(origins, ['graphile'])).not.toContain('graphql');
139+
});
140+
});

packages/pnpm-policy/src/cli.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { findConfig, loadConfig } from './config';
66
import { formatDuration } from './duration';
77
import { PolicyError } from './errors';
88
import { check, generate } from './generate';
9+
import { readWorkspaceGraph, reachableFrom } from './graph';
910
import { buildInventory, writeInventory } from './inventory';
11+
import { readWorkspacePackages } from './lockfile';
12+
import { groupByOwner, namesFromOwners, packageOrigins } from './origins';
1013
import type { BuildsKey } from './policy';
1114

1215
const USAGE = `pnpm-policy — pnpm supply-chain policy for npm maintainers
@@ -19,6 +22,7 @@ Commands:
1922
inventory Query npm for what your maintainers publish, and write the export
2023
generate Patch the policy into pnpm-workspace.yaml
2124
check Fail if the workspace file drifted or a waiver expired
25+
origins Group this workspace's dependencies by the repository they publish from
2226
2327
Options:
2428
--cwd <dir> Workspace root (default: current directory)
@@ -28,6 +32,8 @@ Options:
2832
--no-intersect Emit every first-party name, not just the ones this workspace resolves
2933
--verify-scopes inventory: also glob a scope the registry shows nobody else publishing
3034
into (best-effort — npm's search index is incomplete)
35+
--owner <name> origins: keep only packages published from this repo owner (repeatable)
36+
--from <pkg> origins: limit to the subtree under this dependency (repeatable)
3137
--registry <url> inventory: registry to query (default: https://registry.npmjs.org)
3238
--throttle <ms> inventory: pause between registry requests (default: 1000)
3339
--json Print machine-readable output
@@ -83,6 +89,8 @@ interface Parsed {
8389
verifyScopes: boolean;
8490
registry?: string;
8591
throttle?: number;
92+
owners: string[];
93+
from: string[];
8694
json: boolean;
8795
quiet: boolean;
8896
help: boolean;
@@ -91,6 +99,8 @@ interface Parsed {
9199

92100
export function parseArgs(argv: string[]): Parsed {
93101
const parsed: Parsed = {
102+
owners: [],
103+
from: [],
94104
verifyScopes: false,
95105
json: false,
96106
quiet: false,
@@ -146,6 +156,12 @@ export function parseArgs(argv: string[]): Parsed {
146156
case '--verify-scopes':
147157
parsed.verifyScopes = true;
148158
break;
159+
case '--owner':
160+
parsed.owners.push(next());
161+
break;
162+
case '--from':
163+
parsed.from.push(next());
164+
break;
149165
case '--registry':
150166
parsed.registry = next();
151167
break;
@@ -197,6 +213,88 @@ function runInit(parsed: Parsed): number {
197213
return 0;
198214
}
199215

216+
/**
217+
* Group a workspace's dependencies by the repository they publish from.
218+
*
219+
* The question this answers is "which projects am I actually depending on",
220+
* which is the one worth asking before deciding what to exempt from a release-age
221+
* quarantine. Grouping by repository rather than by npm account matters: an
222+
* account is as wide as everything its owner will ever publish, and the owner of
223+
* a library you want may also co-maintain something far larger.
224+
*
225+
* With --from, only the subtree under those dependencies is considered, so you
226+
* can ask what one decision dragged in rather than surveying the whole lockfile.
227+
* With --owner, the output narrows to those owners and can be written straight
228+
* out as an inventory.
229+
*/
230+
async function runOrigins(parsed: Parsed): Promise<number> {
231+
const workspaceDir = parsed.cwd ?? process.cwd();
232+
233+
let names: Set<string>;
234+
if (parsed.from.length) {
235+
const graph = readWorkspaceGraph(workspaceDir);
236+
const missing = parsed.from.filter((name) => !graph.edges.has(name) && !graph.roots.has(name));
237+
if (missing.length) {
238+
console.error(`Not in this lockfile: ${missing.join(', ')}`);
239+
return 1;
240+
}
241+
names = reachableFrom(graph, parsed.from);
242+
} else {
243+
names = readWorkspacePackages(workspaceDir);
244+
}
245+
246+
if (!parsed.quiet) {
247+
const scope = parsed.from.length ? `under ${parsed.from.join(', ')}` : 'in this workspace';
248+
console.error(`Resolving repositories for ${names.size} package(s) ${scope}...`);
249+
}
250+
251+
const origins = await packageOrigins(names, {
252+
registry: parsed.registry,
253+
throttleMs: parsed.throttle,
254+
onPackage: parsed.quiet
255+
? undefined
256+
: (name, index, total) => {
257+
if (index % 25 === 0) console.error(` ${index}/${total}`);
258+
}
259+
});
260+
261+
if (parsed.owners.length) {
262+
const matched = namesFromOwners(origins, parsed.owners);
263+
264+
if (parsed.out) {
265+
// Deliberately no maintainers and no scopes: a list derived this way is a
266+
// reviewed set of names, and a scope glob would re-widen it to whatever
267+
// gets published into that scope next.
268+
writeInventory(resolve(parsed.out), {
269+
generatedAt: new Date().toISOString(),
270+
maintainers: [],
271+
scopes: [],
272+
packages: matched
273+
});
274+
if (!parsed.quiet) {
275+
console.error(`Wrote ${matched.length} package(s) to ${parsed.out}`);
276+
}
277+
return 0;
278+
}
279+
280+
console.log(parsed.json ? JSON.stringify(matched, null, 2) : matched.join('\n'));
281+
return 0;
282+
}
283+
284+
const grouped = [...groupByOwner(origins)].sort((a, b) => b[1].length - a[1].length);
285+
286+
if (parsed.json) {
287+
console.log(JSON.stringify(Object.fromEntries(grouped), null, 2));
288+
return 0;
289+
}
290+
291+
for (const [owner, packages] of grouped) {
292+
console.log(`${owner || '<no repository metadata>'} (${packages.length})`);
293+
for (const name of packages) console.log(` ${name}`);
294+
}
295+
return 0;
296+
}
297+
200298
async function runInventory(parsed: Parsed): Promise<number> {
201299
const { file: configFile, config } = loadConfig(
202300
parsed.config ?? parsed.cwd ?? process.cwd()
@@ -345,6 +443,8 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<numbe
345443
return runGenerate(parsed);
346444
case 'check':
347445
return runCheck(parsed);
446+
case 'origins':
447+
return await runOrigins(parsed);
348448
default:
349449
console.error(`Unknown command: ${parsed.command}`);
350450
console.error(`\n${USAGE}`);

0 commit comments

Comments
 (0)