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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ That's the entire payload. No source code, no environment variables, no file pat

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

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.

Expand Down
19 changes: 14 additions & 5 deletions src/parsers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PatchstackError, type Manifest, type PackageEntry } from '../types.js';
import { parseNpmLockfile } from './npm.js';
import { walkNodeModules } from './node_modules.js';
import { parsePnpmLockfile } from './pnpm.js';
import { parseYarnLockfile } from './yarn.js';

type LockfileFilename =
| 'package-lock.json'
Expand All @@ -12,7 +13,11 @@ type LockfileFilename =
| 'yarn.lock'
| 'pnpm-lock.yaml';

type DetectionStrategy = 'npm-lockfile' | 'node-modules-walk' | 'pnpm-lockfile';
type DetectionStrategy =
| 'npm-lockfile'
| 'node-modules-walk'
| 'pnpm-lockfile'
| 'yarn-lockfile';

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

const yarnLock = path.join(cwd, 'yarn.lock');
if (await exists(yarnLock)) {
throw new PatchstackError(
'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.',
'LOCKFILE_UNSUPPORTED',
);
return {
ecosystem: 'npm',
filePath: yarnLock,
filename: 'yarn.lock',
strategy: 'yarn-lockfile',
};
}

throw new PatchstackError(
Expand All @@ -91,6 +98,8 @@ async function runStrategy(
return parseNpmLockfile(detected.filePath);
case 'pnpm-lockfile':
return parsePnpmLockfile(detected.filePath);
case 'yarn-lockfile':
return parseYarnLockfile(detected.filePath);
case 'node-modules-walk':
return walkNodeModules(cwd);
}
Expand Down
252 changes: 252 additions & 0 deletions src/parsers/yarn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { PatchstackError, type PackageEntry } from '../types.js';

/**
* Parses yarn.lock (yarn classic v1 and yarn berry v2+) without a YAML
* dependency. Both generations share the same block structure — a top-level
* mapping of comma-separated descriptor lists to a block containing a
* `version` field — so we walk them with the same scanner and only branch on
* the `version` syntax (`version "x"` for v1, `version: x` for berry).
*
* Direct vs transitive can't be derived from yarn.lock alone (yarn does not
* record an importer manifest the way pnpm v9 does), so we cross-reference
* the sibling `package.json` when present.
*/
export async function parseYarnLockfile(lockfilePath: string): Promise<PackageEntry[]> {
let raw: string;
try {
raw = await readFile(lockfilePath, 'utf8');
} catch (cause) {
throw new PatchstackError(
`Could not read lockfile at ${lockfilePath}`,
'LOCKFILE_NOT_FOUND',
cause,
);
}

const blocks = parseBlocks(raw);
if (blocks.length === 0) {
throw new PatchstackError(
`Lockfile at ${lockfilePath} contains no package entries`,
'LOCKFILE_PARSE_ERROR',
);
}

const directNames = await readDirectDepNames(path.dirname(lockfilePath));

const entries: PackageEntry[] = [];
const seen = new Set<string>();
for (const block of blocks) {
if (block.version.length === 0 || block.names.size === 0) {
continue;
}
for (const name of block.names) {
const dedupKey = `${name}@${block.version}`;
if (seen.has(dedupKey)) {
continue;
}
seen.add(dedupKey);
entries.push({
name,
version: block.version,
direct: directNames.has(name),
});
}
}

return entries;
}

interface Block {
names: Set<string>;
version: string;
}

function parseBlocks(raw: string): Block[] {
const lines = raw.split(/\r?\n/);
const blocks: Block[] = [];
let current: Block | null = null;

const finalize = () => {
if (current !== null && current.version.length > 0 && current.names.size > 0) {
blocks.push(current);
}
current = null;
};

for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith('#')) {
continue;
}

const indent = countLeadingSpaces(line);

if (indent === 0) {
finalize();
if (!trimmed.endsWith(':')) {
continue;
}
// `__metadata:` (yarn berry header) has no `@` in any descriptor and
// produces an empty names set, so it's naturally skipped on finalize.
const keyLine = trimmed.slice(0, -1);
const names = new Set<string>();
for (const spec of splitDescriptors(keyLine)) {
const name = extractName(spec);
if (name !== null) {
names.add(name);
}
}
current = { names, version: '' };
continue;
}

if (current === null) {
continue;
}

const version = parseVersionField(trimmed);
if (version !== null) {
current.version = version;
}
}

finalize();
return blocks;
}

function countLeadingSpaces(line: string): number {
let i = 0;
while (i < line.length && line[i] === ' ') {
i++;
}
return i;
}

/**
* Splits a yarn descriptor key list on top-level commas. yarn quotes any
* descriptor that contains characters needing escaping, so we respect quotes
* while splitting to avoid breaking on commas inside (rare in practice but
* cheap to handle).
*/
export function splitDescriptors(keyLine: string): string[] {
const parts: string[] = [];
let current = '';
let quote: '"' | "'" | null = null;

for (let i = 0; i < keyLine.length; i++) {
const c = keyLine[i];
if (quote !== null) {
current += c;
if (c === quote) {
quote = null;
}
continue;
}
if (c === '"' || c === "'") {
quote = c;
current += c;
continue;
}
if (c === ',') {
const piece = current.trim();
if (piece.length > 0) {
parts.push(piece);
}
current = '';
continue;
}
current += c;
}
const tail = current.trim();
if (tail.length > 0) {
parts.push(tail);
}
return parts;
}

/**
* Extracts the package name from a yarn descriptor like `axios@^1.6.0`,
* `"@scope/pkg@^2.1.0"`, or `"@scope/pkg@npm:2.1.0"`. The descriptor's
* range portion is discarded — we only need the name, since the resolved
* version comes from the `version` field of the block.
*/
export function extractName(rawSpec: string): string | null {
let s = rawSpec.trim();
if (s.length === 0) {
return null;
}
if (
(s.startsWith('"') && s.endsWith('"')) ||
(s.startsWith("'") && s.endsWith("'"))
) {
s = s.slice(1, -1);
}
// Position-0 `@` belongs to a scope, so we want the last `@` after it.
const atIdx = s.lastIndexOf('@');
if (atIdx <= 0) {
return null;
}
const name = s.slice(0, atIdx);
return name.length > 0 ? name : null;
}

function parseVersionField(content: string): string | null {
if (!content.startsWith('version')) {
return null;
}
const after = content.slice('version'.length);
// yarn v1: `version "1.2.3"` (whitespace then quoted)
// yarn berry: `version: 1.2.3` or `version: "1.2.3"`
const firstChar = after.charAt(0);
if (firstChar !== ' ' && firstChar !== '\t' && firstChar !== ':') {
return null;
}
let rest = firstChar === ':' ? after.slice(1) : after;
rest = rest.trim();
if (rest.length === 0) {
return null;
}
if (
(rest.startsWith('"') && rest.endsWith('"')) ||
(rest.startsWith("'") && rest.endsWith("'"))
) {
rest = rest.slice(1, -1);
}
return rest.length > 0 ? rest : null;
}

async function readDirectDepNames(cwd: string): Promise<Set<string>> {
const names = new Set<string>();
let raw: string;
try {
raw = await readFile(path.join(cwd, 'package.json'), 'utf8');
} catch {
return names;
}

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return names;
}

if (typeof parsed !== 'object' || parsed === null) {
return names;
}
const obj = parsed as Record<string, unknown>;

for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
const section = obj[field];
if (typeof section !== 'object' || section === null) {
continue;
}
for (const name of Object.keys(section)) {
names.add(name);
}
}

return names;
}
14 changes: 14 additions & 0 deletions tests/fixtures/yarn-berry-project/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "yarn-berry-project",
"version": "1.0.0",
"private": true,
"packageManager": "yarn@4.0.0",
"dependencies": {
"@scope/pkg": "^2.1.0",
"axios": "^1.6.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"vitest": "^3.0.0"
}
}
52 changes: 52 additions & 0 deletions tests/fixtures/yarn-berry-project/yarn.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!

__metadata:
version: 6
cacheKey: 8

"@scope/pkg@npm:^2.1.0":
version: 2.1.0
resolution: "@scope/pkg@npm:2.1.0"
checksum: fake
languageName: node
linkType: hard

"axios@npm:^1.6.0":
version: 1.6.0
resolution: "axios@npm:1.6.0"
dependencies:
follow-redirects: "npm:^1.15.0"
checksum: fake
languageName: node
linkType: hard

"follow-redirects@npm:^1.15.0":
version: 1.15.3
resolution: "follow-redirects@npm:1.15.3"
checksum: fake
languageName: node
linkType: hard

"react@npm:^18.2.0":
version: 18.2.0
resolution: "react@npm:18.2.0"
checksum: fake
languageName: node
linkType: hard

"react-dom@npm:^18.2.0":
version: 18.2.0
resolution: "react-dom@npm:18.2.0"
dependencies:
react: "npm:^18.2.0"
checksum: fake
languageName: node
linkType: hard

"vitest@npm:^3.0.0":
version: 3.0.0
resolution: "vitest@npm:3.0.0"
checksum: fake
languageName: node
linkType: hard
14 changes: 14 additions & 0 deletions tests/fixtures/yarn-v1-project/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "yarn-v1-project",
"version": "1.0.0",
"private": true,
"dependencies": {
"@scope/pkg": "^2.1.0",
"axios": "^1.6.0",
"lodash": "4.17.15",
"react-dom": "^18.2.0"
},
"devDependencies": {
"react": "^18.2.0"
}
}
Loading
Loading