Skip to content

Commit 8df0a6b

Browse files
indexzeroclaude
andauthored
feat(npm) add lockfileVersion 1 support via fromDependenciesTree (#20)
Add npm lockfileVersion 1 lockfile support fromPackageLock only iterated the v2/v3 `packages` map, so v1 lockfiles silently produced zero results. v1 lockfiles store dependencies in a nested tree keyed by package name rather than a flat path-keyed map. Add fromDependenciesTree, an iterative depth-first generator that walks the v1 dependencies tree and yields the same Dependency shape. fromPackageLock falls back to it when `packages` is empty and `dependencies` exists. Also add 12 tests for fromDependenciesTree covering flat deps, nested conflict resolution, scoped packages, and string vs object input; update README with the link field; and add a CHANGELOG entry. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 978a33e commit 8df0a6b

6 files changed

Lines changed: 307 additions & 11 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ Each yielded package has:
108108
name: string; // Package name (e.g., "@babel/core")
109109
version: string; // Resolved version (e.g., "7.23.0")
110110
integrity?: string; // Integrity hash (sha512, sha384, sha256, sha1)
111-
resolved?: string; // Download URL
111+
resolved?: string; // Download URL (registry or private)
112+
link?: boolean; // True if this is a workspace symlink
112113
}
113114
```
114115

doc/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### 🆕 Added
11+
- **npm lockfileVersion 1 support**: `fromPackageLock` now parses v1 lockfiles by falling back to a new `fromDependenciesTree` generator when the `packages` map is absent. v1 lockfiles use a nested `dependencies` tree instead of the flat `packages` map — `fromDependenciesTree` walks the tree iteratively and yields the same `Dependency` shape. The README already claimed v1 support; the parser now delivers on it.
12+
1013
## [1.5.1] - 2026-03-16
1114

1215
### 🐛 Fixed

src/index.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readFile } from 'node:fs/promises';
22
import { detectType, Type } from './detect.js';
33
import {
4+
fromDependenciesTree,
45
fromPackageLock,
56
fromPnpmLock,
67
fromYarnBerryLock,
@@ -25,7 +26,13 @@ export { Type, detectType };
2526
export { Ok, Err };
2627

2728
// Re-export individual parsers
28-
export { fromPackageLock, fromPnpmLock, fromYarnClassicLock, fromYarnBerryLock };
29+
export {
30+
fromDependenciesTree,
31+
fromPackageLock,
32+
fromPnpmLock,
33+
fromYarnClassicLock,
34+
fromYarnBerryLock
35+
};
2936

3037
// Re-export FlatlockSet class
3138
export { FlatlockSet };

src/parsers/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
export {
66
buildWorkspacePackages as buildNpmWorkspacePackages,
77
extractWorkspacePaths as extractNpmWorkspacePaths,
8+
fromDependenciesTree,
89
fromPackageLock,
910
parseLockfileKey as parseNpmKey
1011
} from './npm.js';

src/parsers/npm.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ export function parseLockfileKey(path) {
122122

123123
/**
124124
* Parse npm package-lock.json (v1, v2, v3)
125+
*
126+
* v2/v3 lockfiles use the `packages` map (flat, path-keyed).
127+
* v1 lockfiles use the `dependencies` tree (nested, name-keyed).
128+
* When `packages` is empty or absent and `dependencies` exists,
129+
* falls back to `fromDependenciesTree`.
130+
*
125131
* @param {string | object} input - Lockfile content string or pre-parsed object
126132
* @param {Object} [_options] - Parser options (unused, reserved for future use)
127133
* @returns {Generator<Dependency>}
@@ -130,6 +136,21 @@ export function* fromPackageLock(input, _options = {}) {
130136
const lockfile = typeof input === 'string' ? JSON.parse(input) : input;
131137
const packages = lockfile.packages || {};
132138

139+
// Check if the packages map has any non-root entries
140+
let hasPackageEntries = false;
141+
for (const path of Object.keys(packages)) {
142+
if (path !== '') {
143+
hasPackageEntries = true;
144+
break;
145+
}
146+
}
147+
148+
// v1 fallback: no packages entries but has dependencies tree
149+
if (!hasPackageEntries && lockfile.dependencies) {
150+
yield* fromDependenciesTree(lockfile);
151+
return;
152+
}
153+
133154
for (const [path, pkg] of Object.entries(packages)) {
134155
// Skip root package
135156
if (path === '') continue;
@@ -155,6 +176,56 @@ export function* fromPackageLock(input, _options = {}) {
155176
}
156177
}
157178

179+
/**
180+
* Parse npm lockfileVersion 1 dependencies tree.
181+
*
182+
* v1 lockfiles store dependencies as a nested object tree where each key
183+
* is a package name and each value contains { version, resolved, integrity,
184+
* requires, dependencies }. Nested `dependencies` represent version conflicts
185+
* that couldn't be hoisted.
186+
*
187+
* @param {string | object} input - Lockfile content string or pre-parsed object
188+
* @param {Object} [_options] - Parser options (unused, reserved for future use)
189+
* @returns {Generator<Dependency>}
190+
*/
191+
export function* fromDependenciesTree(input, _options = {}) {
192+
const lockfile = typeof input === 'string' ? JSON.parse(input) : input;
193+
const dependencies = lockfile.dependencies;
194+
if (!dependencies) return;
195+
196+
// Iterative depth-first walk to avoid stack overflow on deep trees
197+
/** @type {Array<[string, object]>} */
198+
const stack = [];
199+
200+
// Push in reverse order so first entries are yielded first
201+
const topLevel = Object.entries(dependencies);
202+
for (let i = topLevel.length - 1; i >= 0; i--) {
203+
stack.push(topLevel[i]);
204+
}
205+
206+
while (stack.length > 0) {
207+
const [name, info] = /** @type {[string, any]} */ (stack.pop());
208+
const { version, integrity, resolved } = info;
209+
210+
if (name && version) {
211+
/** @type {Dependency} */
212+
const dep = { name, version };
213+
if (integrity) dep.integrity = integrity;
214+
if (resolved) dep.resolved = resolved;
215+
yield dep;
216+
}
217+
218+
// Push nested dependencies (conflict resolution overrides)
219+
const nested = info.dependencies;
220+
if (nested) {
221+
const entries = Object.entries(nested);
222+
for (let i = entries.length - 1; i >= 0; i--) {
223+
stack.push(entries[i]);
224+
}
225+
}
226+
}
227+
}
228+
158229
/**
159230
* Extract workspace paths from npm lockfile.
160231
*

0 commit comments

Comments
 (0)