Skip to content

Commit be0c97e

Browse files
ejntaylorclaude
andcommitted
Add yarn.lock parser (yarn classic v1 and berry v2+)
Yarn is the last of the four major Node package managers we didn't yet read. This adds a zero-dep parser that handles both yarn classic (`version "x"`) and yarn berry (`version: x`, `__metadata` header, `npm:` descriptors) by walking the shared block structure and only branching on the value syntax. Yarn's lockfile, unlike pnpm v9's, doesn't record an importer manifest, so direct-vs-transitive marking is cross-referenced against the sibling `package.json`. Absent or unreadable package.json leaves all entries unmarked rather than failing the scan. Detection priority is npm > pnpm > yarn > bun, so a yarn migration in progress with multiple lockfiles still resolves deterministically and matches what the package manager itself would prefer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ad17215 commit be0c97e

8 files changed

Lines changed: 628 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,9 @@ That's the entire payload. No source code, no environment variables, no file pat
117117

118118
-`package-lock.json` (npm v6 / v2 / v3) — parsed directly
119119
-`pnpm-lock.yaml` (pnpm v5 / v6 / v7 / v8 / v9) — parsed directly
120+
-`yarn.lock` (yarn classic v1 and yarn berry v2+) — parsed directly
120121
-`bun.lockb` (binary) — package list resolved by walking `node_modules/`
121122
-`bun.lock` (text) — same fallback; direct parsing coming
122-
-`yarn.lock` — coming soon
123123

124124
If both a Bun lockfile and `node_modules/` are present, the connector walks `node_modules/` to enumerate the installed packages. Run `bun install` (or `npm install`) before scanning so the directory is populated.
125125

src/parsers/index.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { PatchstackError, type Manifest, type PackageEntry } from '../types.js';
44
import { parseNpmLockfile } from './npm.js';
55
import { walkNodeModules } from './node_modules.js';
66
import { parsePnpmLockfile } from './pnpm.js';
7+
import { parseYarnLockfile } from './yarn.js';
78

89
type LockfileFilename =
910
| 'package-lock.json'
@@ -12,7 +13,11 @@ type LockfileFilename =
1213
| 'yarn.lock'
1314
| 'pnpm-lock.yaml';
1415

15-
type DetectionStrategy = 'npm-lockfile' | 'node-modules-walk' | 'pnpm-lockfile';
16+
type DetectionStrategy =
17+
| 'npm-lockfile'
18+
| 'node-modules-walk'
19+
| 'pnpm-lockfile'
20+
| 'yarn-lockfile';
1621

1722
interface DetectedLockfile {
1823
ecosystem: 'npm';
@@ -64,10 +69,12 @@ export async function detectLockfile(cwd: string): Promise<DetectedLockfile> {
6469

6570
const yarnLock = path.join(cwd, 'yarn.lock');
6671
if (await exists(yarnLock)) {
67-
throw new PatchstackError(
68-
'yarn.lock detected but not yet supported. Run `npm install` to generate a package-lock.json, or open an issue at github.com/patchstack/connect.',
69-
'LOCKFILE_UNSUPPORTED',
70-
);
72+
return {
73+
ecosystem: 'npm',
74+
filePath: yarnLock,
75+
filename: 'yarn.lock',
76+
strategy: 'yarn-lockfile',
77+
};
7178
}
7279

7380
throw new PatchstackError(
@@ -91,6 +98,8 @@ async function runStrategy(
9198
return parseNpmLockfile(detected.filePath);
9299
case 'pnpm-lockfile':
93100
return parsePnpmLockfile(detected.filePath);
101+
case 'yarn-lockfile':
102+
return parseYarnLockfile(detected.filePath);
94103
case 'node-modules-walk':
95104
return walkNodeModules(cwd);
96105
}

src/parsers/yarn.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { readFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import { PatchstackError, type PackageEntry } from '../types.js';
4+
5+
/**
6+
* Parses yarn.lock (yarn classic v1 and yarn berry v2+) without a YAML
7+
* dependency. Both generations share the same block structure — a top-level
8+
* mapping of comma-separated descriptor lists to a block containing a
9+
* `version` field — so we walk them with the same scanner and only branch on
10+
* the `version` syntax (`version "x"` for v1, `version: x` for berry).
11+
*
12+
* Direct vs transitive can't be derived from yarn.lock alone (yarn does not
13+
* record an importer manifest the way pnpm v9 does), so we cross-reference
14+
* the sibling `package.json` when present.
15+
*/
16+
export async function parseYarnLockfile(lockfilePath: string): Promise<PackageEntry[]> {
17+
let raw: string;
18+
try {
19+
raw = await readFile(lockfilePath, 'utf8');
20+
} catch (cause) {
21+
throw new PatchstackError(
22+
`Could not read lockfile at ${lockfilePath}`,
23+
'LOCKFILE_NOT_FOUND',
24+
cause,
25+
);
26+
}
27+
28+
const blocks = parseBlocks(raw);
29+
if (blocks.length === 0) {
30+
throw new PatchstackError(
31+
`Lockfile at ${lockfilePath} contains no package entries`,
32+
'LOCKFILE_PARSE_ERROR',
33+
);
34+
}
35+
36+
const directNames = await readDirectDepNames(path.dirname(lockfilePath));
37+
38+
const entries: PackageEntry[] = [];
39+
const seen = new Set<string>();
40+
for (const block of blocks) {
41+
if (block.version.length === 0 || block.names.size === 0) {
42+
continue;
43+
}
44+
for (const name of block.names) {
45+
const dedupKey = `${name}@${block.version}`;
46+
if (seen.has(dedupKey)) {
47+
continue;
48+
}
49+
seen.add(dedupKey);
50+
entries.push({
51+
name,
52+
version: block.version,
53+
direct: directNames.has(name),
54+
});
55+
}
56+
}
57+
58+
return entries;
59+
}
60+
61+
interface Block {
62+
names: Set<string>;
63+
version: string;
64+
}
65+
66+
function parseBlocks(raw: string): Block[] {
67+
const lines = raw.split(/\r?\n/);
68+
const blocks: Block[] = [];
69+
let current: Block | null = null;
70+
71+
const finalize = () => {
72+
if (current !== null && current.version.length > 0 && current.names.size > 0) {
73+
blocks.push(current);
74+
}
75+
current = null;
76+
};
77+
78+
for (const line of lines) {
79+
const trimmed = line.trim();
80+
if (trimmed.length === 0 || trimmed.startsWith('#')) {
81+
continue;
82+
}
83+
84+
const indent = countLeadingSpaces(line);
85+
86+
if (indent === 0) {
87+
finalize();
88+
if (!trimmed.endsWith(':')) {
89+
continue;
90+
}
91+
// `__metadata:` (yarn berry header) has no `@` in any descriptor and
92+
// produces an empty names set, so it's naturally skipped on finalize.
93+
const keyLine = trimmed.slice(0, -1);
94+
const names = new Set<string>();
95+
for (const spec of splitDescriptors(keyLine)) {
96+
const name = extractName(spec);
97+
if (name !== null) {
98+
names.add(name);
99+
}
100+
}
101+
current = { names, version: '' };
102+
continue;
103+
}
104+
105+
if (current === null) {
106+
continue;
107+
}
108+
109+
const version = parseVersionField(trimmed);
110+
if (version !== null) {
111+
current.version = version;
112+
}
113+
}
114+
115+
finalize();
116+
return blocks;
117+
}
118+
119+
function countLeadingSpaces(line: string): number {
120+
let i = 0;
121+
while (i < line.length && line[i] === ' ') {
122+
i++;
123+
}
124+
return i;
125+
}
126+
127+
/**
128+
* Splits a yarn descriptor key list on top-level commas. yarn quotes any
129+
* descriptor that contains characters needing escaping, so we respect quotes
130+
* while splitting to avoid breaking on commas inside (rare in practice but
131+
* cheap to handle).
132+
*/
133+
export function splitDescriptors(keyLine: string): string[] {
134+
const parts: string[] = [];
135+
let current = '';
136+
let quote: '"' | "'" | null = null;
137+
138+
for (let i = 0; i < keyLine.length; i++) {
139+
const c = keyLine[i];
140+
if (quote !== null) {
141+
current += c;
142+
if (c === quote) {
143+
quote = null;
144+
}
145+
continue;
146+
}
147+
if (c === '"' || c === "'") {
148+
quote = c;
149+
current += c;
150+
continue;
151+
}
152+
if (c === ',') {
153+
const piece = current.trim();
154+
if (piece.length > 0) {
155+
parts.push(piece);
156+
}
157+
current = '';
158+
continue;
159+
}
160+
current += c;
161+
}
162+
const tail = current.trim();
163+
if (tail.length > 0) {
164+
parts.push(tail);
165+
}
166+
return parts;
167+
}
168+
169+
/**
170+
* Extracts the package name from a yarn descriptor like `axios@^1.6.0`,
171+
* `"@scope/pkg@^2.1.0"`, or `"@scope/pkg@npm:2.1.0"`. The descriptor's
172+
* range portion is discarded — we only need the name, since the resolved
173+
* version comes from the `version` field of the block.
174+
*/
175+
export function extractName(rawSpec: string): string | null {
176+
let s = rawSpec.trim();
177+
if (s.length === 0) {
178+
return null;
179+
}
180+
if (
181+
(s.startsWith('"') && s.endsWith('"')) ||
182+
(s.startsWith("'") && s.endsWith("'"))
183+
) {
184+
s = s.slice(1, -1);
185+
}
186+
// Position-0 `@` belongs to a scope, so we want the last `@` after it.
187+
const atIdx = s.lastIndexOf('@');
188+
if (atIdx <= 0) {
189+
return null;
190+
}
191+
const name = s.slice(0, atIdx);
192+
return name.length > 0 ? name : null;
193+
}
194+
195+
function parseVersionField(content: string): string | null {
196+
if (!content.startsWith('version')) {
197+
return null;
198+
}
199+
const after = content.slice('version'.length);
200+
// yarn v1: `version "1.2.3"` (whitespace then quoted)
201+
// yarn berry: `version: 1.2.3` or `version: "1.2.3"`
202+
const firstChar = after.charAt(0);
203+
if (firstChar !== ' ' && firstChar !== '\t' && firstChar !== ':') {
204+
return null;
205+
}
206+
let rest = firstChar === ':' ? after.slice(1) : after;
207+
rest = rest.trim();
208+
if (rest.length === 0) {
209+
return null;
210+
}
211+
if (
212+
(rest.startsWith('"') && rest.endsWith('"')) ||
213+
(rest.startsWith("'") && rest.endsWith("'"))
214+
) {
215+
rest = rest.slice(1, -1);
216+
}
217+
return rest.length > 0 ? rest : null;
218+
}
219+
220+
async function readDirectDepNames(cwd: string): Promise<Set<string>> {
221+
const names = new Set<string>();
222+
let raw: string;
223+
try {
224+
raw = await readFile(path.join(cwd, 'package.json'), 'utf8');
225+
} catch {
226+
return names;
227+
}
228+
229+
let parsed: unknown;
230+
try {
231+
parsed = JSON.parse(raw);
232+
} catch {
233+
return names;
234+
}
235+
236+
if (typeof parsed !== 'object' || parsed === null) {
237+
return names;
238+
}
239+
const obj = parsed as Record<string, unknown>;
240+
241+
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
242+
const section = obj[field];
243+
if (typeof section !== 'object' || section === null) {
244+
continue;
245+
}
246+
for (const name of Object.keys(section)) {
247+
names.add(name);
248+
}
249+
}
250+
251+
return names;
252+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "yarn-berry-project",
3+
"version": "1.0.0",
4+
"private": true,
5+
"packageManager": "yarn@4.0.0",
6+
"dependencies": {
7+
"@scope/pkg": "^2.1.0",
8+
"axios": "^1.6.0",
9+
"react-dom": "^18.2.0"
10+
},
11+
"devDependencies": {
12+
"vitest": "^3.0.0"
13+
}
14+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# This file is generated by running "yarn install" inside your project.
2+
# Manual changes might be lost - proceed with caution!
3+
4+
__metadata:
5+
version: 6
6+
cacheKey: 8
7+
8+
"@scope/pkg@npm:^2.1.0":
9+
version: 2.1.0
10+
resolution: "@scope/pkg@npm:2.1.0"
11+
checksum: fake
12+
languageName: node
13+
linkType: hard
14+
15+
"axios@npm:^1.6.0":
16+
version: 1.6.0
17+
resolution: "axios@npm:1.6.0"
18+
dependencies:
19+
follow-redirects: "npm:^1.15.0"
20+
checksum: fake
21+
languageName: node
22+
linkType: hard
23+
24+
"follow-redirects@npm:^1.15.0":
25+
version: 1.15.3
26+
resolution: "follow-redirects@npm:1.15.3"
27+
checksum: fake
28+
languageName: node
29+
linkType: hard
30+
31+
"react@npm:^18.2.0":
32+
version: 18.2.0
33+
resolution: "react@npm:18.2.0"
34+
checksum: fake
35+
languageName: node
36+
linkType: hard
37+
38+
"react-dom@npm:^18.2.0":
39+
version: 18.2.0
40+
resolution: "react-dom@npm:18.2.0"
41+
dependencies:
42+
react: "npm:^18.2.0"
43+
checksum: fake
44+
languageName: node
45+
linkType: hard
46+
47+
"vitest@npm:^3.0.0":
48+
version: 3.0.0
49+
resolution: "vitest@npm:3.0.0"
50+
checksum: fake
51+
languageName: node
52+
linkType: hard
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "yarn-v1-project",
3+
"version": "1.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"@scope/pkg": "^2.1.0",
7+
"axios": "^1.6.0",
8+
"lodash": "4.17.15",
9+
"react-dom": "^18.2.0"
10+
},
11+
"devDependencies": {
12+
"react": "^18.2.0"
13+
}
14+
}

0 commit comments

Comments
 (0)