@lofi-js/parser 0.0.0-stage → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 philSixZero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,214 @@
1
- # Temporary Holding Version
1
+ # @lofi-js/parser
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A small, single dependency library written in typescript for parsing **lockfiles of any js package manager**. Put in a path and get the parsed lockfile data. No matter which package manager you use, it just works.
4
+
5
+ ## Quick Guide
6
+
7
+ ### Installation
8
+
9
+ ```bash
10
+ npm i @lofi-js/parser
11
+ ```
12
+
13
+ ### Usage
14
+
15
+ ```ts
16
+ import { parseLockfile } from "@lofi-js/parser";
17
+
18
+ // From a path
19
+ const lockfile = await parseLockfile("./package-lock.json");
20
+
21
+ const entry = lockfile.get("react", "19.0.0");
22
+ console.log(entry?.integrity, entry?.dependencies);
23
+ ```
24
+
25
+ ## Supported Lock Files
26
+
27
+ | Package Manager | Lock File | Version |
28
+ | --------------- | ------------------------------------------- | ------------------------------------------- |
29
+ | npm | `package-lock.json` / `npm-shrinkwrap.json` | `lockfileVersion` 2 and 3 (npm >= 7) |
30
+ | pnpm | `pnpm-lock.yaml` | `lockfileVersion` 6 and 9 (pnpm >= 8) |
31
+ | yarn classic | `yarn.lock` | v1 |
32
+ | yarn berry | `yarn.lock` | any (tested with `__metadata` version 10) |
33
+ | bun | `bun.lock` | any (tested with `lockfileVersion` 1 and 2) |
34
+
35
+ ## API
36
+
37
+ ### Functions
38
+
39
+ #### `parseLockfile(input: string): Promise<Lockfile>`
40
+
41
+ Results in a parsed lockfile. `input` is either the path to a lockfile or the content of one.
42
+
43
+ ```ts
44
+ const lockfile = await parseLockfile("./package-lock.json");
45
+ ```
46
+
47
+ #### `parseLockfileSync(input: string): Lockfile`
48
+
49
+ Same as `parseLockfile`, but synchronous. It throws if the file can't be read.
50
+
51
+ #### `findLockfile(startDir?: string, options?: { searchParents?: boolean }): Promise<string | undefined>`
52
+
53
+ Looks for a lockfile in `startDir` and returns its absolute path, or `undefined` if there is none. `startDir` defaults to current working directory (`process.cwd()`).
54
+
55
+ Set `searchParents: true` to increase the search in the parent directories. Might be helpful for monorepos.
56
+ Hits in order: `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock`.
57
+
58
+ ```ts
59
+ const file = await findLockfile(process.cwd(), { searchParents: true });
60
+ const lockfile = file && (await parseLockfile(file));
61
+ ```
62
+
63
+ #### `findLockfileSync(startDir?: string, options?: { searchParents?: boolean }): string | undefined`
64
+
65
+ Same as `findLockfile`, but synchronous.
66
+
67
+ #### `findAndParseLock(startDir?: string, options?: { searchParents?: boolean }): Promise<Lockfile | undefined>`
68
+
69
+ Finds a lockfile with `findLockfile` and parses it with `parseLockfile`. It takes the same arguments as `findLockfile`. It returns `undefined` if there is no lockfile.
70
+
71
+ ```ts
72
+ const lockfile = await findAndParseLock(process.cwd(), { searchParents: true });
73
+ ```
74
+
75
+ #### `findAndParseLockSync(startDir?: string, options?: { searchParents?: boolean }): Lockfile | undefined`
76
+
77
+ Same as `findAndParseLock`, but synchronous.
78
+
79
+ ### `Lockfile`
80
+
81
+ The result of `parseLockfile`.
82
+
83
+ | Member | Type | Description |
84
+ | -------------------- | ------------------------------------ | --------------------------------------------------------------------------- |
85
+ | `kind` | `"npm" \| "pnpm" \| "yarn" \| "bun"` | The package manager of the lockfile |
86
+ | `lockfileVersion` | `string \| undefined` | The version of the lockfile format |
87
+ | `entries` | `Map<string, LockEntry>` | All packages, keyed by `name@version` |
88
+ | `importers` | `Map<string, Importer>` | The project and its workspace packages, keyed by path. The root key is `''` |
89
+ | `get(name, version)` | `LockEntry \| undefined` | One package by name and version |
90
+ | `entriesOf(name)` | `LockEntry[]` | All entries of a package, one for each version |
91
+ | `versionsOf(name)` | `string[]` | All versions of a package |
92
+
93
+ #### Working with the dependency graph
94
+
95
+ | Member | Returns | Description |
96
+ | ------------------------------- | -------------------------- | --------------------------------------------------------- |
97
+ | `lockfile.size` | `number` | Number of packages |
98
+ | `for (const entry of lockfile)` | `LockEntry` | Loops over all packages |
99
+ | `filter(predicate)` | `LockEntry[]` | All packages for which `predicate` is true |
100
+ | `getAll(options?)` | `LockEntry[]` | All packages, except the types set to `false` (see below) |
101
+ | `dependenciesOf(entry)` | `LockEntry[]` | All packages that `entry` depends on |
102
+ | `dependentsOf(name, version)` | `LockEntry[]` | All packages that depend on this package |
103
+ | `traverse(options?)` | `Generator<LockEntry>` | Generator returning every package that can be reached |
104
+ | `duplicates()` | `Map<string, LockEntry[]>` | Packages that are installed in more than one version |
105
+ | `why(name, version, options?)` | `LockEntry[][]` | Why a package is installed |
106
+ | `toJSON()` | `LockfileJSON` | Plain data in JSON format |
107
+
108
+ ```ts
109
+ const lockfile = await parseLockfile("./package-lock.json");
110
+
111
+ for (const chain of lockfile.why("js-tokens", "4.0.0")) {
112
+ console.log(chain.map((entry) => entry.name).join(" > "));
113
+ }
114
+ // react > loose-envify > js-tokens
115
+ // react-dom > loose-envify > js-tokens
116
+ // react-dom > react > loose-envify > js-tokens
117
+ // react-dom > scheduler > loose-envify > js-tokens
118
+ ```
119
+
120
+ `getAll` returns all dependencies. Leave out types `prod`, `dev`, `optional` and `peer` by filling the the option object. Keep in mind, that a package can have more than one type, e.g. a dev package that is also optional, which is left out if either of the types is set to `false`. `prod` means a package that is none of the others.
121
+
122
+ ```ts
123
+ // All packages a production install needs
124
+ const production = lockfile.getAll({ dev: false, optional: false });
125
+ ```
126
+
127
+ Yarn does not store these types, so `getAll` treats all yarn packages as `prod`.
128
+
129
+ `traverse` starts at the dependencies of the importers. Use `dev: false` to skip dev dependencies and `optional: false` to skip optional ones. Use `from` to start at other packages.
130
+
131
+ ```ts
132
+ // All packages a production install needs
133
+ const production = [...lockfile.traverse({ dev: false, optional: false })];
134
+ ```
135
+
136
+ `why` stops after 50 chains. Change this with `{ limit }`.
137
+
138
+ > [!note]
139
+ > `traverse` and `why` need importers. Yarn classic lockfiles have none, so they find nothing there.
140
+
141
+ ### `LockEntry`
142
+
143
+ One installed package.
144
+
145
+ | Field | Description |
146
+ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
147
+ | `name`, `version` | Name and version of the package |
148
+ | `integrity`, `resolved` | Integrity hash and download URL |
149
+ | `checksum` | Checksum of the archive (yarn berry only, not an integrity hash) |
150
+ | `source` | Where the package comes from: `registry`, `git`, `tarball`, `file` or `workspace` |
151
+ | `dev`, `optional` | `true` if the package is only used through dev or optional dependencies |
152
+ | `peer` | `true` if the package is only used through peer dependencies |
153
+ | `license`, `deprecated`, `hasInstallScript` | Package metadata |
154
+ | `bin`, `engines`, `os`, `cpu`, `libc` | Package metadata |
155
+ | `dependencies`, `optionalDependencies`, `peerDependencies` | Sets of `name@version` keys that point to `entries`. The last two are part of `dependencies` |
156
+ | `ranges` | The requested version range for each dependency name |
157
+
158
+ > [!note]
159
+ > Not every package manager stores every field. See [Package Manager Differences](#package-manager-differences).
160
+
161
+ ### `Importer`
162
+
163
+ The root project or a workspace package.
164
+
165
+ | Field | Description |
166
+ | ----------------------------------------------------------------------------- | -------------------------------------------------------------- |
167
+ | `path` | Path relative to the lockfile. It is `''` for the root project |
168
+ | `name`, `version` | Name and version, if the lockfile has them |
169
+ | `dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies` | Sets of `name@version` keys that point to `entries` |
170
+
171
+ ## Package Manager Differences And Limitations
172
+
173
+ Not all fields are stored by every package manager, so that missing fields in a lockfile are `undefined` (or empty). However, `@lofi-js/parser` tries to infer as much information as possible from the available data.
174
+
175
+ ✅ read from the lockfile &nbsp; 🟡 partial, derived or inferred &nbsp; ❌ not stored in the lockfile
176
+
177
+ | Data | npm | pnpm | yarn classic | yarn berry | bun |
178
+ | -------------------------- | :-: | :--: | :----------: | :--------: | :-: |
179
+ | `lockfileVersion` | ✅ | ✅ | ✅ | ✅ | ✅ |
180
+ | `integrity` | ✅ | ✅ | ✅ | ❌ | ✅ |
181
+ | `checksum` | ❌ | ❌ | ❌ | ✅ | ❌ |
182
+ | `resolved` | ✅ | ✅ | ✅ | 🟡 | ❌ |
183
+ | `source` | 🟡 | ✅ | 🟡 | ✅ | ✅ |
184
+ | Non-registry packages | ✅ | ✅ | ✅ | ✅ | ❌ |
185
+ | `dev` | ✅ | 🟡 | ❌ | ❌ | 🟡 |
186
+ | `optional` | ✅ | ✅ | ❌ | ❌ | 🟡 |
187
+ | `peer` | ✅ | ❌ | ❌ | ❌ | ❌ |
188
+ | `license` | ✅ | ❌ | ❌ | ❌ | ❌ |
189
+ | `hasInstallScript` | ✅ | 🟡 | ❌ | ❌ | ❌ |
190
+ | `deprecated` | ✅ | ✅ | ❌ | ❌ | ❌ |
191
+ | `bin` | ✅ | ❌ | ❌ | ✅ | ✅ |
192
+ | `engines` | ✅ | ✅ | ❌ | ❌ | ❌ |
193
+ | `os`, `cpu`, `libc` | ✅ | ✅ | ❌ | ✅ | ✅ |
194
+ | `optionalDependencies` | ✅ | ✅ | ✅ | ✅ | ✅ |
195
+ | `peerDependencies` | ✅ | ✅ | ❌ | ❌ | ✅ |
196
+ | `ranges` | ✅ | 🟡 | ✅ | ✅ | ✅ |
197
+ | Importers | ✅ | ✅ | ❌ | 🟡 | ✅ |
198
+ | Importer `devDependencies` | ✅ | ✅ | ❌ | ❌ | ✅ |
199
+
200
+ ### Notes
201
+
202
+ - **npm:** `source` is derived from the `resolved` URL (a `/-/` path segment means registry). Workspace packages are also regular entries with `source: "workspace"`. `package-lock.json` with `lockfileVersion` 1 is not supported.
203
+ - **pnpm:** `dev` is read from lockfile v6 and inferred from the importers for v9. `ranges` only contains peer ranges. `hasInstallScript` is only available for lockfile v6. `lockfileVersion` below 6 is not supported.
204
+ - **yarn classic:** the lockfile has no information about the project itself, so there are no importers and no `dev`/`optional` flags.
205
+ - **yarn berry:** dev dependencies are merged into the importer's `dependencies`. Peer dependencies are only stored as ranges and are not resolved. `resolved` is only set for non-registry packages.
206
+ - **bun:** versions of non-registry packages (git, tarball) are not recorded, so only registry packages appear. `dev` and `optional` are inferred from the importers.
207
+
208
+ ## Missing something?
209
+
210
+ If you are missing a package manager or a feature, feel free to open an issue or submit a pull request to add support for it.
211
+
212
+ ## License
213
+
214
+ MIT
@@ -0,0 +1,239 @@
1
+ //#region src/lockfile.d.ts
2
+ /** Where a package comes from. `workspace` means a local package of the project. */
3
+ type Source = 'registry' | 'git' | 'tarball' | 'file' | 'workspace';
4
+ interface LockEntry {
5
+ /** Package name, e.g. `react` or `@scope/pkg`. */
6
+ name: string;
7
+ /** Exact installed version. */
8
+ version: string;
9
+ /** Integrity hash of the package tarball (SRI format, e.g. `sha512-...`). */
10
+ integrity?: string;
11
+ /** Checksum of the package archive. Only in yarn berry. It is not an SRI hash! */
12
+ checksum?: string;
13
+ /** URL or path the package was resolved from. */
14
+ resolved?: string;
15
+ /** Where the package comes from. */
16
+ source?: Source;
17
+ /** `true` if the package is only used through dev dependencies. Not available for yarn. */
18
+ dev?: boolean;
19
+ /** `true` if the package is only used through optional dependencies. Not available for yarn. */
20
+ optional?: boolean;
21
+ /** `true` if the package is only used through peer dependencies. Only available for npm. */
22
+ peer?: boolean;
23
+ /** License of the package, e.g. `MIT`. */
24
+ license?: string;
25
+ /** Executables of the package. Maps the command name to the file path. */
26
+ bin?: Record<string, string>;
27
+ /** Required versions of the runtime, e.g. `{ node: ">=18" }`. */
28
+ engines?: Record<string, string>;
29
+ /** Operating systems the package works on, e.g. `["darwin"]`. */
30
+ os?: string[];
31
+ /** CPU architectures the package works on, e.g. `["arm64"]`. */
32
+ cpu?: string[];
33
+ /** C libraries the package works with, e.g. `["glibc"]`. */
34
+ libc?: string[];
35
+ /** `true` if the package runs a script when it is installed. */
36
+ hasInstallScript?: boolean;
37
+ /** Message from the author if the package is deprecated. */
38
+ deprecated?: string;
39
+ /** Keys (`name@version`) of all packages this package depends on. This includes optional and peer dependencies. */
40
+ dependencies: Set<string>;
41
+ /** Keys of the optional dependencies. These are also in `dependencies`. */
42
+ optionalDependencies: Set<string>;
43
+ /** Keys of the peer dependencies. These are also in `dependencies`. Not available for yarn. */
44
+ peerDependencies: Set<string>;
45
+ /** The version range that was requested for each dependency, by name. Not available for pnpm. */
46
+ ranges: Record<string, string>;
47
+ }
48
+ /** The root project or a workspace package. */
49
+ interface Importer {
50
+ /** Path relative to the lockfile. It is empty for the root project. */
51
+ path: string;
52
+ /** Package name from the `package.json`, if the lockfile has it. */
53
+ name?: string;
54
+ /** Package version from the `package.json`, if the lockfile has it. */
55
+ version?: string;
56
+ /** Keys (`name@version`) of the dependencies. Yarn does not keep dev dependencies apart, so they are also here. */
57
+ dependencies: Set<string>;
58
+ /** Keys of the dev dependencies. */
59
+ devDependencies: Set<string>;
60
+ /** Keys of the optional dependencies. */
61
+ optionalDependencies: Set<string>;
62
+ /** Keys of the peer dependencies. */
63
+ peerDependencies: Set<string>;
64
+ }
65
+ type LockEntryExtra = Partial<Omit<LockEntry, 'name' | 'version' | 'dependencies' | 'optionalDependencies' | 'peerDependencies'>> & {
66
+ dependencies?: Iterable<string>;
67
+ optionalDependencies?: Iterable<string>;
68
+ peerDependencies?: Iterable<string>;
69
+ };
70
+ type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
71
+ /**
72
+ * What kind of dependency a package is.
73
+ * A package can have more than one kind, e.g. a dev package that is also optional. `prod` means none of the others.
74
+ */
75
+ type DependencyType = 'prod' | 'dev' | 'optional' | 'peer';
76
+ /** Set a type to `false` to leave out the packages of that type. Every type defaults to `true`. */
77
+ type GetAllOptions = Partial<Record<DependencyType, boolean>>;
78
+ /** `T` with every `Set<string>` turned into a `string[]`. */
79
+ type Serialized<T> = { [K in keyof T]: T[K] extends Set<string> ? string[] : T[K]; };
80
+ /** What `Lockfile.toJSON()` returns. Sets become arrays and maps become objects. */
81
+ interface LockfileJSON {
82
+ kind: PackageManager;
83
+ lockfileVersion?: string;
84
+ /** Keyed by `name@version`. */
85
+ entries: Record<string, Serialized<LockEntry>>;
86
+ /** Keyed by path. */
87
+ importers: Record<string, Serialized<Importer>>;
88
+ }
89
+ interface TraverseOptions {
90
+ /** Where to start. By default, everything the importers depend on. The start entries are included. */
91
+ from?: Iterable<LockEntry>;
92
+ /** Follow the dev dependencies of the importers. Defaults to `true`. */
93
+ dev?: boolean;
94
+ /** Follow optional dependencies. Defaults to `true`. */
95
+ optional?: boolean;
96
+ }
97
+ export declare const keyOf: (name: string, version: string) => string;
98
+ export declare class Lockfile {
99
+ #private;
100
+ readonly kind: PackageManager;
101
+ /** All packages, keyed by `name@version`. */
102
+ readonly entries: Map<string, LockEntry>;
103
+ /** The root project and its workspace packages, keyed by path. The root has the key `''`. */
104
+ readonly importers: Map<string, Importer>;
105
+ /** Version of the lockfile format, if the lockfile has one. */
106
+ lockfileVersion?: string;
107
+ /** @param kind The package manager that wrote the lockfile. */
108
+ constructor(kind: PackageManager);
109
+ add(name: string, version: string, extra?: LockEntryExtra): LockEntry;
110
+ importer(path: string): Importer;
111
+ /**
112
+ * Returns the package with this name and version, or `undefined`.
113
+ *
114
+ * @param name The name of the package.
115
+ * @param version The version of the package.
116
+ * @returns The lock entry for the specified package name and version, or `undefined` if it does not exist.
117
+ */
118
+ get(name: string, version: string): LockEntry | undefined;
119
+ /**
120
+ * Returns all entries of a package, one for each version.
121
+ *
122
+ * @param name The name of the package.
123
+ * @returns An array of all entries for the specified package name.
124
+ */
125
+ entriesOf(name: string): LockEntry[];
126
+ /**
127
+ * Returns all versions of a package.
128
+ *
129
+ * @param name The name of the package.
130
+ * @returns An array of all versions for the specified package name.
131
+ */
132
+ versionsOf(name: string): string[];
133
+ /** Number of packages in the lockfile. */
134
+ get size(): number;
135
+ [Symbol.iterator](): IterableIterator<LockEntry>;
136
+ /** Returns all packages for which `predicate` is true. */
137
+ filter(predicate: (entry: LockEntry) => unknown): LockEntry[];
138
+ /**
139
+ * Returns all packages. Set a type to `false` to leave out the packages of that type.
140
+ * A package is left out if it has any of the types that are `false`.
141
+ * For example, `getAll({ dev: false, optional: false })` returns what a production install needs.
142
+ *
143
+ * **Note:** All yarn packages count as `prod`.
144
+ */
145
+ getAll(options?: GetAllOptions): LockEntry[];
146
+ /** Returns the packages that `entry` depends on. Dependencies that are not in the lockfile are left out. */
147
+ dependenciesOf(entry: LockEntry): LockEntry[];
148
+ /** Returns the packages that depend on the given package. */
149
+ dependentsOf(name: string, version: string): LockEntry[];
150
+ /**
151
+ * Visits every package that can be reached from the start, once each. The order is not defined.
152
+ * Without `from` it starts at the dependencies of the importers, so it finds nothing for yarn classic.
153
+ */
154
+ traverse(options?: TraverseOptions): Generator<LockEntry>;
155
+ /** Returns the packages with the same name that are installed in more than one version. */
156
+ duplicates(): Map<string, LockEntry[]>;
157
+ /**
158
+ * Explains why a package is installed. Returns the chains of packages that lead to it.
159
+ * Each chain starts with a package an importer depends on and ends with the given package.
160
+ * The shortest chains come first. The result is empty if the package is missing or if there are no importers (yarn classic).
161
+ *
162
+ * @param options.limit The most chains to return. Defaults to `50`.
163
+ */
164
+ why(name: string, version: string, options?: {
165
+ limit?: number;
166
+ }): LockEntry[][];
167
+ /** Returns a plain data representation that `JSON.stringify` can handle. */
168
+ toJSON(): LockfileJSON;
169
+ }
170
+ //#endregion
171
+ //#region src/bun.d.ts
172
+ export declare function parseBunLock(text: string): Lockfile;
173
+ //#endregion
174
+ //#region src/npm.d.ts
175
+ export declare function parseNpmLock(text: string): Lockfile;
176
+ //#endregion
177
+ //#region src/pnpm.d.ts
178
+ export declare function parsePnpmLock(text: string): Lockfile;
179
+ //#endregion
180
+ //#region src/yarn.d.ts
181
+ export declare function parseYarnLock(text: string): Lockfile;
182
+ //#endregion
183
+ //#region src/index.d.ts
184
+ /**
185
+ * Parse a lockfile by passing the already read contents or the path to it.
186
+ *
187
+ * @param input The contents of the lockfile or the path to it.
188
+ * @returns A `Promise` that resolves to a `Lockfile` object.
189
+ */
190
+ export declare function parseLockfile(input: string): Promise<Lockfile>;
191
+ /**
192
+ * Synchronous version of {@link parseLockfile}.
193
+ *
194
+ * @param input The contents of the lockfile or the path to it.
195
+ * @returns The parsed `Lockfile`.
196
+ */
197
+ export declare function parseLockfileSync(input: string): Lockfile;
198
+ /**
199
+ * Finds a lockfile in `startDir`, and optionally in its parent directories.
200
+ *
201
+ * @param startDir The directory to start searching from. Defaults to cwd.
202
+ * @param options.searchParents Also look in each parent directory, e.g. to find the root lockfile of a workspace. Defaults to `false`.
203
+ * @returns The absolute path of the closest lockfile, or `undefined` if none is found.
204
+ */
205
+ export declare function findLockfile(startDir?: string, options?: {
206
+ searchParents?: boolean;
207
+ }): Promise<string | undefined>;
208
+ /**
209
+ * Synchronous version of {@link findLockfile}.
210
+ *
211
+ * @param startDir The directory to start searching from. Defaults to cwd.
212
+ * @param options.searchParents Also look in each parent directory, e.g. to find the root lockfile of a workspace. Defaults to `false`.
213
+ * @returns The absolute path of the closest lockfile, or `undefined` if none is found.
214
+ */
215
+ export declare function findLockfileSync(startDir?: string, options?: {
216
+ searchParents?: boolean;
217
+ }): string | undefined;
218
+ /**
219
+ * Finds a lockfile with {@link findLockfile} and parses it with {@link parseLockfile}.
220
+ *
221
+ * @param startDir The directory to start searching from. Defaults to cwd.
222
+ * @param options.searchParents Also look in each parent directory. Defaults to `false`.
223
+ * @returns The parsed `Lockfile`, or `undefined` if no lockfile is found.
224
+ */
225
+ export declare function findAndParseLock(startDir?: string, options?: {
226
+ searchParents?: boolean;
227
+ }): Promise<Lockfile | undefined>;
228
+ /**
229
+ * Synchronous version of {@link findAndParseLock}.
230
+ *
231
+ * @param startDir The directory to start searching from. Defaults to cwd.
232
+ * @param options.searchParents Also look in each parent directory. Defaults to `false`.
233
+ * @returns The parsed `Lockfile`, or `undefined` if no lockfile is found.
234
+ */
235
+ export declare function findAndParseLockSync(startDir?: string, options?: {
236
+ searchParents?: boolean;
237
+ }): Lockfile | undefined;
238
+ //#endregion
239
+ export type { DependencyType, GetAllOptions, Importer, LockEntry, LockfileJSON, PackageManager, Source, TraverseOptions };
package/dist/index.js ADDED
@@ -0,0 +1,773 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { parse } from "yaml";
5
+ //#region src/lockfile.ts
6
+ const serialize = (value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, item instanceof Set ? [...item] : item]));
7
+ const keyOf = (name, version) => `${name}@${version}`;
8
+ const both = (a, b) => a === void 0 ? b : b === void 0 ? a : a && b;
9
+ var Lockfile = class {
10
+ kind;
11
+ /** All packages, keyed by `name@version`. */
12
+ entries = /* @__PURE__ */ new Map();
13
+ /** The root project and its workspace packages, keyed by path. The root has the key `''`. */
14
+ importers = /* @__PURE__ */ new Map();
15
+ /** Version of the lockfile format, if the lockfile has one. */
16
+ lockfileVersion;
17
+ /** Reverse of `dependencies`. It is built when needed and reset when an entry is added. */
18
+ #dependents;
19
+ /** @param kind The package manager that wrote the lockfile. */
20
+ constructor(kind) {
21
+ this.kind = kind;
22
+ }
23
+ add(name, version, extra = {}) {
24
+ this.#dependents = void 0;
25
+ const key = keyOf(name, version);
26
+ let entry = this.entries.get(key);
27
+ if (!entry) {
28
+ entry = {
29
+ name,
30
+ version,
31
+ dependencies: /* @__PURE__ */ new Set(),
32
+ optionalDependencies: /* @__PURE__ */ new Set(),
33
+ peerDependencies: /* @__PURE__ */ new Set(),
34
+ ranges: {}
35
+ };
36
+ this.entries.set(key, entry);
37
+ }
38
+ const { dependencies, optionalDependencies, peerDependencies, ranges, dev, optional, peer, ...scalars } = extra;
39
+ const target = entry;
40
+ for (const [field, value] of Object.entries(scalars)) if (value !== void 0) target[field] ??= value;
41
+ entry.dev = both(entry.dev, dev);
42
+ entry.optional = both(entry.optional, optional);
43
+ entry.peer = both(entry.peer, peer);
44
+ for (const dep of dependencies ?? []) entry.dependencies.add(dep);
45
+ for (const dep of optionalDependencies ?? []) entry.optionalDependencies.add(dep);
46
+ for (const dep of peerDependencies ?? []) entry.peerDependencies.add(dep);
47
+ for (const [dep, range] of Object.entries(ranges ?? {})) entry.ranges[dep] ??= range;
48
+ return entry;
49
+ }
50
+ importer(path) {
51
+ let importer = this.importers.get(path);
52
+ if (!importer) {
53
+ importer = {
54
+ path,
55
+ dependencies: /* @__PURE__ */ new Set(),
56
+ devDependencies: /* @__PURE__ */ new Set(),
57
+ optionalDependencies: /* @__PURE__ */ new Set(),
58
+ peerDependencies: /* @__PURE__ */ new Set()
59
+ };
60
+ this.importers.set(path, importer);
61
+ }
62
+ return importer;
63
+ }
64
+ /**
65
+ * Returns the package with this name and version, or `undefined`.
66
+ *
67
+ * @param name The name of the package.
68
+ * @param version The version of the package.
69
+ * @returns The lock entry for the specified package name and version, or `undefined` if it does not exist.
70
+ */
71
+ get(name, version) {
72
+ return this.entries.get(keyOf(name, version));
73
+ }
74
+ /**
75
+ * Returns all entries of a package, one for each version.
76
+ *
77
+ * @param name The name of the package.
78
+ * @returns An array of all entries for the specified package name.
79
+ */
80
+ entriesOf(name) {
81
+ return this.filter((e) => e.name === name);
82
+ }
83
+ /**
84
+ * Returns all versions of a package.
85
+ *
86
+ * @param name The name of the package.
87
+ * @returns An array of all versions for the specified package name.
88
+ */
89
+ versionsOf(name) {
90
+ return this.filter((e) => e.name === name).map((e) => e.version);
91
+ }
92
+ /** Number of packages in the lockfile. */
93
+ get size() {
94
+ return this.entries.size;
95
+ }
96
+ [Symbol.iterator]() {
97
+ return this.entries.values();
98
+ }
99
+ /** Returns all packages for which `predicate` is true. */
100
+ filter(predicate) {
101
+ return [...this.entries.values()].filter(predicate);
102
+ }
103
+ /**
104
+ * Returns all packages. Set a type to `false` to leave out the packages of that type.
105
+ * A package is left out if it has any of the types that are `false`.
106
+ * For example, `getAll({ dev: false, optional: false })` returns what a production install needs.
107
+ *
108
+ * **Note:** All yarn packages count as `prod`.
109
+ */
110
+ getAll(options = {}) {
111
+ const { prod = true, dev = true, optional = true, peer = true } = options;
112
+ return this.filter((entry) => {
113
+ const isOtherType = entry.dev || entry.optional || entry.peer;
114
+ if (!prod && !isOtherType) return false;
115
+ return !(!dev && entry.dev || !optional && entry.optional || !peer && entry.peer);
116
+ });
117
+ }
118
+ /** Returns the packages that `entry` depends on. Dependencies that are not in the lockfile are left out. */
119
+ dependenciesOf(entry) {
120
+ return [...entry.dependencies].flatMap((key) => this.entries.get(key) ?? []);
121
+ }
122
+ /** Returns the packages that depend on the given package. */
123
+ dependentsOf(name, version) {
124
+ if (!this.#dependents) {
125
+ this.#dependents = /* @__PURE__ */ new Map();
126
+ for (const entry of this.entries.values()) for (const key of entry.dependencies) {
127
+ const list = this.#dependents.get(key);
128
+ if (list) list.push(entry);
129
+ else this.#dependents.set(key, [entry]);
130
+ }
131
+ }
132
+ return this.#dependents.get(keyOf(name, version)) ?? [];
133
+ }
134
+ /**
135
+ * Visits every package that can be reached from the start, once each. The order is not defined.
136
+ * Without `from` it starts at the dependencies of the importers, so it finds nothing for yarn classic.
137
+ */
138
+ *traverse(options = {}) {
139
+ const { dev = true, optional = true } = options;
140
+ const stack = [...options.from ?? this.#directDependencies(dev, optional)];
141
+ const seen = /* @__PURE__ */ new Set();
142
+ for (let entry = stack.pop(); entry; entry = stack.pop()) {
143
+ if (seen.has(entry)) continue;
144
+ seen.add(entry);
145
+ yield entry;
146
+ for (const key of entry.dependencies) {
147
+ if (!optional && entry.optionalDependencies.has(key)) continue;
148
+ const dependency = this.entries.get(key);
149
+ if (dependency) stack.push(dependency);
150
+ }
151
+ }
152
+ }
153
+ /** Returns the packages with the same name that are installed in more than one version. */
154
+ duplicates() {
155
+ const byName = /* @__PURE__ */ new Map();
156
+ for (const entry of this.entries.values()) {
157
+ const list = byName.get(entry.name);
158
+ if (list) list.push(entry);
159
+ else byName.set(entry.name, [entry]);
160
+ }
161
+ return new Map([...byName].filter(([, list]) => list.length > 1));
162
+ }
163
+ /**
164
+ * Explains why a package is installed. Returns the chains of packages that lead to it.
165
+ * Each chain starts with a package an importer depends on and ends with the given package.
166
+ * The shortest chains come first. The result is empty if the package is missing or if there are no importers (yarn classic).
167
+ *
168
+ * @param options.limit The most chains to return. Defaults to `50`.
169
+ */
170
+ why(name, version, options = {}) {
171
+ const { limit = 50 } = options;
172
+ const target = this.get(name, version);
173
+ if (!target) return [];
174
+ const roots = new Set(this.#directDependencies(true, true));
175
+ const chains = [];
176
+ const queue = [[target]];
177
+ for (let i = 0; i < queue.length && chains.length < limit; i++) {
178
+ const path = queue[i];
179
+ const head = path[path.length - 1];
180
+ if (roots.has(head)) chains.push([...path].reverse());
181
+ for (const parent of this.dependentsOf(head.name, head.version)) if (!path.includes(parent)) queue.push([...path, parent]);
182
+ }
183
+ return chains;
184
+ }
185
+ /** Returns a plain data representation that `JSON.stringify` can handle. */
186
+ toJSON() {
187
+ return {
188
+ kind: this.kind,
189
+ lockfileVersion: this.lockfileVersion,
190
+ entries: Object.fromEntries([...this.entries].map(([key, entry]) => [key, serialize(entry)])),
191
+ importers: Object.fromEntries([...this.importers].map(([path, importer]) => [path, serialize(importer)]))
192
+ };
193
+ }
194
+ #directDependencies(dev, optional) {
195
+ const keys = /* @__PURE__ */ new Set();
196
+ for (const importer of this.importers.values()) {
197
+ for (const key of importer.dependencies) keys.add(key);
198
+ for (const key of importer.peerDependencies) keys.add(key);
199
+ if (dev) for (const key of importer.devDependencies) keys.add(key);
200
+ if (optional) for (const key of importer.optionalDependencies) keys.add(key);
201
+ }
202
+ return [...keys].flatMap((key) => this.entries.get(key) ?? []);
203
+ }
204
+ };
205
+ //#endregion
206
+ //#region src/util.ts
207
+ const asList = (value) => value === void 0 ? void 0 : Array.isArray(value) ? value : [value];
208
+ function asBin(bin, name) {
209
+ if (typeof bin === "string") return { [name.slice(name.lastIndexOf("/") + 1)]: bin };
210
+ return bin && typeof bin === "object" ? bin : void 0;
211
+ }
212
+ const asLicense = (license) => typeof license === "string" ? license : void 0;
213
+ const GIT_URL = /^(?:git(?:\+|:)|github:|gitlab:|bitbucket:|gist:)|\.git(?:#|$)|codeload\.github\.com/;
214
+ /** Registry tarballs have a `/-/` path segment; other http(s) URLs are treated as plain tarballs. */
215
+ function sourceOfUrl(url) {
216
+ if (url.startsWith("file:")) return "file";
217
+ if (GIT_URL.test(url)) return "git";
218
+ return url.includes("/-/") ? "registry" : "tarball";
219
+ }
220
+ /**
221
+ * Fills `dev` and `optional` where the lockfile did not record them, by walking the dependency graph from the importers.
222
+ * `dev`: not reachable from any non-dev root. `optional`: not reachable without crossing an optional edge.
223
+ */
224
+ function inferScopes(lockfile) {
225
+ const importers = [...lockfile.importers.values()];
226
+ if (importers.length === 0) return;
227
+ const reach = (roots, skipOptionalEdges) => {
228
+ const seen = /* @__PURE__ */ new Set();
229
+ const stack = [...roots];
230
+ for (let key = stack.pop(); key !== void 0; key = stack.pop()) {
231
+ const entry = lockfile.entries.get(key);
232
+ if (!entry || seen.has(key)) continue;
233
+ seen.add(key);
234
+ for (const dep of entry.dependencies) if (!(skipOptionalEdges && entry.optionalDependencies.has(dep))) stack.push(dep);
235
+ }
236
+ return seen;
237
+ };
238
+ const prod = reach(importers.flatMap((i) => [
239
+ ...i.dependencies,
240
+ ...i.optionalDependencies,
241
+ ...i.peerDependencies
242
+ ]), false);
243
+ const required = reach(importers.flatMap((i) => [
244
+ ...i.dependencies,
245
+ ...i.devDependencies,
246
+ ...i.peerDependencies
247
+ ]), true);
248
+ for (const [key, entry] of lockfile.entries) {
249
+ entry.dev ??= !prod.has(key);
250
+ entry.optional ??= !required.has(key);
251
+ }
252
+ }
253
+ //#endregion
254
+ //#region src/bun.ts
255
+ function identify(ident) {
256
+ const at = ident.indexOf("@", 1);
257
+ if (at <= 0) return void 0;
258
+ const spec = ident.slice(at + 1);
259
+ if (spec.startsWith("npm:")) return identify(spec.slice(4));
260
+ return /^\d/.test(spec) ? {
261
+ name: ident.slice(0, at),
262
+ version: spec
263
+ } : void 0;
264
+ }
265
+ const infoOf = (pkg) => pkg.slice(1, 3).find((x) => x && typeof x === "object" && !Array.isArray(x)) ?? {};
266
+ /** Splits a lockfile key such as `@scope/a/b` into its package segments `@scope/a`, `b`. */
267
+ function segments(key) {
268
+ const parts = key.split("/");
269
+ const result = [];
270
+ for (let i = 0; i < parts.length; i++) result.push(parts[i].startsWith("@") && i + 1 < parts.length ? `${parts[i]}/${parts[++i]}` : parts[i]);
271
+ return result;
272
+ }
273
+ function parseBunLock(text) {
274
+ const doc = parse(text);
275
+ if (!doc?.packages) throw new Error("bun.lock has no packages section");
276
+ const keyByPath = /* @__PURE__ */ new Map();
277
+ for (const [lockKey, pkg] of Object.entries(doc.packages)) {
278
+ const id = identify(pkg[0]);
279
+ if (id) keyByPath.set(lockKey, keyOf(id.name, id.version));
280
+ }
281
+ const resolve = (parent, dep) => {
282
+ for (let base = parent;; base = base.slice(0, -1)) {
283
+ const target = keyByPath.get([...base, dep].join("/"));
284
+ if (target) return target;
285
+ if (base.length === 0) return void 0;
286
+ }
287
+ };
288
+ const resolveAll = (parent, names) => {
289
+ const keys = /* @__PURE__ */ new Set();
290
+ for (const dep of names) {
291
+ const key = resolve(parent, dep);
292
+ if (key) keys.add(key);
293
+ }
294
+ return keys;
295
+ };
296
+ const lockfile = new Lockfile("bun");
297
+ if (doc.lockfileVersion !== void 0) lockfile.lockfileVersion = String(doc.lockfileVersion);
298
+ for (const [lockKey, pkg] of Object.entries(doc.packages)) {
299
+ const id = identify(pkg[0]);
300
+ if (!id) continue;
301
+ const info = infoOf(pkg);
302
+ const parent = segments(lockKey);
303
+ const optionalDependencies = resolveAll(parent, [...Object.keys(info.optionalDependencies ?? {}), ...info.optionalPeers ?? []]);
304
+ const peerDependencies = resolveAll(parent, Object.keys(info.peerDependencies ?? {}));
305
+ const integrity = typeof pkg[3] === "string" && pkg[3].startsWith("sha") ? pkg[3] : void 0;
306
+ lockfile.add(id.name, id.version, {
307
+ integrity,
308
+ source: "registry",
309
+ bin: asBin(info.bin, id.name),
310
+ os: asList(info.os),
311
+ cpu: asList(info.cpu),
312
+ libc: asList(info.libc),
313
+ dependencies: /* @__PURE__ */ new Set([
314
+ ...resolveAll(parent, Object.keys(info.dependencies ?? {})),
315
+ ...optionalDependencies,
316
+ ...peerDependencies
317
+ ]),
318
+ optionalDependencies,
319
+ peerDependencies,
320
+ ranges: {
321
+ ...info.peerDependencies,
322
+ ...info.optionalDependencies,
323
+ ...info.dependencies
324
+ }
325
+ });
326
+ }
327
+ for (const [workspacePath, workspace] of Object.entries(doc.workspaces ?? {})) {
328
+ const importer = lockfile.importer(workspacePath);
329
+ importer.name = workspace.name;
330
+ importer.version = workspace.version;
331
+ const parent = workspacePath && workspace.name ? [workspace.name] : [];
332
+ importer.dependencies = resolveAll(parent, Object.keys(workspace.dependencies ?? {}));
333
+ importer.devDependencies = resolveAll(parent, Object.keys(workspace.devDependencies ?? {}));
334
+ importer.optionalDependencies = resolveAll(parent, Object.keys(workspace.optionalDependencies ?? {}));
335
+ importer.peerDependencies = resolveAll(parent, Object.keys(workspace.peerDependencies ?? {}));
336
+ }
337
+ inferScopes(lockfile);
338
+ return lockfile;
339
+ }
340
+ //#endregion
341
+ //#region src/npm.ts
342
+ const LAST_PACKAGE_SEGMENT = /(?:^|\/)node_modules\/(?:@[^/]+\/)?[^/]+$/;
343
+ const isInstalled = (pkgPath) => pkgPath.includes("node_modules/");
344
+ function sourceOf$1(pkgPath, info) {
345
+ if (!isInstalled(pkgPath)) return "workspace";
346
+ return info.resolved ? sourceOfUrl(info.resolved) : void 0;
347
+ }
348
+ function parseNpmLock(text) {
349
+ const json = JSON.parse(text);
350
+ if (!json.packages) throw new Error("package-lock.json lockfileVersion 1 is not supported; regenerate it with npm >= 7");
351
+ const lockfile = new Lockfile("npm");
352
+ if (json.lockfileVersion !== void 0) lockfile.lockfileVersion = String(json.lockfileVersion);
353
+ const keyByPath = /* @__PURE__ */ new Map();
354
+ for (const [pkgPath, info] of Object.entries(json.packages)) {
355
+ if (!pkgPath || info.link) continue;
356
+ const name = info.name ?? pkgPath.slice(pkgPath.lastIndexOf("node_modules/") + 13);
357
+ if (!name || !info.version) continue;
358
+ keyByPath.set(pkgPath, keyOf(name, info.version));
359
+ }
360
+ const resolve = (pkgPath, dep) => {
361
+ let base = pkgPath;
362
+ for (;;) {
363
+ const target = keyByPath.get(`${base ? `${base}/` : ""}node_modules/${dep}`);
364
+ if (target) return target;
365
+ if (!base) return void 0;
366
+ const parent = base.replace(LAST_PACKAGE_SEGMENT, "");
367
+ base = parent === base ? "" : parent;
368
+ }
369
+ };
370
+ const resolveAll = (pkgPath, deps) => {
371
+ const keys = /* @__PURE__ */ new Set();
372
+ for (const dep of Object.keys(deps ?? {})) {
373
+ const key = resolve(pkgPath, dep);
374
+ if (key) keys.add(key);
375
+ }
376
+ return keys;
377
+ };
378
+ for (const [pkgPath, info] of Object.entries(json.packages)) {
379
+ const key = keyByPath.get(pkgPath);
380
+ if (!key || !info.version) continue;
381
+ const name = key.slice(0, key.length - info.version.length - 1);
382
+ const optionalDependencies = resolveAll(pkgPath, info.optionalDependencies);
383
+ const peerDependencies = resolveAll(pkgPath, info.peerDependencies);
384
+ lockfile.add(name, info.version, {
385
+ integrity: info.integrity,
386
+ resolved: info.resolved,
387
+ source: sourceOf$1(pkgPath, info),
388
+ dev: info.dev === true || info.devOptional === true,
389
+ optional: info.optional === true || info.devOptional === true,
390
+ peer: info.peer === true,
391
+ license: asLicense(info.license),
392
+ bin: asBin(info.bin, name),
393
+ engines: Array.isArray(info.engines) ? void 0 : info.engines,
394
+ os: asList(info.os),
395
+ cpu: asList(info.cpu),
396
+ libc: asList(info.libc),
397
+ hasInstallScript: info.hasInstallScript,
398
+ deprecated: info.deprecated,
399
+ dependencies: /* @__PURE__ */ new Set([
400
+ ...resolveAll(pkgPath, info.dependencies),
401
+ ...optionalDependencies,
402
+ ...peerDependencies
403
+ ]),
404
+ optionalDependencies,
405
+ peerDependencies,
406
+ ranges: {
407
+ ...info.peerDependencies,
408
+ ...info.optionalDependencies,
409
+ ...info.dependencies
410
+ }
411
+ });
412
+ }
413
+ for (const [pkgPath, info] of Object.entries(json.packages)) {
414
+ if (info.link || isInstalled(pkgPath)) continue;
415
+ const importer = lockfile.importer(pkgPath);
416
+ importer.name = info.name;
417
+ importer.version = info.version;
418
+ importer.dependencies = resolveAll(pkgPath, info.dependencies);
419
+ importer.devDependencies = resolveAll(pkgPath, info.devDependencies);
420
+ importer.optionalDependencies = resolveAll(pkgPath, info.optionalDependencies);
421
+ importer.peerDependencies = resolveAll(pkgPath, info.peerDependencies);
422
+ }
423
+ return lockfile;
424
+ }
425
+ //#endregion
426
+ //#region src/pnpm.ts
427
+ const NUMERIC = /^\d/;
428
+ /** Splits a package or snapshot key into its name and spec; the spec is a version unless the package is not from a registry. */
429
+ function parseKey(rawKey) {
430
+ const s = (rawKey.startsWith("/") ? rawKey.slice(1) : rawKey).replace(/\(.*$/, "");
431
+ const at = s.indexOf("@", 1);
432
+ if (at <= 0) return void 0;
433
+ const spec = s.slice(at + 1);
434
+ return {
435
+ name: s.slice(0, at),
436
+ spec: NUMERIC.test(spec) ? spec.split("_")[0] : spec
437
+ };
438
+ }
439
+ function sourceOf(spec, resolution) {
440
+ if (NUMERIC.test(spec)) return "registry";
441
+ if (resolution?.type === "git" || resolution?.repo) return "git";
442
+ if (resolution?.type === "directory" || resolution?.directory) return "file";
443
+ return sourceOfUrl(spec);
444
+ }
445
+ function parsePnpmLock(text) {
446
+ const doc = parse(text);
447
+ if (!doc || typeof doc !== "object" || doc.lockfileVersion === void 0) throw new Error("Not a pnpm-lock.yaml: lockfileVersion is missing");
448
+ if (Number.parseFloat(String(doc.lockfileVersion)) < 6) throw new Error(`pnpm-lock.yaml lockfileVersion ${doc.lockfileVersion} is not supported; use pnpm >= 8`);
449
+ const keyBySpec = /* @__PURE__ */ new Map();
450
+ const metas = /* @__PURE__ */ new Map();
451
+ for (const [rawKey, info] of Object.entries(doc.packages ?? {})) {
452
+ const id = parseKey(rawKey);
453
+ const version = id && (NUMERIC.test(id.spec) ? id.spec : info.version);
454
+ if (!id || !version) continue;
455
+ keyBySpec.set(`${id.name}@${id.spec}`, keyOf(id.name, String(version)));
456
+ metas.set(`${id.name}@${id.spec}`, info);
457
+ }
458
+ const resolveDependency = (depName, rawValue) => {
459
+ const value = String(rawValue).replace(/\(.*$/, "");
460
+ if (value.startsWith("link:")) return void 0;
461
+ if (NUMERIC.test(value)) return keyOf(depName, value.split("_")[0]);
462
+ const alias = value.startsWith("/") || value.indexOf("@", 1) > 0 ? parseKey(value) : void 0;
463
+ if (alias && NUMERIC.test(alias.spec)) return keyOf(alias.name, alias.spec);
464
+ return keyBySpec.get(alias ? `${alias.name}@${alias.spec}` : `${depName}@${value}`);
465
+ };
466
+ const lockfile = new Lockfile("pnpm");
467
+ if (doc.lockfileVersion !== void 0) lockfile.lockfileVersion = String(doc.lockfileVersion);
468
+ for (const [rawKey, info] of Object.entries(doc.snapshots ?? doc.packages ?? {})) {
469
+ const id = parseKey(rawKey);
470
+ if (!id) continue;
471
+ const meta = metas.get(`${id.name}@${id.spec}`);
472
+ const key = keyBySpec.get(`${id.name}@${id.spec}`);
473
+ if (!key || !meta) continue;
474
+ const version = key.slice(id.name.length + 1);
475
+ const resolved = /* @__PURE__ */ new Map();
476
+ const optionalDependencies = /* @__PURE__ */ new Set();
477
+ for (const [depName, value] of Object.entries(info.dependencies ?? {})) {
478
+ const target = resolveDependency(depName, value);
479
+ if (target) resolved.set(depName, target);
480
+ }
481
+ for (const [depName, value] of Object.entries(info.optionalDependencies ?? {})) {
482
+ const target = resolveDependency(depName, value);
483
+ if (!target) continue;
484
+ resolved.set(depName, target);
485
+ optionalDependencies.add(target);
486
+ }
487
+ const peerDependencies = /* @__PURE__ */ new Set();
488
+ for (const peer of Object.keys(meta.peerDependencies ?? {})) {
489
+ const target = resolved.get(peer);
490
+ if (target) peerDependencies.add(target);
491
+ }
492
+ const resolution = meta.resolution;
493
+ lockfile.add(id.name, version, {
494
+ integrity: resolution?.integrity,
495
+ resolved: resolution?.tarball ?? resolution?.directory ?? (resolution?.repo ? `${resolution.repo}#${resolution.commit}` : void 0),
496
+ source: sourceOf(id.spec, resolution),
497
+ dev: meta.dev,
498
+ optional: info.optional === true || meta.optional === true,
499
+ engines: meta.engines,
500
+ os: asList(meta.os),
501
+ cpu: asList(meta.cpu),
502
+ libc: asList(meta.libc),
503
+ hasInstallScript: info.requiresBuild ?? meta.requiresBuild,
504
+ deprecated: meta.deprecated,
505
+ dependencies: resolved.values(),
506
+ optionalDependencies,
507
+ peerDependencies,
508
+ ranges: meta.peerDependencies
509
+ });
510
+ }
511
+ const importers = doc.importers ?? (doc.dependencies || doc.devDependencies || doc.optionalDependencies ? { ".": doc } : {});
512
+ const resolveImporterDeps = (deps) => {
513
+ const keys = /* @__PURE__ */ new Set();
514
+ for (const [depName, value] of Object.entries(deps ?? {})) {
515
+ const target = resolveDependency(depName, typeof value === "string" ? value : value.version);
516
+ if (target) keys.add(target);
517
+ }
518
+ return keys;
519
+ };
520
+ for (const [importerPath, info] of Object.entries(importers)) {
521
+ const importer = lockfile.importer(importerPath === "." ? "" : importerPath);
522
+ importer.dependencies = resolveImporterDeps(info.dependencies);
523
+ importer.devDependencies = resolveImporterDeps(info.devDependencies);
524
+ importer.optionalDependencies = resolveImporterDeps(info.optionalDependencies);
525
+ }
526
+ inferScopes(lockfile);
527
+ return lockfile;
528
+ }
529
+ //#endregion
530
+ //#region src/yarn.ts
531
+ const nameOfDescriptor = (descriptor) => descriptor.slice(0, descriptor.indexOf("@", 1));
532
+ function link(lockfile, entries, keyByDescriptor, candidates) {
533
+ const resolve = (name, range) => candidates(name, range).map((c) => keyByDescriptor.get(c)).find(Boolean);
534
+ for (const [descriptor, info] of entries) {
535
+ if (!keyByDescriptor.get(descriptor) || !info.version) continue;
536
+ const { dependencies, optionalDependencies } = resolveDependencies(info, resolve);
537
+ lockfile.add(nameOfDescriptor(descriptor), info.version, {
538
+ dependencies,
539
+ optionalDependencies,
540
+ ranges: {
541
+ ...info.dependencies,
542
+ ...info.optionalDependencies
543
+ }
544
+ });
545
+ }
546
+ }
547
+ function resolveDependencies(info, resolve) {
548
+ const dependencies = /* @__PURE__ */ new Set();
549
+ const optionalDependencies = /* @__PURE__ */ new Set();
550
+ const add = (deps, optional) => {
551
+ for (const [dep, range] of Object.entries(deps ?? {})) {
552
+ const target = resolve(dep, range);
553
+ if (!target) continue;
554
+ dependencies.add(target);
555
+ if (optional(dep)) optionalDependencies.add(target);
556
+ }
557
+ };
558
+ add(info.dependencies, (name) => info.dependenciesMeta?.[name]?.optional === true);
559
+ add(info.optionalDependencies, () => true);
560
+ return {
561
+ dependencies,
562
+ optionalDependencies
563
+ };
564
+ }
565
+ const QUOTED = String.raw`"(?:[^"\\]|\\.)*"`;
566
+ const DESCRIPTOR = new RegExp(`${QUOTED}|[^\\s,]+`, "g");
567
+ const SECTION = new RegExp(`^(${QUOTED}|[^\\s:]+):$`);
568
+ const FIELD = new RegExp(`^(${QUOTED}|\\S+)\\s+(.*)$`);
569
+ const unquote = (value) => value.startsWith("\"") ? JSON.parse(value) : value;
570
+ /** Parses the yarn v1 format: `descriptor, descriptor:` headers followed by indented `key value` lines and `key:` sections. */
571
+ function parseClassicText(text) {
572
+ const entries = {};
573
+ let entry;
574
+ let section;
575
+ for (const rawLine of text.split(/\r?\n/)) {
576
+ const line = rawLine.trim();
577
+ if (!line || line.startsWith("#")) continue;
578
+ const indent = rawLine.length - rawLine.trimStart().length;
579
+ if (indent === 0) {
580
+ entry = {};
581
+ section = void 0;
582
+ for (const [descriptor] of line.replace(/:$/, "").matchAll(DESCRIPTOR)) entries[unquote(descriptor)] = entry;
583
+ } else if (!entry) throw new Error("yarn.lock is not valid: found a field outside of an entry");
584
+ else if (indent === 2) {
585
+ const sectionName = SECTION.exec(line)?.[1];
586
+ if (sectionName) {
587
+ section = {};
588
+ entry[unquote(sectionName)] = section;
589
+ continue;
590
+ }
591
+ const [, key, value] = FIELD.exec(line) ?? [];
592
+ if (key && value !== void 0) entry[unquote(key)] = unquote(value);
593
+ } else if (section) {
594
+ const [, name, range] = FIELD.exec(line) ?? [];
595
+ if (name && range !== void 0) section[unquote(name)] = unquote(range);
596
+ }
597
+ }
598
+ return entries;
599
+ }
600
+ function parseClassic(text) {
601
+ const object = parseClassicText(text);
602
+ const lockfile = new Lockfile("yarn");
603
+ lockfile.lockfileVersion = "1";
604
+ const keyByDescriptor = /* @__PURE__ */ new Map();
605
+ const entries = Object.entries(object);
606
+ for (const [descriptor, info] of entries) {
607
+ if (!info.version) continue;
608
+ const name = nameOfDescriptor(descriptor);
609
+ keyByDescriptor.set(descriptor, keyOf(name, info.version));
610
+ lockfile.add(name, info.version, {
611
+ integrity: info.integrity,
612
+ resolved: info.resolved,
613
+ source: info.resolved ? sourceOfUrl(info.resolved) : void 0
614
+ });
615
+ }
616
+ link(lockfile, entries, keyByDescriptor, (name, range) => [`${name}@${range}`]);
617
+ return lockfile;
618
+ }
619
+ /** Returns undefined for entries that are not installed packages (patches, links, generated). */
620
+ function berrySource(spec) {
621
+ if (spec.startsWith("npm:")) return "registry";
622
+ if (/^(?:patch|portal|link|exec):/.test(spec)) return void 0;
623
+ return sourceOfUrl(spec);
624
+ }
625
+ function parseConditions(conditions) {
626
+ const result = {};
627
+ for (const [, field, value] of (conditions ?? "").matchAll(/\b(os|cpu|libc)=([^\s&|]+)/g)) (result[field] ??= []).push(value);
628
+ return result;
629
+ }
630
+ function parseBerry(text) {
631
+ const doc = parse(text);
632
+ const lockfile = new Lockfile("yarn");
633
+ const metadata = doc.__metadata;
634
+ if (metadata?.version !== void 0) lockfile.lockfileVersion = String(metadata.version);
635
+ const keyByDescriptor = /* @__PURE__ */ new Map();
636
+ const entries = [];
637
+ const workspaces = [];
638
+ for (const [descriptors, info] of Object.entries(doc)) {
639
+ if (descriptors === "__metadata" || !info.version || !info.resolution) continue;
640
+ const name = nameOfDescriptor(info.resolution);
641
+ const spec = info.resolution.slice(name.length + 1);
642
+ if (spec.startsWith("workspace:")) {
643
+ workspaces.push([descriptors, info]);
644
+ continue;
645
+ }
646
+ const source = berrySource(spec);
647
+ if (!source) continue;
648
+ for (const descriptor of descriptors.split(",").map((d) => d.trim())) {
649
+ keyByDescriptor.set(descriptor, keyOf(name, info.version));
650
+ entries.push([descriptor, info]);
651
+ }
652
+ lockfile.add(name, info.version, {
653
+ checksum: info.checksum,
654
+ resolved: source === "registry" ? void 0 : spec,
655
+ source,
656
+ bin: asBin(info.bin, name),
657
+ ...parseConditions(info.conditions)
658
+ });
659
+ }
660
+ const candidates = (name, range) => [`${name}@${range}`, `${name}@npm:${range}`];
661
+ link(lockfile, entries, keyByDescriptor, candidates);
662
+ for (const [, info] of workspaces) {
663
+ const name = nameOfDescriptor(info.resolution);
664
+ const workspacePath = info.resolution.slice(name.length + 11);
665
+ const importer = lockfile.importer(workspacePath === "." ? "" : workspacePath);
666
+ importer.name = name;
667
+ if (!info.version.startsWith("0.0.0-use.local")) importer.version = info.version;
668
+ const { dependencies, optionalDependencies } = resolveDependencies(info, (dep, range) => candidates(dep, range).map((c) => keyByDescriptor.get(c)).find(Boolean));
669
+ for (const key of optionalDependencies) dependencies.delete(key);
670
+ importer.dependencies = dependencies;
671
+ importer.optionalDependencies = optionalDependencies;
672
+ }
673
+ return lockfile;
674
+ }
675
+ function parseYarnLock(text) {
676
+ return /^__metadata:/m.test(text) ? parseBerry(text) : parseClassic(text);
677
+ }
678
+ //#endregion
679
+ //#region src/index.ts
680
+ const PARSER_BY_FILENAME = {
681
+ "package-lock.json": parseNpmLock,
682
+ "npm-shrinkwrap.json": parseNpmLock,
683
+ "pnpm-lock.yaml": parsePnpmLock,
684
+ "yarn.lock": parseYarnLock,
685
+ "bun.lock": parseBunLock
686
+ };
687
+ function detectParser(text) {
688
+ if (text.trimStart().startsWith("{")) try {
689
+ const { packages } = JSON.parse(text);
690
+ const first = Object.values(packages ?? {})[0];
691
+ return Array.isArray(first) ? parseBunLock : parseNpmLock;
692
+ } catch {
693
+ return parseBunLock;
694
+ }
695
+ return /^lockfileVersion:/m.test(text) ? parsePnpmLock : parseYarnLock;
696
+ }
697
+ const isContent = (input) => input.includes("\n") || input.trimStart().startsWith("{");
698
+ function parseText(text, file) {
699
+ return (file && PARSER_BY_FILENAME[path.basename(file)] || detectParser(text))(text);
700
+ }
701
+ function* nextFile(startDir, searchParents) {
702
+ for (let dir = path.resolve(startDir);; dir = path.dirname(dir)) {
703
+ for (const name of Object.keys(PARSER_BY_FILENAME)) yield path.join(dir, name);
704
+ if (!searchParents || path.dirname(dir) === dir) return;
705
+ }
706
+ }
707
+ /**
708
+ * Parse a lockfile by passing the already read contents or the path to it.
709
+ *
710
+ * @param input The contents of the lockfile or the path to it.
711
+ * @returns A `Promise` that resolves to a `Lockfile` object.
712
+ */
713
+ async function parseLockfile(input) {
714
+ if (isContent(input)) return parseText(input);
715
+ return parseText(await readFile(input, "utf8"), input);
716
+ }
717
+ /**
718
+ * Synchronous version of {@link parseLockfile}.
719
+ *
720
+ * @param input The contents of the lockfile or the path to it.
721
+ * @returns The parsed `Lockfile`.
722
+ */
723
+ function parseLockfileSync(input) {
724
+ if (isContent(input)) return parseText(input);
725
+ return parseText(readFileSync(input, "utf8"), input);
726
+ }
727
+ /**
728
+ * Finds a lockfile in `startDir`, and optionally in its parent directories.
729
+ *
730
+ * @param startDir The directory to start searching from. Defaults to cwd.
731
+ * @param options.searchParents Also look in each parent directory, e.g. to find the root lockfile of a workspace. Defaults to `false`.
732
+ * @returns The absolute path of the closest lockfile, or `undefined` if none is found.
733
+ */
734
+ async function findLockfile(startDir = process.cwd(), options = {}) {
735
+ for (const file of nextFile(startDir, options.searchParents ?? false)) try {
736
+ await access(file);
737
+ return file;
738
+ } catch {}
739
+ }
740
+ /**
741
+ * Synchronous version of {@link findLockfile}.
742
+ *
743
+ * @param startDir The directory to start searching from. Defaults to cwd.
744
+ * @param options.searchParents Also look in each parent directory, e.g. to find the root lockfile of a workspace. Defaults to `false`.
745
+ * @returns The absolute path of the closest lockfile, or `undefined` if none is found.
746
+ */
747
+ function findLockfileSync(startDir = process.cwd(), options = {}) {
748
+ for (const file of nextFile(startDir, options.searchParents ?? false)) if (existsSync(file)) return file;
749
+ }
750
+ /**
751
+ * Finds a lockfile with {@link findLockfile} and parses it with {@link parseLockfile}.
752
+ *
753
+ * @param startDir The directory to start searching from. Defaults to cwd.
754
+ * @param options.searchParents Also look in each parent directory. Defaults to `false`.
755
+ * @returns The parsed `Lockfile`, or `undefined` if no lockfile is found.
756
+ */
757
+ async function findAndParseLock(startDir, options) {
758
+ const file = await findLockfile(startDir, options);
759
+ return file === void 0 ? void 0 : parseLockfile(file);
760
+ }
761
+ /**
762
+ * Synchronous version of {@link findAndParseLock}.
763
+ *
764
+ * @param startDir The directory to start searching from. Defaults to cwd.
765
+ * @param options.searchParents Also look in each parent directory. Defaults to `false`.
766
+ * @returns The parsed `Lockfile`, or `undefined` if no lockfile is found.
767
+ */
768
+ function findAndParseLockSync(startDir, options) {
769
+ const file = findLockfileSync(startDir, options);
770
+ return file === void 0 ? void 0 : parseLockfileSync(file);
771
+ }
772
+ //#endregion
773
+ export { Lockfile, findAndParseLock, findAndParseLockSync, findLockfile, findLockfileSync, keyOf, parseBunLock, parseLockfile, parseLockfileSync, parseNpmLock, parsePnpmLock, parseYarnLock };
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@lofi-js/parser",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A typescript lockfile parser. Parse lockfiles of any package manager (npm, pnpm, yarn, bun).",
5
+ "keywords": [
6
+ "bun.lock",
7
+ "lockfile",
8
+ "package-lock",
9
+ "parser",
10
+ "pnpm-lock",
11
+ "yarn.lock"
12
+ ],
13
+ "homepage": "https://github.com/lofi-js/parser#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/lofi-js/parser/issues"
16
+ },
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/lofi-js/parser.git"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public",
35
+ "provenance": true
36
+ },
37
+ "scripts": {
38
+ "build": "tsdown",
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "vitest run",
41
+ "lint": "oxlint",
42
+ "format": "oxfmt",
43
+ "format:check": "oxfmt --check",
44
+ "prepublishOnly": "npm run build && npm test"
45
+ },
46
+ "dependencies": {
47
+ "yaml": "^2.9.1"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^26.6.5",
51
+ "oxfmt": "^0.73.0",
52
+ "oxlint": "^1.88.0",
53
+ "tsdown": "^0.23.1",
54
+ "typescript": "^7.0.2",
55
+ "vitest": "^5.0.4"
56
+ },
57
+ "engines": {
58
+ "node": ">=20"
59
+ }
60
+ }