flatlock 1.1.0 → 1.3.0

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.
@@ -1,10 +1,16 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ /** @typedef {import('./types.js').Dependency} Dependency */
5
+
1
6
  /**
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
7
+ * @typedef {Object} WorkspacePackage
8
+ * @property {string} name
9
+ * @property {string} version
10
+ * @property {Record<string, string>} [dependencies]
11
+ * @property {Record<string, string>} [devDependencies]
12
+ * @property {Record<string, string>} [optionalDependencies]
13
+ * @property {Record<string, string>} [peerDependencies]
8
14
  */
9
15
 
10
16
  /**
@@ -43,13 +49,68 @@
43
49
  * pkg := name (unscoped)
44
50
  * | @scope/name (scoped)
45
51
  *
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
52
  * @param {string} path - Lockfile path key
52
53
  * @returns {string} Package name
54
+ *
55
+ * @example
56
+ * // Simple unscoped package
57
+ * parseLockfileKey('node_modules/lodash')
58
+ * // => 'lodash'
59
+ *
60
+ * @example
61
+ * // Scoped package
62
+ * parseLockfileKey('node_modules/@babel/core')
63
+ * // => '@babel/core'
64
+ *
65
+ * @example
66
+ * // Nested dependency (hoisted conflict resolution)
67
+ * parseLockfileKey('node_modules/foo/node_modules/bar')
68
+ * // => 'bar'
69
+ *
70
+ * @example
71
+ * // Nested scoped dependency
72
+ * parseLockfileKey('node_modules/foo/node_modules/@scope/bar')
73
+ * // => '@scope/bar'
74
+ *
75
+ * @example
76
+ * // Deeply nested dependency
77
+ * parseLockfileKey('node_modules/a/node_modules/b/node_modules/c')
78
+ * // => 'c'
79
+ *
80
+ * @example
81
+ * // Deeply nested scoped dependency
82
+ * parseLockfileKey('node_modules/a/node_modules/@types/node')
83
+ * // => '@types/node'
84
+ *
85
+ * @example
86
+ * // Workspace package path (definition)
87
+ * parseLockfileKey('packages/my-lib')
88
+ * // => 'my-lib'
89
+ *
90
+ * @example
91
+ * // Workspace nested dependency
92
+ * parseLockfileKey('packages/my-lib/node_modules/lodash')
93
+ * // => 'lodash'
94
+ *
95
+ * @example
96
+ * // Workspace nested scoped dependency
97
+ * parseLockfileKey('packages/my-lib/node_modules/@types/react')
98
+ * // => '@types/react'
99
+ *
100
+ * @example
101
+ * // Package with hyphenated name
102
+ * parseLockfileKey('node_modules/string-width')
103
+ * // => 'string-width'
104
+ *
105
+ * @example
106
+ * // Scoped package with hyphenated name
107
+ * parseLockfileKey('node_modules/@emotion/styled')
108
+ * // => '@emotion/styled'
109
+ *
110
+ * @example
111
+ * // Complex nested path
112
+ * parseLockfileKey('node_modules/@babel/core/node_modules/@babel/helper-compilation-targets')
113
+ * // => '@babel/helper-compilation-targets'
53
114
  */
54
115
  export function parseLockfileKey(path) {
55
116
  const parts = path.split('/');
@@ -61,12 +122,12 @@ export function parseLockfileKey(path) {
61
122
 
62
123
  /**
63
124
  * Parse npm package-lock.json (v1, v2, v3)
64
- * @param {string} content - Lockfile content
125
+ * @param {string | object} input - Lockfile content string or pre-parsed object
65
126
  * @param {Object} [_options] - Parser options (unused, reserved for future use)
66
127
  * @returns {Generator<Dependency>}
67
128
  */
68
- export function* fromPackageLock(content, _options = {}) {
69
- const lockfile = JSON.parse(content);
129
+ export function* fromPackageLock(input, _options = {}) {
130
+ const lockfile = typeof input === 'string' ? JSON.parse(input) : input;
70
131
  const packages = lockfile.packages || {};
71
132
 
72
133
  for (const [path, pkg] of Object.entries(packages)) {
@@ -93,3 +154,72 @@ export function* fromPackageLock(content, _options = {}) {
93
154
  }
94
155
  }
95
156
  }
157
+
158
+ /**
159
+ * Extract workspace paths from npm lockfile.
160
+ *
161
+ * npm workspace packages are entries in `packages` that:
162
+ * - Are not the root ('')
163
+ * - Don't contain 'node_modules/' (those are installed deps)
164
+ * - Have a version field
165
+ *
166
+ * @param {string | object} input - Lockfile content string or pre-parsed object
167
+ * @returns {string[]} Array of workspace paths (e.g., ['workspaces/arborist', 'workspaces/libnpmfund'])
168
+ *
169
+ * @example
170
+ * extractWorkspacePaths(lockfile)
171
+ * // => ['workspaces/arborist', 'workspaces/libnpmfund', ...]
172
+ */
173
+ export function extractWorkspacePaths(input) {
174
+ const lockfile = typeof input === 'string' ? JSON.parse(input) : input;
175
+ const packages = lockfile.packages || {};
176
+ const paths = [];
177
+
178
+ for (const [path, pkg] of Object.entries(packages)) {
179
+ // Skip root and node_modules entries
180
+ if (path === '' || path.includes('node_modules')) continue;
181
+
182
+ // Workspace entries have a version
183
+ if (pkg.version) {
184
+ paths.push(path);
185
+ }
186
+ }
187
+
188
+ return paths;
189
+ }
190
+
191
+ /**
192
+ * Build workspace packages map by reading package.json files.
193
+ *
194
+ * @param {string | object} input - Lockfile content string or pre-parsed object
195
+ * @param {string} repoDir - Path to repository root
196
+ * @returns {Promise<Record<string, WorkspacePackage>>} Map of workspace path to package info
197
+ *
198
+ * @example
199
+ * const workspaces = await buildWorkspacePackages(lockfile, '/path/to/repo');
200
+ * // => { 'workspaces/arborist': { name: '@npmcli/arborist', version: '1.0.0', dependencies: {...} } }
201
+ */
202
+ export async function buildWorkspacePackages(input, repoDir) {
203
+ const paths = extractWorkspacePaths(input);
204
+ /** @type {Record<string, WorkspacePackage>} */
205
+ const workspacePackages = {};
206
+
207
+ for (const wsPath of paths) {
208
+ const pkgJsonPath = join(repoDir, wsPath, 'package.json');
209
+ try {
210
+ const pkg = JSON.parse(await readFile(pkgJsonPath, 'utf8'));
211
+ workspacePackages[wsPath] = {
212
+ name: pkg.name,
213
+ version: pkg.version || '0.0.0',
214
+ dependencies: pkg.dependencies,
215
+ devDependencies: pkg.devDependencies,
216
+ optionalDependencies: pkg.optionalDependencies,
217
+ peerDependencies: pkg.peerDependencies
218
+ };
219
+ } catch {
220
+ // Skip workspaces with missing or invalid package.json
221
+ }
222
+ }
223
+
224
+ return workspacePackages;
225
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * @fileoverview Version detection for pnpm lockfiles
3
+ *
4
+ * Detects the era and version of pnpm lockfiles including:
5
+ * - shrinkwrap.yaml (v3/v4) from 2016-2019
6
+ * - pnpm-lock.yaml v5.x (2019-2022)
7
+ * - pnpm-lock.yaml v6.0 (2023)
8
+ * - pnpm-lock.yaml v9.0 (2024+)
9
+ *
10
+ * @module flatlock/parsers/pnpm/detect
11
+ */
12
+
13
+ /**
14
+ * @typedef {'shrinkwrap'|'v5'|'v5-inline'|'v6'|'v9'|'unknown'} LockfileEra
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} DetectedVersion
19
+ * @property {LockfileEra} era - The lockfile era/format family
20
+ * @property {string|number} version - The raw version from the lockfile
21
+ * @property {boolean} isShrinkwrap - True if this is a shrinkwrap.yaml file
22
+ */
23
+
24
+ /**
25
+ * Detect the version and era of a pnpm lockfile.
26
+ *
27
+ * Version detection rules:
28
+ * - If `shrinkwrapVersion` exists: shrinkwrap era (v3/v4)
29
+ * - If `lockfileVersion` is a number: v5 era
30
+ * - If `lockfileVersion` is '5.4-inlineSpecifiers': v5-inline era
31
+ * - If `lockfileVersion` starts with '6': v6 era
32
+ * - If `lockfileVersion` starts with '9': v9 era
33
+ *
34
+ * @param {Object} lockfile - Parsed pnpm lockfile object
35
+ * @param {string|number} [lockfile.lockfileVersion] - The lockfile version field
36
+ * @param {number} [lockfile.shrinkwrapVersion] - The shrinkwrap version field (v3/v4)
37
+ * @param {number} [lockfile.shrinkwrapMinorVersion] - Minor version for shrinkwrap
38
+ * @returns {DetectedVersion} The detected version information
39
+ *
40
+ * @example
41
+ * // shrinkwrap.yaml v3
42
+ * detectVersion({ shrinkwrapVersion: 3 })
43
+ * // => { era: 'shrinkwrap', version: 3, isShrinkwrap: true }
44
+ *
45
+ * @example
46
+ * // pnpm-lock.yaml v5.4
47
+ * detectVersion({ lockfileVersion: 5.4 })
48
+ * // => { era: 'v5', version: 5.4, isShrinkwrap: false }
49
+ *
50
+ * @example
51
+ * // pnpm-lock.yaml v6.0
52
+ * detectVersion({ lockfileVersion: '6.0' })
53
+ * // => { era: 'v6', version: '6.0', isShrinkwrap: false }
54
+ *
55
+ * @example
56
+ * // pnpm-lock.yaml v9.0
57
+ * detectVersion({ lockfileVersion: '9.0' })
58
+ * // => { era: 'v9', version: '9.0', isShrinkwrap: false }
59
+ */
60
+ export function detectVersion(lockfile) {
61
+ // Handle null/undefined input
62
+ if (!lockfile || typeof lockfile !== 'object') {
63
+ return { era: 'unknown', version: '', isShrinkwrap: false };
64
+ }
65
+
66
+ // Check for shrinkwrap.yaml (v3/v4) - oldest format
67
+ if ('shrinkwrapVersion' in lockfile) {
68
+ const version = lockfile.shrinkwrapVersion;
69
+ return {
70
+ era: 'shrinkwrap',
71
+ version: version ?? 0,
72
+ isShrinkwrap: true
73
+ };
74
+ }
75
+
76
+ // Get the lockfileVersion
77
+ const version = lockfile.lockfileVersion;
78
+
79
+ // Handle missing version
80
+ if (version === undefined || version === null) {
81
+ return { era: 'unknown', version: '', isShrinkwrap: false };
82
+ }
83
+
84
+ // Numeric version: v5.x era
85
+ if (typeof version === 'number') {
86
+ return {
87
+ era: 'v5',
88
+ version: version,
89
+ isShrinkwrap: false
90
+ };
91
+ }
92
+
93
+ // String version
94
+ if (typeof version === 'string') {
95
+ // v5.4-inlineSpecifiers: experimental transitional format
96
+ if (version.includes('inlineSpecifiers')) {
97
+ return {
98
+ era: 'v5-inline',
99
+ version: version,
100
+ isShrinkwrap: false
101
+ };
102
+ }
103
+
104
+ // v9.x era (2024+)
105
+ if (version.startsWith('9')) {
106
+ return {
107
+ era: 'v9',
108
+ version: version,
109
+ isShrinkwrap: false
110
+ };
111
+ }
112
+
113
+ // v6.x era (2023)
114
+ if (version.startsWith('6')) {
115
+ return {
116
+ era: 'v6',
117
+ version: version,
118
+ isShrinkwrap: false
119
+ };
120
+ }
121
+
122
+ // v7.x or v8.x would fall here if they existed (they don't)
123
+ // Could be a future version we don't know about
124
+ }
125
+
126
+ return { era: 'unknown', version: version, isShrinkwrap: false };
127
+ }
128
+
129
+ /**
130
+ * Check if a lockfile uses the v6+ package key format (name@version).
131
+ *
132
+ * v5 and earlier use: /name/version or /@scope/name/version
133
+ * v6+ use: /name@version or /@scope/name@version
134
+ * v9+ use: name@version (no leading slash)
135
+ *
136
+ * @param {DetectedVersion} detected - The detected version info
137
+ * @returns {boolean} True if the lockfile uses @ separator for name@version
138
+ *
139
+ * @example
140
+ * usesAtSeparator({ era: 'v5', version: 5.4 }) // => false
141
+ * usesAtSeparator({ era: 'v6', version: '6.0' }) // => true
142
+ * usesAtSeparator({ era: 'v9', version: '9.0' }) // => true
143
+ */
144
+ export function usesAtSeparator(detected) {
145
+ return detected.era === 'v6' || detected.era === 'v9';
146
+ }
147
+
148
+ /**
149
+ * Check if a lockfile uses the packages/snapshots split (v9+).
150
+ *
151
+ * v9 separates package metadata (packages) from dependency relationships (snapshots).
152
+ *
153
+ * @param {DetectedVersion} detected - The detected version info
154
+ * @returns {boolean} True if the lockfile has packages/snapshots split
155
+ *
156
+ * @example
157
+ * usesSnapshotsSplit({ era: 'v6', version: '6.0' }) // => false
158
+ * usesSnapshotsSplit({ era: 'v9', version: '9.0' }) // => true
159
+ */
160
+ export function usesSnapshotsSplit(detected) {
161
+ return detected.era === 'v9';
162
+ }
163
+
164
+ /**
165
+ * Check if a lockfile uses inline specifiers.
166
+ *
167
+ * v5.4-inlineSpecifiers and v6+ use inline specifiers in importers.
168
+ * Earlier versions have a separate `specifiers` block.
169
+ *
170
+ * @param {DetectedVersion} detected - The detected version info
171
+ * @returns {boolean} True if specifiers are inlined
172
+ *
173
+ * @example
174
+ * usesInlineSpecifiers({ era: 'v5', version: 5.4 }) // => false
175
+ * usesInlineSpecifiers({ era: 'v5-inline', version: '5.4-inlineSpecifiers' }) // => true
176
+ * usesInlineSpecifiers({ era: 'v6', version: '6.0' }) // => true
177
+ */
178
+ export function usesInlineSpecifiers(detected) {
179
+ return detected.era === 'v5-inline' || detected.era === 'v6' || detected.era === 'v9';
180
+ }
181
+
182
+ /**
183
+ * Check if package keys have a leading slash.
184
+ *
185
+ * v5 and v6 use leading slash: /name/version or /name@version
186
+ * v9 omits leading slash: name@version
187
+ *
188
+ * @param {DetectedVersion} detected - The detected version info
189
+ * @returns {boolean} True if package keys have leading slash
190
+ *
191
+ * @example
192
+ * hasLeadingSlash({ era: 'v5', version: 5.4 }) // => true
193
+ * hasLeadingSlash({ era: 'v6', version: '6.0' }) // => true
194
+ * hasLeadingSlash({ era: 'v9', version: '9.0' }) // => false
195
+ */
196
+ export function hasLeadingSlash(detected) {
197
+ return detected.era !== 'v9';
198
+ }