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/LICENSE +201 -0
- package/README.md +94 -0
- package/bin/flatlock-cmp.js +514 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/package.json +85 -0
- package/src/detect.js +153 -0
- package/src/index.js +144 -0
- package/src/parsers/index.js +8 -0
- package/src/parsers/npm.js +96 -0
- package/src/parsers/pnpm.js +81 -0
- package/src/parsers/yarn-berry.js +105 -0
- package/src/parsers/yarn-classic.js +105 -0
- package/src/result.js +35 -0
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,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
|
+
}
|