From dc82d545a6a98093c81be2da1e4d5caaa0753ce9 Mon Sep 17 00:00:00 2001 From: indexzero Date: Tue, 14 Apr 2026 09:48:36 -0400 Subject: [PATCH 1/5] feat(npm) add fromDependenciesTree for lockfileVersion 1 support fromPackageLock only iterates the v2/v3 packages map. v1 lockfiles store deps in a nested dependencies tree and produced zero results. fromDependenciesTree walks the v1 tree iteratively (depth-first) and yields the same Dependency shape. fromPackageLock falls back to it when packages is empty and dependencies exists. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.js | 3 +- src/parsers/index.js | 1 + src/parsers/npm.js | 71 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 06d3a65..3a7b2d0 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import { detectType, Type } from './detect.js'; import { + fromDependenciesTree, fromPackageLock, fromPnpmLock, fromYarnBerryLock, @@ -25,7 +26,7 @@ export { Type, detectType }; export { Ok, Err }; // Re-export individual parsers -export { fromPackageLock, fromPnpmLock, fromYarnClassicLock, fromYarnBerryLock }; +export { fromDependenciesTree, fromPackageLock, fromPnpmLock, fromYarnClassicLock, fromYarnBerryLock }; // Re-export FlatlockSet class export { FlatlockSet }; diff --git a/src/parsers/index.js b/src/parsers/index.js index 272b25f..36fe731 100644 --- a/src/parsers/index.js +++ b/src/parsers/index.js @@ -5,6 +5,7 @@ export { buildWorkspacePackages as buildNpmWorkspacePackages, extractWorkspacePaths as extractNpmWorkspacePaths, + fromDependenciesTree, fromPackageLock, parseLockfileKey as parseNpmKey } from './npm.js'; diff --git a/src/parsers/npm.js b/src/parsers/npm.js index 19359b5..d12f267 100644 --- a/src/parsers/npm.js +++ b/src/parsers/npm.js @@ -122,6 +122,12 @@ export function parseLockfileKey(path) { /** * Parse npm package-lock.json (v1, v2, v3) + * + * v2/v3 lockfiles use the `packages` map (flat, path-keyed). + * v1 lockfiles use the `dependencies` tree (nested, name-keyed). + * When `packages` is empty or absent and `dependencies` exists, + * falls back to `fromDependenciesTree`. + * * @param {string | object} input - Lockfile content string or pre-parsed object * @param {Object} [_options] - Parser options (unused, reserved for future use) * @returns {Generator} @@ -130,6 +136,21 @@ export function* fromPackageLock(input, _options = {}) { const lockfile = typeof input === 'string' ? JSON.parse(input) : input; const packages = lockfile.packages || {}; + // Check if the packages map has any non-root entries + let hasPackageEntries = false; + for (const path of Object.keys(packages)) { + if (path !== '') { + hasPackageEntries = true; + break; + } + } + + // v1 fallback: no packages entries but has dependencies tree + if (!hasPackageEntries && lockfile.dependencies) { + yield* fromDependenciesTree(lockfile); + return; + } + for (const [path, pkg] of Object.entries(packages)) { // Skip root package if (path === '') continue; @@ -155,6 +176,56 @@ export function* fromPackageLock(input, _options = {}) { } } +/** + * Parse npm lockfileVersion 1 dependencies tree. + * + * v1 lockfiles store dependencies as a nested object tree where each key + * is a package name and each value contains { version, resolved, integrity, + * requires, dependencies }. Nested `dependencies` represent version conflicts + * that couldn't be hoisted. + * + * @param {string | object} input - Lockfile content string or pre-parsed object + * @param {Object} [_options] - Parser options (unused, reserved for future use) + * @returns {Generator} + */ +export function* fromDependenciesTree(input, _options = {}) { + const lockfile = typeof input === 'string' ? JSON.parse(input) : input; + const dependencies = lockfile.dependencies; + if (!dependencies) return; + + // Iterative depth-first walk to avoid stack overflow on deep trees + /** @type {Array<[string, object]>} */ + const stack = []; + + // Push in reverse order so first entries are yielded first + const topLevel = Object.entries(dependencies); + for (let i = topLevel.length - 1; i >= 0; i--) { + stack.push(topLevel[i]); + } + + while (stack.length > 0) { + const [name, info] = /** @type {[string, any]} */ (stack.pop()); + const { version, integrity, resolved } = info; + + if (name && version) { + /** @type {Dependency} */ + const dep = { name, version }; + if (integrity) dep.integrity = integrity; + if (resolved) dep.resolved = resolved; + yield dep; + } + + // Push nested dependencies (conflict resolution overrides) + const nested = info.dependencies; + if (nested) { + const entries = Object.entries(nested); + for (let i = entries.length - 1; i >= 0; i--) { + stack.push(entries[i]); + } + } + } +} + /** * Extract workspace paths from npm lockfile. * From e22cdf029644f5baebd091b16abda9aa447d4f0e Mon Sep 17 00:00:00 2001 From: indexzero Date: Tue, 14 Apr 2026 09:48:41 -0400 Subject: [PATCH 2/5] test(npm) add v1 lockfile and fromDependenciesTree coverage Replace the assertion that v1 returns empty with tests proving v1 now yields deps. Add 12 tests for fromDependenciesTree covering flat deps, nested conflict resolution, scoped packages, string vs object input, and fallback delegation from fromPackageLock. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/parsers/npm.test.js | 231 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 222 insertions(+), 9 deletions(-) diff --git a/test/parsers/npm.test.js b/test/parsers/npm.test.js index 61a4954..2e40da8 100644 --- a/test/parsers/npm.test.js +++ b/test/parsers/npm.test.js @@ -2,12 +2,9 @@ * @fileoverview Comprehensive tests for npm lockfile parsers * * Tests cover npm package-lock.json formats: - * - v1 (legacy dependencies format - NOT supported, returns empty) + * - v1 (legacy dependencies tree format, parsed via fromDependenciesTree) * - v2 (current format with packages field) * - v3 (same as v2, optimized for npm 7+) - * - * Note: This parser only supports v2/v3 format (packages field). - * v1 format uses dependencies field and is not supported. */ import assert from 'node:assert/strict'; @@ -17,7 +14,7 @@ import { describe, test } from 'node:test'; import { fileURLToPath } from 'node:url'; // Public API -import { fromPackageLock, parseLockfileKey } from '../../src/parsers/npm.js'; +import { fromDependenciesTree, fromPackageLock, parseLockfileKey } from '../../src/parsers/npm.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const decodedDir = join(__dirname, '..', 'decoded', 'npm'); @@ -125,13 +122,22 @@ describe('npm parsers', () => { // ============================================================================ describe('fromPackageLock', () => { describe('[npm-01] version detection', () => { - test('returns empty for v1 format (uses dependencies, not packages)', () => { + test('parses v1 format via dependencies tree fallback', () => { const content = loadFixture('package-lock.json.v1'); const deps = [...fromPackageLock(content)]; - // v1 format uses dependencies field, not packages - // Our parser only supports v2/v3 (packages field) - assert.equal(deps.length, 0, 'v1 format should return empty (not supported)'); + // v1 format falls back to fromDependenciesTree + assert.ok(deps.length > 0, `v1 should yield deps, got ${deps.length}`); + + // Every dep should have name and version + for (const dep of deps) { + assert.ok(dep.name, 'Every dep should have name'); + assert.ok(dep.version, 'Every dep should have version'); + } + + // Most deps should have integrity (the v1 fixture has them) + const withIntegrity = deps.filter(d => d.integrity); + assert.ok(withIntegrity.length > deps.length * 0.9, 'Most deps should have integrity'); }); test('parses v2 format', () => { @@ -390,4 +396,211 @@ describe('npm parsers', () => { }); }); }); + + // ============================================================================ + // fromDependenciesTree tests (v1 lockfile support) + // ============================================================================ + describe('fromDependenciesTree', () => { + test('parses flat dependencies', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + lodash: { + version: '4.17.21', + resolved: 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz', + integrity: 'sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ==' + }, + express: { + version: '4.18.0', + resolved: 'https://registry.npmjs.org/express/-/express-4.18.0.tgz' + } + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 2); + assert.equal(deps[0].name, 'lodash'); + assert.equal(deps[0].version, '4.17.21'); + assert.equal(deps[0].resolved, 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz'); + assert.ok(deps[0].integrity); + assert.equal(deps[1].name, 'express'); + assert.equal(deps[1].version, '4.18.0'); + }); + + test('walks nested dependencies recursively', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + base: { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/base/-/base-1.0.0.tgz', + dependencies: { + 'define-property': { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz', + dependencies: { + 'is-descriptor': { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.0.tgz' + } + } + } + } + } + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 3); + + const names = deps.map(d => d.name); + assert.ok(names.includes('base')); + assert.ok(names.includes('define-property')); + assert.ok(names.includes('is-descriptor')); + }); + + test('skips entries without version', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + lodash: { version: '4.17.21' }, + broken: {} + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 1); + assert.equal(deps[0].name, 'lodash'); + }); + + test('handles empty dependencies object', () => { + const lockfile = { lockfileVersion: 1, dependencies: {} }; + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 0); + }); + + test('handles missing dependencies field', () => { + const lockfile = { lockfileVersion: 1 }; + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 0); + }); + + test('accepts JSON string input', () => { + const content = JSON.stringify({ + lockfileVersion: 1, + dependencies: { + lodash: { version: '4.17.21' } + } + }); + + const deps = [...fromDependenciesTree(content)]; + assert.equal(deps.length, 1); + }); + + test('yields resolved when present', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + lodash: { + version: '4.17.21', + resolved: 'https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz' + } + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps[0].resolved, 'https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz'); + }); + + test('omits resolved when absent', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + lodash: { version: '4.17.21' } + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps[0].resolved, undefined); + }); + + test('parses v1 fixture with correct count', () => { + const content = loadFixture('package-lock.json.v1'); + const deps = [...fromDependenciesTree(content)]; + + // The v1 fixture (meteor-guide) has 345 top-level + 273 nested = 618 total + assert.ok(deps.length > 300, `Expected >300 deps, got ${deps.length}`); + + // Verify structure + for (const dep of deps) { + assert.ok(dep.name, 'Every dep should have name'); + assert.ok(dep.version, 'Every dep should have version'); + } + + // Most should have resolved + const withResolved = deps.filter(d => d.resolved); + assert.ok(withResolved.length > deps.length * 0.9, 'Most deps should have resolved'); + }); + + test('fromPackageLock delegates to fromDependenciesTree for v1', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + lodash: { + version: '4.17.21', + resolved: 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz' + } + } + }; + + // fromPackageLock should fall back to fromDependenciesTree + const viaPL = [...fromPackageLock(lockfile)]; + const viaDT = [...fromDependenciesTree(lockfile)]; + + assert.equal(viaPL.length, viaDT.length); + assert.deepEqual( + viaPL.map(d => `${d.name}@${d.version}`), + viaDT.map(d => `${d.name}@${d.version}`) + ); + }); + + test('fromPackageLock prefers packages over dependencies for v2', () => { + // v2 lockfiles have both packages and dependencies + // fromPackageLock should use packages, not dependencies + const lockfile = { + lockfileVersion: 2, + packages: { + '': { name: 'root', version: '1.0.0' }, + 'node_modules/lodash': { version: '4.17.21' } + }, + dependencies: { + lodash: { version: '4.17.20' } // different version to prove packages wins + } + }; + + const deps = [...fromPackageLock(lockfile)]; + assert.equal(deps.length, 1); + assert.equal(deps[0].version, '4.17.21', 'Should use packages version, not dependencies'); + }); + + test('handles scoped packages in v1 format', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + '@babel/core': { + version: '7.23.0', + resolved: 'https://registry.npmjs.org/@babel/core/-/core-7.23.0.tgz' + }, + '@types/node': { + version: '20.0.0' + } + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 2); + assert.ok(deps.some(d => d.name === '@babel/core')); + assert.ok(deps.some(d => d.name === '@types/node')); + }); + }); }); From dbe8cb6479e7624c7e334894a14b0edaa62b85e8 Mon Sep 17 00:00:00 2001 From: indexzero Date: Tue, 14 Apr 2026 10:18:39 -0400 Subject: [PATCH 3/5] doc(changelog) add unreleased entry for v1 lockfile support Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index d41e3c3..09bf685 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 🆕 Added +- **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. + ## [1.5.1] - 2026-03-16 ### 🐛 Fixed From 27bd4f0ae516d86ac4825b2713e7db6a67529363 Mon Sep 17 00:00:00 2001 From: indexzero Date: Tue, 14 Apr 2026 10:19:48 -0400 Subject: [PATCH 4/5] doc(readme) add link field to Output Format type definition The Dependency type has included resolved and link since they were added to types.js, but the README output example omitted link. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d03acf..ca6a154 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,8 @@ Each yielded package has: name: string; // Package name (e.g., "@babel/core") version: string; // Resolved version (e.g., "7.23.0") integrity?: string; // Integrity hash (sha512, sha384, sha256, sha1) - resolved?: string; // Download URL + resolved?: string; // Download URL (registry or private) + link?: boolean; // True if this is a workspace symlink } ``` From 3cdb80993ea7f53aa989b6d867e3b510a95fd2f7 Mon Sep 17 00:00:00 2001 From: indexzero Date: Tue, 14 Apr 2026 10:20:45 -0400 Subject: [PATCH 5/5] nit(format) fix biome line-length violation in re-export Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 3a7b2d0..42d49a0 100644 --- a/src/index.js +++ b/src/index.js @@ -26,7 +26,13 @@ export { Type, detectType }; export { Ok, Err }; // Re-export individual parsers -export { fromDependenciesTree, fromPackageLock, fromPnpmLock, fromYarnClassicLock, fromYarnBerryLock }; +export { + fromDependenciesTree, + fromPackageLock, + fromPnpmLock, + fromYarnClassicLock, + fromYarnBerryLock +}; // Re-export FlatlockSet class export { FlatlockSet };