flatlock 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/detect.js ADDED
@@ -0,0 +1,153 @@
1
+ import yaml from 'js-yaml';
2
+ import { parseSyml } from '@yarnpkg/parsers';
3
+ import yarnLockfile from '@yarnpkg/lockfile';
4
+
5
+ /**
6
+ * @typedef {'npm' | 'pnpm' | 'yarn-classic' | 'yarn-berry'} LockfileType
7
+ */
8
+
9
+ /**
10
+ * Lockfile type constants
11
+ */
12
+ export const Type = Object.freeze({
13
+ NPM: 'npm',
14
+ PNPM: 'pnpm',
15
+ YARN_CLASSIC: 'yarn-classic',
16
+ YARN_BERRY: 'yarn-berry'
17
+ });
18
+
19
+ /**
20
+ * Try to parse content as npm package-lock.json
21
+ * @param {string} content
22
+ * @returns {boolean}
23
+ */
24
+ function tryParseNpm(content) {
25
+ try {
26
+ const parsed = JSON.parse(content);
27
+ // Must have lockfileVersion as a number at root level
28
+ return typeof parsed.lockfileVersion === 'number';
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Try to parse content as yarn berry (v2+) lockfile
36
+ * @param {string} content
37
+ * @returns {boolean}
38
+ */
39
+ function tryParseYarnBerry(content) {
40
+ try {
41
+ const parsed = parseSyml(content);
42
+ // Must have __metadata object at root with version property
43
+ return parsed
44
+ && typeof parsed.__metadata === 'object'
45
+ && parsed.__metadata !== null
46
+ && 'version' in parsed.__metadata;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Try to parse content as yarn classic (v1) lockfile
54
+ * @param {string} content
55
+ * @returns {boolean}
56
+ */
57
+ function tryParseYarnClassic(content) {
58
+ try {
59
+ const parse = yarnLockfile.default?.parse || yarnLockfile.parse;
60
+ if (!parse) return false;
61
+
62
+ const result = parse(content);
63
+ // Must parse successfully and NOT have __metadata (that's berry)
64
+ // Must have at least one package entry (not empty object)
65
+ const isValidResult = result.type === 'success' || result.type === 'merge';
66
+ const hasEntries = result.object && Object.keys(result.object).length > 0;
67
+ const notBerry = !('__metadata' in result.object);
68
+
69
+ return isValidResult && hasEntries && notBerry;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Try to parse content as pnpm lockfile
77
+ * @param {string} content
78
+ * @returns {boolean}
79
+ */
80
+ function tryParsePnpm(content) {
81
+ try {
82
+ const parsed = yaml.load(content);
83
+ // Must have lockfileVersion at root and NOT have __metadata
84
+ return parsed
85
+ && typeof parsed === 'object'
86
+ && 'lockfileVersion' in parsed
87
+ && !('__metadata' in parsed);
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Detect lockfile type from content and/or path
95
+ *
96
+ * Content is the primary signal - we actually parse the content to verify
97
+ * it's a valid lockfile of the detected type. This prevents spoofing attacks
98
+ * where malicious content contains detection markers in strings/comments.
99
+ *
100
+ * Path is only used as a fallback hint when content is not provided.
101
+ *
102
+ * @param {Object} options - Detection options
103
+ * @param {string} [options.path] - Path to the lockfile (optional hint)
104
+ * @param {string} [options.content] - Lockfile content (primary signal)
105
+ * @returns {LockfileType}
106
+ * @throws {Error} If unable to detect lockfile type
107
+ */
108
+ export function detectType({ path, content } = {}) {
109
+ // Content-based detection (primary) - actually parse to verify type
110
+ if (content) {
111
+ // npm: valid JSON with lockfileVersion number at root
112
+ if (tryParseNpm(content)) {
113
+ return Type.NPM;
114
+ }
115
+
116
+ // yarn berry: valid YAML with __metadata.version at root
117
+ if (tryParseYarnBerry(content)) {
118
+ return Type.YARN_BERRY;
119
+ }
120
+
121
+ // yarn classic: parses with @yarnpkg/lockfile, no __metadata
122
+ if (tryParseYarnClassic(content)) {
123
+ return Type.YARN_CLASSIC;
124
+ }
125
+
126
+ // pnpm: valid YAML with lockfileVersion at root, no __metadata
127
+ if (tryParsePnpm(content)) {
128
+ return Type.PNPM;
129
+ }
130
+ }
131
+
132
+ // If content was provided but didn't match any format, that's an error
133
+ // Don't fall back to path-based detection with invalid content (security risk)
134
+ if (content) {
135
+ throw new Error('Unable to detect lockfile type: content does not match any known format');
136
+ }
137
+
138
+ // Path-based detection (only when no content provided)
139
+ if (path) {
140
+ if (path.endsWith('package-lock.json') || path.endsWith('npm-shrinkwrap.json')) {
141
+ return Type.NPM;
142
+ }
143
+ if (path.endsWith('pnpm-lock.yaml')) {
144
+ return Type.PNPM;
145
+ }
146
+ if (path.endsWith('yarn.lock')) {
147
+ // Without content, default to classic (more common historically)
148
+ return Type.YARN_CLASSIC;
149
+ }
150
+ }
151
+
152
+ throw new Error('Unable to detect lockfile type');
153
+ }
package/src/index.js ADDED
@@ -0,0 +1,144 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { Type, detectType } from './detect.js';
3
+ import { Ok, Err } from './result.js';
4
+ import {
5
+ fromPackageLock,
6
+ fromPnpmLock,
7
+ fromYarnClassicLock,
8
+ fromYarnBerryLock
9
+ } from './parsers/index.js';
10
+
11
+ /**
12
+ * @typedef {import('./detect.js').LockfileType} LockfileType
13
+ * @typedef {import('./result.js').Result} Result
14
+ * @typedef {import('./parsers/npm.js').Dependency} Dependency
15
+ */
16
+
17
+ // Re-export Type and detection
18
+ export { Type, detectType };
19
+
20
+ // Re-export Result helpers
21
+ export { Ok, Err };
22
+
23
+ // Re-export individual parsers
24
+ export { fromPackageLock, fromPnpmLock, fromYarnClassicLock, fromYarnBerryLock };
25
+
26
+ /**
27
+ * Parse lockfile from path (auto-detect type)
28
+ * @param {string} path - Path to lockfile
29
+ * @param {Object} [options] - Parser options
30
+ * @returns {AsyncGenerator<Dependency>}
31
+ */
32
+ export async function* fromPath(path, options = {}) {
33
+ const content = await readFile(path, 'utf8');
34
+ const type = detectType({ path, content });
35
+
36
+ yield* fromString(content, { ...options, path, type });
37
+ }
38
+
39
+ /**
40
+ * Parse lockfile from string (auto-detect or use options.type)
41
+ * @param {string} content - Lockfile content
42
+ * @param {Object} [options] - Parser options
43
+ * @param {string} [options.path] - Path hint for type detection
44
+ * @param {LockfileType} [options.type] - Explicit type (skip detection)
45
+ * @returns {Generator<Dependency>}
46
+ */
47
+ export function* fromString(content, options = {}) {
48
+ const type = options.type || detectType({ path: options.path, content });
49
+
50
+ switch (type) {
51
+ case Type.NPM: {
52
+ yield* fromPackageLock(content, options);
53
+ break;
54
+ }
55
+ case Type.PNPM: {
56
+ yield* fromPnpmLock(content, options);
57
+ break;
58
+ }
59
+ case Type.YARN_CLASSIC: {
60
+ yield* fromYarnClassicLock(content, options);
61
+ break;
62
+ }
63
+ case Type.YARN_BERRY: {
64
+ yield* fromYarnBerryLock(content, options);
65
+ break;
66
+ }
67
+ default: {
68
+ throw new Error(`Unknown lockfile type: ${type}`);
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Try to parse lockfile from path (returns Result)
75
+ * @param {string} path - Path to lockfile
76
+ * @param {Object} [options] - Parser options
77
+ * @returns {Promise<Result<AsyncGenerator<Dependency>>>}
78
+ */
79
+ export async function tryFromPath(path, options = {}) {
80
+ try {
81
+ const generator = fromPath(path, options);
82
+ return Ok(generator);
83
+ } catch (err) {
84
+ return Err(err);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Try to parse lockfile from string (returns Result)
90
+ * @param {string} content - Lockfile content
91
+ * @param {Object} [options] - Parser options
92
+ * @returns {Result<Generator<Dependency>>}
93
+ */
94
+ export function tryFromString(content, options = {}) {
95
+ try {
96
+ // Eagerly detect type before creating generator to catch detection errors
97
+ const type = options.type || detectType({ path: options.path, content });
98
+ const generator = fromString(content, { ...options, type });
99
+ return Ok(generator);
100
+ } catch (err) {
101
+ return Err(err);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Parse yarn.lock (auto-detect classic vs berry)
107
+ * @param {string} content - Lockfile content
108
+ * @param {Object} [options] - Parser options
109
+ * @returns {Generator<Dependency>}
110
+ */
111
+ export function* fromYarnLock(content, options = {}) {
112
+ // Auto-detect classic vs berry
113
+ const isBerry = content.includes('__metadata');
114
+ if (isBerry) {
115
+ yield* fromYarnBerryLock(content, options);
116
+ } else {
117
+ yield* fromYarnClassicLock(content, options);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Collect all dependencies into an array
123
+ * @param {string} pathOrContent - Path to lockfile or content string
124
+ * @param {Object} [options] - Parser options
125
+ * @returns {Promise<Dependency[]>}
126
+ */
127
+ export async function collect(pathOrContent, options = {}) {
128
+ const deps = [];
129
+
130
+ // Check if it's a path or content
131
+ const isPath = !pathOrContent.includes('\n') && !pathOrContent.startsWith('{');
132
+
133
+ if (isPath) {
134
+ for await (const dep of fromPath(pathOrContent, options)) {
135
+ deps.push(dep);
136
+ }
137
+ } else {
138
+ for (const dep of fromString(pathOrContent, options)) {
139
+ deps.push(dep);
140
+ }
141
+ }
142
+
143
+ return deps;
144
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Re-export all lockfile parsers
3
+ */
4
+
5
+ export { fromPackageLock } from './npm.js';
6
+ export { fromPnpmLock } from './pnpm.js';
7
+ export { fromYarnClassicLock } from './yarn-classic.js';
8
+ export { fromYarnBerryLock } from './yarn-berry.js';
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @typedef {Object} Dependency
3
+ * @property {string} name - Package name
4
+ * @property {string} version - Resolved version
5
+ * @property {string} [integrity] - Integrity hash
6
+ * @property {string} [resolved] - Resolution URL
7
+ * @property {boolean} [link] - True if this is a symlink
8
+ */
9
+
10
+ /**
11
+ * LIMITATION: Workspace symlinks are not yielded
12
+ *
13
+ * npm workspaces create two entries in package-lock.json:
14
+ * 1. packages/<workspace-path> → has version (workspace definition)
15
+ * 2. node_modules/<pkg-name> → link:true, NO version (symlink to #1)
16
+ *
17
+ * Arborist resolves #2 to get version from #1. This parser does not.
18
+ * Entries with link:true but no version are skipped.
19
+ *
20
+ * To include workspace packages, users should use @npmcli/arborist directly.
21
+ */
22
+
23
+ /**
24
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
25
+ * !! WARNING: DO NOT MODIFY THIS FUNCTION !!
26
+ * !! AI AGENTS (Claude, Copilot, etc.): DO NOT TOUCH THIS CODE !!
27
+ * !! !!
28
+ * !! This is PATH parsing, NOT spec parsing. It extracts package names from !!
29
+ * !! filesystem paths like "node_modules/@scope/name", NOT from package !!
30
+ * !! specs like "@scope/name@^1.0.0". !!
31
+ * !! !!
32
+ * !! npm-package-arg (npa) is for SPEC parsing. This is PATH parsing. !!
33
+ * !! They are different things. Do not "improve" this with npa. !!
34
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
35
+ *
36
+ * Extract package name from npm lockfile path.
37
+ *
38
+ * Paths in package-lock.json follow this grammar:
39
+ * path := (node_modules/<pkg>)+
40
+ * | <workspace>/<path>
41
+ * | <workspace>/<path>/(node_modules/<pkg>)+
42
+ *
43
+ * pkg := name (unscoped)
44
+ * | @scope/name (scoped)
45
+ *
46
+ * Examples:
47
+ * - node_modules/lodash → "lodash"
48
+ * - node_modules/@babel/core → "@babel/core"
49
+ * - node_modules/foo/node_modules/@scope/bar → "@scope/bar"
50
+ *
51
+ * @param {string} path - Lockfile path key
52
+ * @returns {string} Package name
53
+ */
54
+ function extractPackageName(path) {
55
+ const parts = path.split('/');
56
+ const name = parts.at(-1);
57
+ const maybeScope = parts.at(-2);
58
+
59
+ return maybeScope?.startsWith('@')
60
+ ? `${maybeScope}/${name}`
61
+ : name;
62
+ }
63
+
64
+ /**
65
+ * Parse npm package-lock.json (v1, v2, v3)
66
+ * @param {string} content - Lockfile content
67
+ * @param {Object} [options] - Parser options
68
+ * @returns {Generator<Dependency>}
69
+ */
70
+ export function* fromPackageLock(content, _options = {}) {
71
+ const lockfile = JSON.parse(content);
72
+ const packages = lockfile.packages || {};
73
+
74
+ for (const [path, pkg] of Object.entries(packages)) {
75
+ // Skip root package
76
+ if (path === '') continue;
77
+
78
+ // Skip workspace definitions (only yield installed dependencies)
79
+ // Workspace entries come in pairs:
80
+ // 1. packages/<workspace-path> → has version (workspace definition)
81
+ // 2. node_modules/<workspace-package.json-name> → link, NO version (symlink)
82
+ if (!path.includes('node_modules/')) continue;
83
+
84
+ const name = extractPackageName(path);
85
+ const { version, integrity, resolved, link } = pkg;
86
+
87
+ // Only yield if we have a name and version
88
+ if (name && version) {
89
+ const dep = { name, version };
90
+ if (integrity) dep.integrity = integrity;
91
+ if (resolved) dep.resolved = resolved;
92
+ if (link) dep.link = true;
93
+ yield dep;
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,81 @@
1
+ import yaml from 'js-yaml';
2
+
3
+ /**
4
+ * @typedef {Object} Dependency
5
+ * @property {string} name - Package name
6
+ * @property {string} version - Resolved version
7
+ * @property {string} [integrity] - Integrity hash
8
+ * @property {string} [resolved] - Resolution URL
9
+ * @property {boolean} [link] - True if this is a symlink
10
+ */
11
+
12
+ /**
13
+ * Parse pnpm package spec to extract name and version
14
+ * Examples:
15
+ * "/@babel/core@7.23.0" → { name: "@babel/core", version: "7.23.0" }
16
+ * "/lodash@4.17.21" → { name: "lodash", version: "4.17.21" }
17
+ * "link:packages/foo" → { name: null, version: null } (skip these)
18
+ *
19
+ * @param {string} spec - Package spec from pnpm lockfile
20
+ * @returns {{ name: string | null, version: string | null }}
21
+ */
22
+ function parseSpec(spec) {
23
+ // Skip special protocols
24
+ if (spec.startsWith('link:') || spec.startsWith('file:')) {
25
+ return { name: null, version: null };
26
+ }
27
+
28
+ // Remove leading slash if present
29
+ const cleaned = spec.startsWith('/') ? spec.slice(1) : spec;
30
+
31
+ // Find the last @ which separates name from version
32
+ // For scoped packages like "@babel/core@7.23.0", we need the last @
33
+ const lastAtIndex = cleaned.lastIndexOf('@');
34
+
35
+ if (lastAtIndex === -1) {
36
+ return { name: null, version: null };
37
+ }
38
+
39
+ const name = cleaned.slice(0, lastAtIndex);
40
+ const versionPart = cleaned.slice(lastAtIndex + 1);
41
+
42
+ // Extract version (may have additional info like "_@babel+core@7.23.0")
43
+ // For peer dependencies, format can be: "lodash@4.17.21(@types/node@20.0.0)"
44
+ const version = versionPart.split('(')[0];
45
+
46
+ return { name, version };
47
+ }
48
+
49
+ /**
50
+ * Parse pnpm-lock.yaml (v5.4, v6, v9)
51
+ * @param {string} content - Lockfile content
52
+ * @param {Object} [options] - Parser options
53
+ * @returns {Generator<Dependency>}
54
+ */
55
+ export function* fromPnpmLock(content, _options = {}) {
56
+ const lockfile = yaml.load(content);
57
+ const packages = lockfile.packages || {};
58
+
59
+ for (const [spec, pkg] of Object.entries(packages)) {
60
+ const { name, version } = parseSpec(spec);
61
+
62
+ // Skip if we couldn't parse name/version
63
+ if (!name || !version) continue;
64
+
65
+ const resolution = pkg.resolution || {};
66
+ const integrity = resolution.integrity;
67
+ const resolved = resolution.tarball;
68
+ const link = spec.startsWith('link:') || resolution.type === 'directory';
69
+
70
+ // Skip workspace/link entries - flatlock only cares about external dependencies
71
+ if (link) continue;
72
+
73
+ const dep = { name, version };
74
+ if (integrity) dep.integrity = integrity;
75
+ if (resolved) dep.resolved = resolved;
76
+ yield dep;
77
+ }
78
+
79
+ // Note: importers (workspace packages) are intentionally NOT yielded
80
+ // flatlock only cares about external dependencies
81
+ }
@@ -0,0 +1,105 @@
1
+ import { parseSyml } from '@yarnpkg/parsers';
2
+
3
+ /**
4
+ * @typedef {Object} Dependency
5
+ * @property {string} name - Package name
6
+ * @property {string} version - Resolved version
7
+ * @property {string} [integrity] - Integrity hash
8
+ * @property {string} [resolved] - Resolution URL
9
+ * @property {boolean} [link] - True if this is a symlink
10
+ */
11
+
12
+ /**
13
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
14
+ * !! WARNING: DO NOT MODIFY THIS FUNCTION !!
15
+ * !! AI AGENTS (Claude, Copilot, etc.): DO NOT TOUCH THIS CODE !!
16
+ * !! !!
17
+ * !! This is YARN BERRY LOCKFILE KEY parsing, NOT npm spec parsing. !!
18
+ * !! Yarn berry lockfile keys have their own format: !!
19
+ * !! - Protocol markers: @npm:, @workspace:, @patch:, @portal:, @link: !!
20
+ * !! - Nested protocols: @patch:pkg@npm:version#hash !!
21
+ * !! - Multiple comma-separated entries !!
22
+ * !! !!
23
+ * !! npm-package-arg (npa) does NOT understand these formats. !!
24
+ * !! Do not "improve" this with npa. !!
25
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
26
+ *
27
+ * Extract package name from yarn berry key.
28
+ *
29
+ * Examples:
30
+ * "lodash@npm:^4.17.21" → "lodash"
31
+ * "@babel/core@npm:^7.0.0" → "@babel/core"
32
+ * "@babel/core@npm:^7.0.0, @babel/core@npm:^7.12.3" → "@babel/core"
33
+ * "@ngageoint/simple-features-js@patch:@ngageoint/simple-features-js@npm:1.1.0#..." → "@ngageoint/simple-features-js"
34
+ *
35
+ * @param {string} key - Lockfile entry key
36
+ * @returns {string} Package name
37
+ */
38
+ function extractName(key) {
39
+ // Keys can have multiple comma-separated entries, take the first one
40
+ const firstKey = key.split(',')[0].trim();
41
+
42
+ // Find the FIRST protocol marker by checking all protocols and using earliest position
43
+ // This is important because patch: entries contain npm: references inside them
44
+ const protocols = ['@npm:', '@workspace:', '@portal:', '@link:', '@patch:', '@file:'];
45
+ let earliestIndex = -1;
46
+
47
+ for (const protocol of protocols) {
48
+ const idx = firstKey.indexOf(protocol);
49
+ if (idx !== -1 && (earliestIndex === -1 || idx < earliestIndex)) {
50
+ earliestIndex = idx;
51
+ }
52
+ }
53
+
54
+ if (earliestIndex !== -1) {
55
+ return firstKey.slice(0, earliestIndex);
56
+ }
57
+
58
+ // Fallback: for scoped packages, find the @ after the scope
59
+ if (firstKey.startsWith('@')) {
60
+ const slashIndex = firstKey.indexOf('/');
61
+ if (slashIndex !== -1) {
62
+ const afterSlash = firstKey.indexOf('@', slashIndex);
63
+ if (afterSlash !== -1) {
64
+ return firstKey.slice(0, afterSlash);
65
+ }
66
+ }
67
+ }
68
+
69
+ // For unscoped packages
70
+ const atIndex = firstKey.indexOf('@');
71
+ return atIndex !== -1 ? firstKey.slice(0, atIndex) : firstKey;
72
+ }
73
+
74
+ /**
75
+ * Parse yarn.lock v2+ (berry)
76
+ * @param {string} content - Lockfile content
77
+ * @param {Object} [options] - Parser options
78
+ * @returns {Generator<Dependency>}
79
+ */
80
+ export function* fromYarnBerryLock(content, _options = {}) {
81
+ const lockfile = parseSyml(content);
82
+
83
+ for (const [key, pkg] of Object.entries(lockfile)) {
84
+ // Skip metadata
85
+ if (key === '__metadata') continue;
86
+
87
+ const name = extractName(key);
88
+ const { version, checksum, resolution } = pkg;
89
+
90
+ // Check if this is a link (workspace:, portal:, or link: protocol)
91
+ const link = resolution?.startsWith('workspace:')
92
+ || resolution?.startsWith('portal:')
93
+ || resolution?.startsWith('link:');
94
+
95
+ // Skip workspace/link entries - flatlock only cares about external dependencies
96
+ if (link) continue;
97
+
98
+ if (name && version) {
99
+ const dep = { name, version };
100
+ if (checksum) dep.integrity = checksum;
101
+ if (resolution) dep.resolved = resolution;
102
+ yield dep;
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,105 @@
1
+ import yarnLockfile from '@yarnpkg/lockfile';
2
+
3
+ const { parse } = yarnLockfile;
4
+
5
+ /**
6
+ * @typedef {Object} Dependency
7
+ * @property {string} name - Package name
8
+ * @property {string} version - Resolved version
9
+ * @property {string} [integrity] - Integrity hash
10
+ * @property {string} [resolved] - Resolution URL
11
+ * @property {boolean} [link] - True if this is a symlink
12
+ */
13
+
14
+ /**
15
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
16
+ * !! WARNING: DO NOT MODIFY THIS FUNCTION !!
17
+ * !! AI AGENTS (Claude, Copilot, etc.): DO NOT TOUCH THIS CODE !!
18
+ * !! !!
19
+ * !! This is YARN LOCKFILE KEY parsing, NOT npm spec parsing. !!
20
+ * !! Yarn lockfile keys have their own format: !!
21
+ * !! - Multiple comma-separated entries: "pkg@^1.0.0, pkg@^2.0.0" !!
22
+ * !! - npm: aliasing protocol: "alias@npm:actual@^1.0.0" !!
23
+ * !! !!
24
+ * !! npm-package-arg (npa) does NOT understand these formats. !!
25
+ * !! Do not "improve" this with npa. !!
26
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
27
+ *
28
+ * Extract package name from yarn classic key.
29
+ *
30
+ * Examples:
31
+ * "lodash@^4.17.21" → "lodash"
32
+ * "@babel/core@^7.0.0" → "@babel/core"
33
+ * "lodash@^4.17.21, lodash@^4.0.0" → "lodash" (multiple version ranges)
34
+ * "@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3" → "@babel/traverse--for-generate-function-map"
35
+ *
36
+ * @param {string} key - Lockfile entry key
37
+ * @returns {string} Package name
38
+ */
39
+ function extractName(key) {
40
+ // Keys can have multiple version ranges: "pkg@^1.0.0, pkg@^2.0.0"
41
+ // Take the first part before comma
42
+ const firstKey = key.split(',')[0].trim();
43
+
44
+ // Handle npm: protocol aliasing (alias-name@npm:actual-package@version)
45
+ // The name is the alias name before @npm:
46
+ const npmProtocolIndex = firstKey.indexOf('@npm:');
47
+ if (npmProtocolIndex !== -1) {
48
+ const beforeProtocol = firstKey.slice(0, npmProtocolIndex);
49
+ // beforeProtocol could be "@scope/name" or "name"
50
+ return beforeProtocol;
51
+ }
52
+
53
+ // For scoped packages like "@babel/core@^7.0.0"
54
+ if (firstKey.startsWith('@')) {
55
+ // Find the @ after the slash which separates scope/name from version
56
+ const slashIndex = firstKey.indexOf('/');
57
+ if (slashIndex !== -1) {
58
+ const afterSlash = firstKey.indexOf('@', slashIndex);
59
+ if (afterSlash !== -1) {
60
+ return firstKey.slice(0, afterSlash);
61
+ }
62
+ }
63
+ // Fallback to lastIndexOf if no slash found
64
+ const lastAtIndex = firstKey.lastIndexOf('@');
65
+ return firstKey.slice(0, lastAtIndex);
66
+ }
67
+
68
+ // For regular packages like "lodash@^4.17.21"
69
+ const atIndex = firstKey.indexOf('@');
70
+ return atIndex !== -1 ? firstKey.slice(0, atIndex) : firstKey;
71
+ }
72
+
73
+ /**
74
+ * Parse yarn.lock v1 (classic)
75
+ * @param {string} content - Lockfile content
76
+ * @param {Object} [options] - Parser options
77
+ * @returns {Generator<Dependency>}
78
+ */
79
+ export function* fromYarnClassicLock(content, _options = {}) {
80
+ const parsed = parse(content);
81
+
82
+ if (parsed.type !== 'success') {
83
+ throw new Error(`Failed to parse yarn.lock: ${parsed.type}`);
84
+ }
85
+
86
+ const lockfile = parsed.object;
87
+
88
+ for (const [key, pkg] of Object.entries(lockfile)) {
89
+ const name = extractName(key);
90
+ const { version, integrity, resolved } = pkg;
91
+
92
+ // Check if this is a link (file: or link: protocol)
93
+ const link = resolved?.startsWith('file:') || resolved?.startsWith('link:');
94
+
95
+ // Skip workspace/link entries - flatlock only cares about external dependencies
96
+ if (link) continue;
97
+
98
+ if (name && version) {
99
+ const dep = { name, version };
100
+ if (integrity) dep.integrity = integrity;
101
+ if (resolved) dep.resolved = resolved;
102
+ yield dep;
103
+ }
104
+ }
105
+ }